Время и часовые пояса в Java
Различные операции с датой и временем, а заодно и связанные проблемы, возникают, как правило, уже в коммерческой разработке. В процессе обучения, изучения алгоритмов, языка, фреймворков и технологий, этой теме не уделяют особого внимания. С одной стороны, время - это ведь тип данных, который не намного сложнее целого числа, но явно проще строк, массивов, стримов и так далее. Все карты путают часовые пояса (time zones). На каждом проекте раз за разом в базе данных или на страницах сайтов отовсюду вылезают смещённые значения. То на час вперед, то на два назад. В иной раз вместо 00:00 дня сегодняшнего получаем 23:00 дня вчерашнего.
Проблемам этим не видно конца и края. Программисты на Java отчаянно расставляют по коду конструкции наподобие TimeZone.setDefault(TimeZone.getTimeZone("UTC")), и периодически это помогает, но не всегда и хуже того, не навсегда. Разработчики со временем привыкают к неотвратимости проблем с часовыми поясами и стараются избегать их решения без необходимости. Вспоминаю задачу "Время в заголовке сайта сдвинуто на три часа", которая годами откладывалась как не приоритетная, пока проект не закрылся. Таким образом сформировалось общепринятое отношение к этой теме: "Timezones are hell".
Если в базе данных даты уже "поехали", то исправить всё практически невозможно, только построить заново. Но при правильном и аккуратном подходе с самого начала проблем можно избежать. Принцип достаточно простой - нужно избавиться от всех неявных часовых поясов и всегда указывать правильный в особых местах. Эти места - точки перехода даты из состояния "С таймзоной" и состояния "БЕЗ таймзоны". Думаю, звучит уже непонятно и усложняется ещё тем, что в java 8 появился новый подход к работе с датой и временем, и к классу Date добавилось ещё примерно семь - десять новых классов. Постараюсь рассказать и разложить всё по полочкам.
Начну с небольшого обзора работы со временем в Java. Сначала до java 8. Дата и время с точностью до миллисекунд инкапсулированы классом java.util.Date. Разумно предположить, что это некий класс, к котором есть поля year, month, day, hour и так далее. Но нет, в Java, javascript и во многих других языках, Date - это число, long, 8 байт. Число миллисекунд с фиксированного момента времени: January 1, 1970, 00:00:00 GMT (в англоязычной терминологии, "the epoch"). Думаю, такая концепция была очень удобна в 1970 году, и небольшой целочисленной переменной хватало с лихвой, ведь тогда память была намного ценнее абстрактной правильности подхода (если уж так проектировать, то почему не дату 0000-01-01 взять за начало времён).
Операций с датой не так много - создать новый объект с помощью конструктора, сравнить на before/after, как-то преобразовать в строку. Все операции на получение и установку компонентов времени, таких как год, месяц, час и так далее, помечены как "deprecated". То есть использовать их нежелательно, а в будущих версиях Java они могут быть удалены совсем. Но важнее то, что из-за часовых поясов в общем случае они не могут работать корректно. Использовать их не нужно.
Несколько примеров:
Внимательный читатель обратил внимание, что результат преобразования к строке (метод Date.toString()), вывод которого указан в комментариях, содержит год, месяц, день и даже часовой пояс (MSK), что не сходится с тем, что Date - просто целое число. Дело в том, что внутри реализации toString() неявно используется текущий часовой пояс (TimeZone.getDefaultRef(), если покопаться в исходниках), что позволяет определить компоненты времени в этом поясе и отобразить в читаемом виде. Математически правильнее было бы всегда печатать число миллисекунд, но это абсолютно нечитаемо и неудобно. Вообще, преобразование даты в строку методом toString полезно только для отладочной печати. Для бизнес сценариев используются "форматтеры", например, SimpleDateFormat. Пример ниже.
Итак, Date - это число, его можно преобразовать в строку, указав таймзону. Такое преобразование обычно приводит к потере данных, в прошлом примере были утрачены миллисекунды. Можно использовать формат записи с той же точностью, не приводящий к потере данных, например, "2020-03-20T13:14:47.746Z", но на практике это избыточно: точность нужна обычно до секунд или даже до минут. Вообще, до тех пор пока дату не нужно куда-то передать или показать пользователю, сложностей с ней особо не возникает. А вот если нужно сохранить или получить значение из базы данных, либо передать через Rest сервис и показать на экране, тогда начинаются трудности.
Для операций с базой данных существуют два вспомогательных типа: java.sql.Date и java.sql.Timestamp. Последний позволяет указывать время с точностью до наносекунд с помощью метода setNanos(int n). В остальном их назначение подобно маркер интерфейсу - чтобы драйвер мог определить, к какому типу относится целевое поле в БД: DATE или TIMESTAMP, если это не указано явно. Мы будем стремиться всё указывать явно, чтобы драйвер и библиотеки не осуществляли никаких скрытых преобразований.
В качестве примера разберу небольшое приложение по управлению запланированными встречами. Код примера можно найти на github. Стек технологий: Java 11, Spring (Boot, Mvc, JdbcTemplate, Data Jpa, Test), Junit 5, H2 database (Oracle Syntax). Все классы в одном пакете, запускается локально классом TimezonesApplication, дополнительных настроек не требуется. Порт по умолчанию 8085 (приложение: http://localhost:8085/, h2 console: http://localhost:8085/h2-console). Выглядит так:
Кроме митингов, добавленных через браузер, на старте добавляется ещё три: один в скрипте инициализации БД, используя sysdate, и два из кода используя реализацию через JdbcTemplate и JPA, используя new Date() и OffsetDateTime.now(). Это сделано для того, чтобы убедиться, что все варианты реализации работают корректно и не смещают время ошибочно.
Данные о каждом митинге хранятся в классе MeetingDto, имеющим два поля для хранения времени. Значения в них совпадают, но одно реализовано с помощью Date, а другое - с помощью OffsetDateTime из java.time. Это сделано для иллюстрации работы в каждом из случаев, потому что не на всех проектах можно с сегодняшнего дня перестать использовать Date и полностью перейти на java.time. На странице выводятся оба значения, чтобы показать, что они совпадают и ни одно не сместилось в процессе сохранения или передачи. Кроме того, время выводится для текущего часового пояса, определённого браузером, и для Нью-Йорка. Полезно если работаете в распределённой команде, чтобы в уме постоянно не прибавлять или вычитать часы, а заодно не помнить о переходе на летнее и зимнее время. Сначала расскажу все подробности работы с Date, а потом - с OffsetDateTime, после обзора пакета java.time.
Я не буду описывать все компоненты приложения, лишь ключевые моменты, связанные с передачей и сохранением времени. Общая схема такова. Путь от браузера до базы данных:
- 1. Html: В поле input с типом type="datetime-local" с помощью встроенного селектора вводится дата и время
- 2. Javascript: После нажатия кнопки 'добавить' значение передаётся в конструктор javascript типа Date:
- 3. Javascript: После этого Date преобразовывается к стандартному формату toISOString (ISO 8601). Пример результата: 2020-03-28T10:00:00.000Z.
- 4. Javascript: Это значение оборачивается в объект, который методом JSON.stringify становится телом (body, request payload) POST запроса к REST сервису: /api/meeting/add.
- 5. Java: MeetingRestService: Spring преобразовывает тело запроса в класс MeetingDto, за это отвечает код:
- 6. Java: Библиотека jackson за счет правильного формата даты, совпадающего с тем, который использует javascript (ISO 8601), преобразовывает строку к классу Date. К слову, для OffsetDateTime всё работает так же.
- 7a. Java: сохранение в базу данных при помощи JdbcTemplate. Ключевым является передача объекта типа Calendar, содержащего информацию о таймзоне, в качестве дополнительного параметра при передаче даты в PreparedStatement. Этот параметр необязателен, и тогда будет неявно использована таймзона по умолчанию, что приведёт к ошибкам.
- 7б. Java: сохранение в базу данных, но при помощи JPA. Используется Spring Data Jpa, но можно было бы напрямую использовать EntityManager, изменения минимальные. Для @Entity используется отдельный класс, чтобы не смешивать аннотации Jackson и JPA и иметь больше гибкости. В этом случае мы не указываем таймзону при каждой передаче параметров, но указываем специальное свойство в конфигурации: hibernate.jdbc.time_zone.
Последний шаг, как и работа с базой данных в целом, требует пояснения. База данных H2 инициализируется при старте приложения и хранит данные в памяти. Такие базы данных как правило называют embedded database. При этом такую базу данных можно конфигурировать для имитации синтаксиса и поведения полноразмерных баз данных: Oracle, MySQL, PostgreSQL, MS SQL Server и т.д. Некоторые сильно специфичные функции работать не будут. В этом примере я использую синтаксис Oracle.
При проектировании таблицы MEETINGS нужно выбрать тип данных, в котором будет храниться время митинга, то есть дата и время с точностью до минут. В Oracle имеем как минимум следующие варианты:
Тип данных | Временная зона | Доли секунд |
---|---|---|
DATE | Нет | Нет |
TIMESTAMP | Нет | Да |
TIMESTAMP WITH TIME ZONE | Явный (Explicit) | Да |
TIMESTAMP WITH LOCAL TIME ZONE | Относительный (Relative) | Да |
Последние два типа я не рекомендую использовать, кроме случая если Вы эксперт в Oracle, таймзонах и точно знаете, зачем вам конкретный тип. В коммерческих проектах ни разу их не встречал, при использовании данных типов возможностей для ошибок станет сильно больше. Кроме того, эти типы данных поддерживаются не всеми СУБД. В MySQL, например, таких нет. На мой взгляд, лучший выбор - всегда использовать тип TIMESTAMP. В любой СУБД так или иначе будет присутствовать этот тип данных, и вести себя тоже будет примерно одинаково.
Тип данных DATE выглядит подходящим, но тоже может привести к путанице, такой, что провод от наушников в кармане позавидует. В Oracle тип DATE хранит время с точностью до секунд. Но при этом настройки по умолчанию популярного клиента Oracle Sql Developer показывают только месяц и день, создавая у пользователей неверное впечатление. Ни один человек в мире в такой ситуации не догадается сам, что время там на самом деле есть, нужно только перенастроить клиент. Но это не конец истории. В PraparedStatement есть два метода для передачи даты: setDate и setTimestamp. Если использовать setDate, то уже сам драйвер (по крайней мере, H2) обнуляет время и сохраняет только год-месяц-число. И нужно для типа дынных DATE использовать setTimestamp! DATE на практике используется, потому что при проектировании базы данных такой путаницы никто не ожидает, но я в этом примере всё же выбрал TIMESTAMP. В примере, к слову, в файле schema.sql можно поменять TIMESTAMP на DATE и ничего не сломается.
Структура БД хранится в файле schema.sql и инициализируется на старте приложения.
Мы выбрали тип TIMESTAMP, но что же он представляет из себя изнутри? Для себя я упростил модель и рассматриваю представление даты как одно из двух: число или строка. Даже если данные хранятся в специальном объекте с полями год, месяц, число и т.д., но там нет информации о смещении/таймзоне/часовом поясе, то по сути операций это - строка наподобие "1999-01-02 10:00.123". Мы не можем сказать, что это за момент времени, не зная таймзоны. TIMESTAMP именно так и работает. У СУБД в конфигурации прописан часовой пояс того сервера, на котором она запущена. Пользователь, который подключится клиентом (Squirrel, Oracle SQL Developer) и будет просматривать информацию посредством SQL запросов, увидит дату и время как будто в часовом поясе базы данных. Можно использовать запрос "select sysdate from dual", чтобы узнать текущее время и понять, как настроена база данных. Но в java у нас время в виде числа, а в базе - в виде строки - следовательно при чтении и записи будет происходить преобразование с использованием информации о таймзоне. Нужно сделать так, чтобы в каждом таком преобразовании таймзона была явно указана, а не использовалась бы та, что установлена по умолчанию в JVM.
Вернёмся к коду, который отвечает за само сохранение в базу данных. Вне зависимости от реализации можно выделить две подзадачи: первая - написать параметризованный запрос, вторая - передать все параметры. Параметризованный запрос может содержать знаки вопроса (?) в случае PreparedStatement, JdbcTemplate, либо именованные параметры в случае NamedParameterJdbcTemplate или javax.persistence.Query. В примере используется JdbcTemplate и сигнатура метода с параметром типа Calendar, указывающим на таймзону сервера баз данных. Вообще моделируется ситуация, в которой база данных находится в Нью-Йорке, а сервер приложения - в Москве.
Реализация ниже выглядит чуть короче и лаконичнее, причём у метода addValue нет сигнатуры, принимающей Calendar или TimeZone. Этот факт создаёт ошибочное впечатление, что всё правильно, ведь раз нельзя указать таймзону, значит она и не требуется. Но это не так, код ниже приводит к ошибочному смещению времени.
В случае, если доступ к базе данных осуществляется с помощью JPA/Hibernate, то достаточно в конфигурации указать свойство hibernate.jdbc.time_zone=America/New_York или в YAML: Радует, что технологии развиваются и код становится проще. Ранее в Hibernate приходилось реализовывать специальный тип, например, UtcTimestampType extends TimestampType, в реализации которого Calendar явно передавался параметром в PreparedStatement. При использовании Query также следовало избегать передачи Date и использовать Calendar. Вы наверняка встретите подобное в старом коде, но разбирать подробно сейчас я не буду. Сейчас, согласно данному примеру и моим тестам, свойства hibernate.jdbc.time_zone достаточно.
Следует отметить, что когда ведется локальная разработка и браузер, JVM, СУБД, операционная система и другие компоненты настроены на единый часовой пояс, ошибки не проявляются. И это только усугубляет ситуацию. Локально тесты проходят, а на Unix сервере, на котором запущен Jenkins - падают. В тестовой среде всё работает, а в продакшене с большим количеством серверов в разных датацентрах - проявляются ошибки. В коде примера я использую небольшой трюк, устанавливая таймзону по умолчанию в одно значение для инициализации H2, а после изменяю на другое - для операций чтения и сохранения. Такая конфигурация эмулирует базу данных на удалённом сервере.
Мы рассмотрели путь от браузера до базы данных, теперь рассмотрим обратный путь. Нужно найти в базе все митинги не старше одного часа и отобразить их в браузере по местному времени и по Нью-Йорку.
- 1a. Java: JdbcTemplate. Calendar передаётся в трёх местах. Принцип тот же: setTimestamp и getTimestamp должны иметь этот параметр в явном виде.
- 1б. Java: Spring Data JPA. Вся логика заключена в имени метода и, к счастью, IDEA даёт отличные подсказки по Ctrl+Space в процессе его написания. И таймзоны указывать не приходится, свойства в конфигурации достаточною.
- 2. Java:Библиотека Jackson преобразовывает объект в строку, которая будет передана по Http. Формат даты совпадает с ISO 8601, чтобы javascript смог его распарсить без необходимости указания формата.
- 3. Java: Spring MVC обрабатывает аннотацию @GetMapping, в результате чего будет создан сервлет, к которому браузер обратится с HTTP GET запросом (/api/meeting/actual).
- 4. Javascript: При загрузке страницы, а также после операций добавления или удаления, выполняется AJAX GET запрос. Javascript преобразовывает текст в формате JSON в объект, представляющий из себя массив объектов, соответствующих структуре класса MeetingDto.
- 5. Javascript: Результат вызова REST сервиса используется для наполнения таблицы при помощи динамического манипулирования DOM.
- 6. Javascript: Тип Date в javascript аналогичен классу Date в java и, чтобы отобразить его в читаемом виде, нужно отформатировать и указать таймзону, аналогично тому, как мы это делали с SimpleDateFormat. В javascript используется Intl. Часовой пояс пользователя определяется свойством Intl.DateTimeFormat().resolvedOptions().timeZone.
Теперь все компоненты связаны воедино, время и даты не смещаются. Корректность работы достигается за счёт того, что ни одно преобразование времени из числа в строку или эквивалентный тип не является неявным с использованием таймзоны по умолчанию. Вместо этого таймзона всегда указывается - для JPA в конфигурации, для JDBC - при вызове setTimestamp/getTimestamp. Классы Date и OffsetDateTime ведут себя одинаково.
Чтобы объяснить, почему из java.time я посчитал наиболее подходящим OffsetDateTime, а не LocalDateTime или ZonedDateTime, сделаю небольшой обзор классов пакета java.time, добавленного в Java 1.8.
java.time
За много лет существования класса Date разработчики разобрались, что к чему и отработали всевозможные сценарии его использования. Кто-то перешёл на использование библиотек, например, joda time. Тем не менее, в версии 1.8 появился полностью переработанный подход к работе с временем, датами и часовыми поясами. Теперь для каждой операции существует специально подобранный тип данных, а переходы между ними требуют явного указания недостающей информации. Новый подход намного лучше, но требует подробного изучения и понимания. Кроме того, миграция существующих приложений будет происходить ещё долго, и мы обречены видеть в коде смешение старого и нового подхода и бесчисленные преобразования туда и обратно. Впечатление от java.time сугубо положительное, но нюансы использования ещё предстоит отработать на практике.
Так как нам нужны и дата, и время, то я остановлюсь на типах Instant, LocalDateTime, ZonedDateTime и OffsetDateTime, а LocalDate, LocalTime и другие оставлю на другой случай. Первое отличие всех этих классов от Date в том, что точность увеличена с миллисекунд до наносекунд. Второе, основное - в разделении по сценариям использования. Выбирая правильный класс, мы скорее всего будем оперировать только с ним, а отдельные случаи преобразования к другим классам всегда будут явными.
- LocalDateTime
- Не указывает на конкретный момент времени. Хранит год, месяц, день, час, минуты, секунды и наносекунды. Основные операции - смещение, сравнение. Преобразование к Instant требует указания ZoneOffset.
- Instant
- Указывает на момент времени. Полностью аналогичен Date, только точность до наносекунд. Не позволяет определить компоненты времени без указания таймзоны. Часто требуется преобразовать к ZonedDateTime, для этого указываем ZoneId.
- ZonedDateTime / OffsetDateTime
- ZonedDateTime можно воспринимать как комбинацию трёх составляющих: момент времени (Instant), смещение (ZoneOffset) и правил вычисления времени (ZoneRules), отвечающих в основном за переход на зимнее и летнее время. OffsetDateTime - это только Instant + ZoneOffset. В конкретный момент времени разница невелика, но если при операциях сдвига времени (plus/minus) попасть на перевод часов, то получим разный результат. OffsetDateTime всё равно будет работать правильно, он не потеряет и не добавит час по ошибке, но после добавления 24 часов к 24 октября ZonedDateTime продолжает указывать правильное время по Лондону, а OffsetDateTime указывает на абстрактный часовой пояс со смещением +1 час.
Ещё одно важное отличие OffsetDateTime и ZonedDateTime в операциях сравнения: для первого equals вернёт true, если это тот же самый момент времени, для второго нужно, чтобы кроме этого совпало строковое представление таймзоны. Несколько примеров:
Для наглядности я сгруппировал все доступные ZoneId, соответствующие одному ZoneOffset в таблице:
Смещение | Список ZoneId с таким смещением (на момент epochMillis=0) |
---|---|
-12:00 | Pacific/Kwajalein, Pacific/Enderbury, Kwajalein, Etc/GMT+12 |
-11:30 | Pacific/Niue |
-11:00 | Pacific/Pago_Pago, Pacific/Fakaofo, Pacific/Samoa, Pacific/Apia, America/Atka, US/Samoa, America/Adak, US/Aleutian, Etc/GMT+11, America/Nome, Pacific/Midway |
-10:40 | Pacific/Kiritimati |
-10:30 | Pacific/Rarotonga |
-10:00 | Pacific/Honolulu, US/Alaska, Pacific/Tahiti, Pacific/Johnston, US/Hawaii, America/Anchorage, SystemV/HST10, Etc/GMT+10 |
-09:30 | Pacific/Marquesas |
-09:00 | Etc/GMT+9, Pacific/Gambier, America/Yakutat, America/Dawson, SystemV/YST9, SystemV/YST9YDT |
-08:30 | Pacific/Pitcairn |
-08:00 | Etc/GMT+8, Canada/Yukon, US/Pacific-New, Mexico/BajaSur, America/Dawson_Creek, America/Juneau, America/Metlakatla, America/Inuvik, Canada/Pacific, PST8PDT, America/Mazatlan, Mexico/BajaNorte, America/Sitka, America/Tijuana, SystemV/PST8, America/Hermosillo, America/Bahia_Banderas, America/Santa_Isabel, America/Vancouver, America/Ensenada, America/Whitehorse, America/Fort_Nelson, SystemV/PST8PDT, America/Los_Angeles, US/Pacific |
-07:00 | Etc/GMT+7, US/Arizona, America/Denver, America/Yellowknife, America/Swift_Current, SystemV/MST7, America/Boise, America/North_Dakota/Beulah, MST7MDT, America/North_Dakota/Center, US/Mountain, America/Creston, America/Edmonton, Canada/Mountain, America/Cambridge_Bay, Navajo, America/Phoenix, SystemV/MST7MDT, America/North_Dakota/New_Salem, America/Shiprock |
-06:00 | America/El_Salvador, America/Guatemala, America/Belize, America/Managua, America/Indiana/Petersburg, America/Chicago, America/Tegucigalpa, Etc/GMT+6, Pacific/Easter, America/Regina, Mexico/General, America/Rankin_Inlet, US/Central, America/Rainy_River, America/Costa_Rica, America/Indiana/Knox, America/Monterrey, SystemV/CST6, America/Kentucky/Monticello, America/Chihuahua, America/Ojinaga, Chile/EasterIsland, America/Mexico_City, America/Matamoros, CST6CDT, America/Knox_IN, America/Resolute, Canada/Central, America/Cancun, US/Indiana-Starke, SystemV/CST6CDT, America/Merida, Canada/Saskatchewan, America/Winnipeg |
-05:00 | America/Panama, America/Eirunepe, America/Grand_Turk, Cuba, Etc/GMT+5, America/Fort_Wayne, America/Havana, America/Porto_Acre, US/Michigan, America/Louisville, America/Guayaquil, Pacific/Galapagos, America/Indiana/Vevay, America/Indiana/Vincennes, America/Indianapolis, America/Iqaluit, America/Kentucky/Louisville, EST5EDT, America/Nassau, America/Jamaica, America/Atikokan, America/Coral_Harbour, America/Cayman, America/Indiana/Tell_City, America/Indiana/Indianapolis, America/Thunder_Bay, America/Indiana/Marengo, America/Bogota, America/Menominee, SystemV/EST5, US/Eastern, Canada/Eastern, America/Port-au-Prince, America/Nipigon, Brazil/Acre, US/East-Indiana, America/Lima, America/Rio_Branco, America/Detroit, Jamaica, America/Montreal, America/Indiana/Winamac, America/New_York, America/Toronto, SystemV/EST5EDT |
-04:30 | America/Santo_Domingo |
-04:00 | America/Cuiaba, America/Marigot, America/Miquelon, Canada/Atlantic, Etc/GMT+4, America/Manaus, America/St_Thomas, America/Anguilla, America/Barbados, America/Curacao, America/Martinique, America/Puerto_Rico, America/Port_of_Spain, SystemV/AST4, America/Kralendijk, America/Antigua, America/Moncton, America/St_Vincent, America/Dominica, America/Santarem, America/Asuncion, Atlantic/Bermuda, Atlantic/Stanley, Brazil/West, America/Aruba, America/Halifax, America/La_Paz, America/Blanc-Sablon, America/Glace_Bay, America/St_Barthelemy, America/St_Lucia, America/Montserrat, America/Lower_Princes, America/Thule, America/Tortola, America/Porto_Velho, America/Campo_Grande, America/Goose_Bay, America/Virgin, America/Pangnirtung, America/Boa_Vista, America/Grenada, America/St_Kitts, America/Caracas, America/Guadeloupe, SystemV/AST4ADT |
-03:45 | America/Guyana |
-03:30 | America/St_Johns, America/Paramaribo, Canada/Newfoundland |
-03:00 | Chile/Continental, America/Argentina/Catamarca, America/Argentina/Cordoba, America/Araguaina, America/Argentina/Salta, Etc/GMT+3, America/Montevideo, Brazil/East, America/Argentina/Mendoza, America/Argentina/Rio_Gallegos, America/Catamarca, America/Godthab, America/Cordoba, America/Sao_Paulo, America/Argentina/Jujuy, America/Cayenne, America/Recife, America/Buenos_Aires, America/Mendoza, America/Maceio, America/Argentina/San_Luis, America/Santiago, America/Argentina/Ushuaia, Antarctica/Palmer, America/Punta_Arenas, America/Fortaleza, America/Danmarkshavn, America/Argentina/La_Rioja, America/Belem, America/Jujuy, America/Bahia, America/Argentina/San_Juan, America/Argentina/ComodRivadavia, America/Argentina/Tucuman, America/Rosario, America/Argentina/Buenos_Aires |
-02:00 | Etc/GMT+2, Atlantic/Cape_Verde, America/Noronha, Brazil/DeNoronha, Atlantic/South_Georgia, America/Scoresbysund |
-01:00 | Etc/GMT+1, Atlantic/Azores, Africa/El_Aaiun, Africa/Bissau |
-00:44:30 | Africa/Monrovia |
Z | GMT, Etc/GMT-0, Atlantic/St_Helena, Etc/GMT+0, Africa/Banjul, Etc/GMT, Africa/Freetown, Africa/Algiers, Africa/Bamako, Africa/Conakry, Universal, Africa/Sao_Tome, Africa/Ceuta, Africa/Nouakchott, Antarctica/Troll, UTC, Etc/Universal, Atlantic/Faeroe, Africa/Abidjan, Africa/Accra, Atlantic/Faroe, Etc/UCT, GMT0, Zulu, Africa/Ouagadougou, Antarctica/Rothera, Atlantic/Reykjavik, Atlantic/Madeira, Etc/Zulu, Iceland, Atlantic/Canary, Africa/Lome, Greenwich, Africa/Casablanca, Etc/GMT0, Africa/Dakar, WET, Etc/Greenwich, Africa/Timbuktu, UCT, Etc/UTC |
+01:00 | Europe/London, Europe/Brussels, Europe/Warsaw, CET, Etc/GMT-1, Europe/Jersey, Europe/Luxembourg, Europe/Guernsey, Europe/Isle_of_Man, Africa/Tunis, Europe/Malta, Europe/Busingen, Africa/Malabo, Europe/Skopje, Europe/Sarajevo, GB-Eire, Africa/Lagos, Europe/Rome, Europe/Zurich, GB, Europe/Gibraltar, Europe/Vaduz, Europe/Ljubljana, Portugal, Europe/Berlin, Europe/Stockholm, Europe/Budapest, Europe/Zagreb, Europe/Paris, Africa/Ndjamena, Europe/Prague, Europe/Copenhagen, Europe/Vienna, Europe/Tirane, MET, Eire, Europe/Amsterdam, Europe/Dublin, Africa/Libreville, Europe/San_Marino, Africa/Douala, Africa/Brazzaville, Africa/Porto-Novo, Poland, Europe/Andorra, Europe/Lisbon, Europe/Oslo, Europe/Podgorica, Europe/Belfast, Africa/Luanda, Atlantic/Jan_Mayen, Africa/Kinshasa, Europe/Madrid, Africa/Bangui, Europe/Belgrade, Africa/Niamey, Europe/Bratislava, Arctic/Longyearbyen, Europe/Vatican, Europe/Monaco |
+02:00 | Africa/Cairo, Africa/Mbabane, Europe/Istanbul, Etc/GMT-2, Libya, Africa/Kigali, Africa/Tripoli, Israel, Africa/Windhoek, Europe/Bucharest, Europe/Mariehamn, Africa/Lubumbashi, Asia/Istanbul, Europe/Helsinki, Asia/Beirut, Asia/Tel_Aviv, Europe/Sofia, Africa/Gaborone, Asia/Gaza, Africa/Maputo, Asia/Damascus, Asia/Jerusalem, Africa/Bujumbura, Africa/Maseru, Africa/Blantyre, Africa/Lusaka, Africa/Harare, Turkey, Africa/Khartoum, Africa/Johannesburg, Africa/Juba, Asia/Nicosia, Asia/Famagusta, EET, Asia/Hebron, Egypt, Asia/Amman, Europe/Nicosia, Europe/Athens |
+03:00 | Asia/Aden, Africa/Nairobi, Etc/GMT-3, Europe/Zaporozhye, Indian/Comoro, Antarctica/Syowa, Europe/Kaliningrad, Africa/Mogadishu, Africa/Asmera, Europe/Tiraspol, Europe/Moscow, Europe/Chisinau, Africa/Djibouti, Europe/Simferopol, Africa/Asmara, Europe/Riga, Asia/Baghdad, Africa/Dar_es_Salaam, Africa/Addis_Ababa, Europe/Uzhgorod, Asia/Riyadh, Asia/Kuwait, Africa/Kampala, Europe/Minsk, Europe/Kiev, Europe/Vilnius, Indian/Antananarivo, Indian/Mayotte, Europe/Tallinn, W-SU |
+03:30 | Iran, Asia/Tehran |
+04:00 | Asia/Yerevan, Etc/GMT-4, Asia/Dubai, Indian/Reunion, Indian/Mauritius, Europe/Saratov, Europe/Samara, Indian/Mahe, Asia/Baku, Europe/Kirov, Asia/Qatar, Asia/Muscat, Asia/Bahrain, Europe/Volgograd, Europe/Astrakhan, Asia/Tbilisi, Europe/Ulyanovsk |
+04:30 | Asia/Kabul |
+05:00 | Asia/Aqtau, Etc/GMT-5, Asia/Samarkand, Asia/Karachi, Asia/Yekaterinburg, Indian/Chagos, Indian/Maldives, Asia/Oral, Asia/Qyzylorda, Asia/Aqtobe, Asia/Ashkhabad, Asia/Ashgabat, Asia/Atyrau, Indian/Kerguelen |
+05:30 | Asia/Kolkata, Asia/Kathmandu, Asia/Colombo, Asia/Thimbu, Asia/Katmandu, Asia/Thimphu, Asia/Calcutta |
+06:00 | Asia/Kashgar, Etc/GMT-6, Asia/Almaty, Asia/Dacca, Asia/Omsk, Asia/Dhaka, Asia/Dushanbe, Asia/Tashkent, Antarctica/Mawson, Asia/Bishkek, Asia/Hovd, Antarctica/Vostok, Asia/Urumqi |
+06:30 | Asia/Yangon, Asia/Rangoon, Indian/Cocos |
+07:00 | Etc/GMT-7, Asia/Phnom_Penh, Asia/Novosibirsk, Antarctica/Davis, Asia/Tomsk, Asia/Choibalsan, Asia/Jakarta, Asia/Barnaul, Indian/Christmas, Asia/Ulan_Bator, Asia/Ulaanbaatar, Asia/Bangkok, Asia/Vientiane, Asia/Novokuznetsk, Asia/Krasnoyarsk |
+07:30 | Singapore, Asia/Kuala_Lumpur, Asia/Singapore |
+08:00 | Asia/Pontianak, Asia/Kuching, Asia/Chungking, Etc/GMT-8, Australia/Perth, Asia/Macao, Asia/Macau, Asia/Shanghai, Asia/Ho_Chi_Minh, Antarctica/Casey, Asia/Chongqing, Asia/Taipei, Asia/Manila, PRC, Asia/Ujung_Pandang, Asia/Harbin, Asia/Brunei, Australia/West, Asia/Hong_Kong, Asia/Saigon, Asia/Makassar, Hongkong, Asia/Irkutsk |
+08:45 | Australia/Eucla |
+09:00 | Etc/GMT-9, Pacific/Palau, Asia/Chita, Asia/Dili, Asia/Jayapura, Asia/Yakutsk, Asia/Pyongyang, ROK, Asia/Seoul, Asia/Khandyga, Japan, Asia/Ust-Nera, Asia/Tokyo |
+09:30 | Australia/North, Australia/Yancowinna, Australia/Adelaide, Australia/Broken_Hill, Australia/South, Australia/Darwin |
+10:00 | Pacific/Yap, Pacific/Port_Moresby, Australia/ACT, Pacific/Bougainville, Australia/Victoria, Pacific/Chuuk, Australia/Queensland, Australia/Canberra, Australia/Currie, Pacific/Guam, Australia/Lord_Howe, Pacific/Truk, Australia/NSW, Asia/Vladivostok, Pacific/Saipan, Antarctica/DumontDUrville, Australia/Sydney, Australia/LHI, Australia/Brisbane, Etc/GMT-10, Australia/Melbourne, Australia/Lindeman |
+11:00 | Australia/Hobart, Australia/Tasmania, Pacific/Ponape, Antarctica/Macquarie, Pacific/Pohnpei, Pacific/Efate, Asia/Magadan, Asia/Sakhalin, Pacific/Noumea, Etc/GMT-11, Asia/Srednekolymsk, Pacific/Guadalcanal |
+11:30 | Pacific/Nauru, Pacific/Norfolk |
+12:00 | Antarctica/McMurdo, Pacific/Wallis, Pacific/Fiji, Pacific/Funafuti, NZ, Pacific/Wake, Antarctica/South_Pole, Pacific/Tarawa, Pacific/Auckland, Pacific/Kosrae, Asia/Kamchatka, Etc/GMT-12, Pacific/Majuro |
+12:45 | NZ-CHAT, Pacific/Chatham |
+13:00 | Pacific/Tongatapu, Etc/GMT-13, Asia/Anadyr |
+14:00 | Etc/GMT-14 |
По логике нам подходят все типы данных, которые указывают на точный момент времени, потому что их можно будет однозначно преобразовать к java.sql.Timestamp и передать драйверу JDBC. Из всего вышеперечисленного это Instant, OffsetDateTime и ZonedDateTime. JPA не поддерживает Instant и ZonedDateTime, поэтому остаётся только OffsetDateTime. Если выбрать LocalDateTime, то предположу, что потребуются явные конвертеры в Timestamp и возникнет путаница между локальной таймзоной и таймзоной сервера базы данных. Самостоятельно такой сценарий я не тестировал.
Использование классов из пакета java.time в JPA/Hibernate требует либо написания конвертеров, либо версий JPA 2.2+ и Hibernate 5.3+. Если у Вас в проекте старая версия Hibernate с широкой завязкой на определённые специфичные классы и методы (на вскидку: setFlushMode, UserType), то при миграции потребуются значительные усилия, чтобы скомпилировать проект, и ещё большие, чтобы заставить его снова корректно работать.
Заключение
Работа с датой и временем в Java основана на манипуляциях с классом Date. Начиная с Java 1.8 появились специализированные типы, использование которых позволяет структурировать операции и делает их более строгими. Тем не менее, проблема ошибочного смещения времени при сохранении и чтении из базы данных не решается простым переходом на классы пакета java.time. Решается эта проблема отказом от любых неявных преобразований времени и использования таймзоны по умолчанию. Вместо этого любые методы PreparedStatement.setTimestamp/getTimestamp, Query.setParameter/getParаmeter, переопределённые UserType в Hibernate и так далее должны явным образом указывать временную зону, соответствующие той, которая используется в базе данных. В JPA + Spring Boot последних версий достаточно установить свойство hibernate.jdbc.time_zone.