Работа с Date, Time и Timestamp в JDBC
Типы Date, Time и Timestamp требуют особого внимания, поскольку с ними связано несколько распространённых проблем. Наиболее частая проблема — это обработка часовых поясов. Другая проблема — строковое представление и особенности его использования. Кроме того, у каждой базы данных и драйвера есть свои особенности и ограничения.
Цель этого документа — быть руководством для принятия решений, описывая задачи, приводя детали реализации и объясняя проблемы.
Часовые пояса
Мы все знаем, что с часовыми поясами сложно работать (переход на летнее время, постоянные изменения смещения). Но в этом разделе речь пойдёт о другой проблеме, связанной с часовыми поясами: о том, как они соотносятся со строковым представлением меток времени.
Как ClickHouse преобразует строки DateTime
ClickHouse использует следующие правила для преобразования строковых значений DateTime:
- Если столбец определён с часовым поясом (
DateTime64(9, ‘Asia/Tokyo’)), то строковое значение будет интерпретироваться как временная метка в этом часовом поясе.2026-01-01 13:00:00будет2026-01-01 04:00:00во времениUTC. - Если у столбца нет определения часового пояса, используется только часовой пояс сервера. Важно: настройка
session_timezoneне влияет на поведение. Поэтому если часовой пояс сервера —UTC, а часовой пояс сеанса —America/Los_Angeles, то2026-01-01 13:00:00будет записано как время вUTC. - Когда значение читается из столбца без определения часового пояса, используется
session_timezone, а если она не установлена — часовой пояс сервера. Именно поэтому чтение временных меток как строк может зависеть отsession_timezone. В этом нет ничего некорректного, но об этом следует помнить.
Запись временных меток в разных часовых поясах
Предположим, у нас есть приложение, работающее в регионе us-west с локальным часовым поясом UTC-8, и нам нужно записать локальную временную метку 2026-01-01 02:00:00, которая в UTC соответствует 2026-01-01 10:00:00:
- При записи в виде строки её нужно предварительно преобразовать в часовой пояс сервера или часовой пояс столбца.
- При записи в виде нативной для языка структуры времени драйвер должен знать целевой часовой пояс, но:
- это не всегда возможно;
- API драйвера спроектирован для этого неудачно;
- единственный способ — описать, какие преобразования будут выполняться, чтобы приложение могло это компенсировать (или записать Unix-временную метку как число).
API меток времени в Java и JDBC
В Java и JDBC есть разные способы задать метку времени:
- Использовать класс
Timestamp, который на самом деле представляет собой Unix-метку времени.- При использовании с объектом
Calendarэто позволяет переинтерпретироватьTimestampв часовом поясе календаря. - У
Timestampесть внутренний календарь, наличие которого неочевидно.
- При использовании с объектом
- Использовать класс
LocalDateTime, который легко преобразовать в любой часовой пояс, но нет метода, позволяющего передать целевой часовой пояс. - Использовать класс
ZonedDateTime, который помогает с конвертацией часовых поясов при записи вDateTimeбез часового пояса (поскольку известно, что нужно использовать часовой пояс сервера).- Но запись
ZonedDateTimeв столбец с определённым часовым поясом требует от пользователя учитывать преобразование, выполняемое драйвером.
- Но запись
- Использовать
Longдля записи значений Unix-метки времени в миллисекундах. - Использовать
String, чтобы выполнять все конвертации на стороне приложения (что не очень переносимо).
Рекомендуется использовать java.time.ZoneId#of(java.lang.String) при поиске часового пояса по его идентификатору.
Этот метод выбрасывает исключение, если часовой пояс не найден (java.util.TimeZone#getTimeZone(java.lang.String) безошибочно вернётся к GMT).
Правильный способ получить часовой пояс Tokyo:
TimeZone.getTimeZone(ZoneId.of("Asia/Tokyo"))
Date
Даты по своей природе не зависят от часового пояса. Существуют типы Date и Date32 для хранения дат. Оба типа используют количество дней, прошедших с эпохи (1970-01-01). Date использует только положительное количество дней, поэтому его диапазон заканчивается на 2149-06-06. Date32 обрабатывает отрицательное количество дней, чтобы охватывать даты до 1970-01-01, но его диапазон меньше (от 1900-01-01 до 2100-01-01, при этом 0 — это 1970-01-01). ClickHouse воспринимает 2026-01-01 как 2026-01-01 в любом часовом поясе, и при определении столбцов параметр часового пояса не указывается.
Использование java.time.LocalDate
В Java наиболее подходящим классом для представления значений даты является java.time.LocalDate. Клиент использует этот класс для хранения значений столбцов Date и Date32 (при чтении LocalDate.ofEpochDay((long)readUnsignedShortLE())).
Мы рекомендуем использовать java.time.LocalDate, потому что он не подвержен преобразованиям временных зон и является частью современного API для работы со временем.
Использование java.sql.Date
LocalDate был добавлен в Java 8. До этого для записи и чтения дат использовался java.sql.Date. Внутри этот класс является обёрткой над моментом времени (instant — значением, представляющим абсолютную точку во времени). Поэтому toString() возвращает разную дату в зависимости от часового пояса, в котором запущена JVM. Это требует от драйвера тщательного формирования значений и от пользователя — учёта этого поведения.
Переинтерпретация с учетом календаря
В java.sql.ResultSet есть метод для получения значений типа дата, который принимает Calendar; аналогичный метод есть в java.sql.PreparedStatement. Это было задумано для того, чтобы JDBC‑драйвер мог интерпретировать значение даты в указанном часовом поясе. Например, в БД хранится значение 2026-01-01, но приложение хочет рассматривать эту дату как полночь в Tokyo. Это означает, что возвращаемый объект java.sql.Date будет соответствовать конкретному моменту времени, и при преобразовании в локальный часовой пояс дата может измениться из‑за разницы во времени. Того же эффекта мы можем добиться с LocalDate, используя java.time.LocalDate#atStartOfDay(java.time.ZoneId).
JDBC‑драйвер ClickHouse всегда возвращает объект java.sql.Date, который указывает на локальную дату в полночь. Другими словами, если дата — 2026-01-01, мы имеем в виду 2026-01-01 12:00 AM в часовом поясе JVM (то же поведение, что и у JDBC‑драйверов PostgreSQL и MariaDB).
Time
Значения Time, как и значения Date, в большинстве случаев не зависят от часового пояса. ClickHouse не выполняет никаких преобразований литералов времени в какой-либо часовой пояс — ’6:30’ имеет одно и то же значение независимо от того, где оно интерпретируется.
Типы времени ClickHouse
Time и Time64 были введены в версии 25.6. До этого вместо них использовались типы временных меток DateTime и DateTime64 (они рассматриваются далее в этом руководстве). Time хранится как 32-битное целое число секунд и имеет диапазон значений [-999:59:59, 999:59:59]. Time64 кодируется как беззнаковый Decimal64 и хранит различные единицы времени в зависимости от точности. Распространённые варианты — 3 (миллисекунды), 6 (микросекунды) и 9 (наносекунды). Диапазон значений точности — [0, 9].
Сопоставление типов Java
Клиент читает значения типов Time и Time64 и сохраняет их как LocalDateTime. Это сделано для поддержки отрицательного диапазона значений времени (LocalTime его не поддерживает). В этом случае в качестве даты используется дата эпохи 1970-01-01, поэтому отрицательные значения будут относиться ко времени до этой даты.
Основная поддержка временных типов реализована с использованием LocalTime (когда значение находится в пределах суток) и Duration для охвата полного диапазона значений. LocalDateTime может использоваться только для чтения.
Использование java.sql.Time
Использование java.sql.Time ограничено диапазоном значений LocalTime. Внутренне значение java.sql.Time преобразуется в строковый литерал. Значение может быть изменено с помощью параметра Calendar в PreparedStatement#setTime().
Функция toTime
toTimeвсегда требует значение типаDate,DateTimeили другого аналогичного типа. Она не принимает строки. Связанная задача: https://github.com/ClickHouse/ClickHouse/issues/89896- Является алиасом для
toTimeWithFixedDate. - Есть проблема, связанная с часовыми поясами: https://github.com/ClickHouse/ClickHouse/pull/90310
Timestamp
Метка времени (timestamp) — это конкретная точка во времени. Например, Unix timestamp представляет любую точку во времени как количество секунд, прошедших с 1970-01-01 00:00:00 UTC (отрицательное количество секунд соответствует моменту до начала Unix-времени, а положительное — после него). Такое представление легко вычислять и обрабатывать, если наблюдатель находится в часовом поясе UTC или использует его вместо своего локального часового пояса.
Типы временных меток в ClickHouse
В ClickHouse есть типы временных меток DateTime (32-битное целое число, точность всегда в секундах) и DateTime64 (64-битное целое число, точность зависит от определения типа). Значения всегда хранятся как метки времени в UTC. Это означает, что при представлении их в виде чисел преобразование часового пояса не выполняется.
Строковое представление и поведение часовых поясов
Строковое представление имеет ряд особенностей:
- Если в определении столбца часовой пояс не указан, а при записи передаётся строка, она будет преобразована из часового пояса сервера в числовое значение метки времени в UTC. При чтении значения из такого столбца оно будет преобразовано из метки времени в UTC в литерал метки времени с использованием часового пояса сервера или сессии (аналогичный подход применяется к литералам меток времени в выражениях, где часовой пояс явно не задан).
- Если в определении столбца указан часовой пояс, то только этот часовой пояс используется во всех преобразованиях строк. Это противоречит логике, применяемой, когда часовой пояс не указан, поэтому требуется чёткое понимание того, как записываются данные для каждого столбца в запросе.
- Если дата передаётся как строка в формате, который включает часовой пояс, то требуется функция преобразования. Обычно используется
parseDateTimeBestEffort.
Как драйвер JDBC обрабатывает временные метки
В драйвере JDBC временные метки преобразуются в числовое представление:
Это представление решает большинство проблем с преобразованием значений временных меток, поскольку отправляет данные на сервер в унифицированном формате. Однако такой подход требует небольшого изменения в SQL-командах и при этом обеспечивает самый простой и понятный способ записи временных меток в любой столбец.
DateTime и DateTime64 на стороне клиента читаются и хранятся как java.time.ZonedDateTime, что упрощает преобразование таких значений в любой другой часовой пояс (информация о часовом поясе сохраняется).
Распространённая ошибка при использовании toDateTime64
Следующий пример кода выглядит корректным, но не проходит проверку assert:
Это происходит потому, что toDateTime64 использует часовой пояс сервера и не учитывает часовой пояс источника.
Таблицы преобразований
Если пара типов для преобразования не указана в таблицах ниже, то такое преобразование не поддерживается. Например, столбцы типа Date нельзя читать как java.sql.Timestamp, потому что в них отсутствует часть времени.
Запись значений с помощью PreparedStatement#setObject
В следующей таблице показано, как преобразуются значения при передаче в PreparedStatement#setObject(column, value):
Класс value | Преобразование |
|---|---|
java.time.LocalDate | Форматируется как YYYY-MM-DD. |
java.sql.Date | Преобразуется с использованием календаря по умолчанию и форматируется как LocalDate (YYYY-MM-DD). |
java.time.LocalTime | Форматируется как HH:mm:ss. |
java.time.Duration | Форматируется как HHH:mm:ss. Значение может быть отрицательным. |
java.sql.Time | Преобразуется с использованием календаря по умолчанию и форматируется как LocalTime (HH:mm). |
java.time.LocalDateTime | Преобразуется в метку времени Unix в наносекундах и передаётся в функцию fromUnixTimestamp64Nano. |
java.time.ZonedDateTime | Преобразуется в метку времени Unix в наносекундах и передаётся в функцию fromUnixTimestamp64Nano. |
java.sql.Timestamp | Преобразуется в метку времени Unix в наносекундах и передаётся в функцию fromUnixTimestamp64Nano. |
Тип столбца следует считать неизвестным. Решение о том, что передавать в подготовленный запрос, принимает приложение.
Чтение значений с помощью ResultSet#getObject
В следующей таблице показано, как значения преобразуются при чтении с помощью ResultSet#getObject(column, class):
Тип данных ClickHouse для столбца column | Значение class | Преобразование |
|---|---|---|
Date или Date32 | java.time.LocalDate | Значение в БД (количество дней) преобразуется в LocalDate. |
Date или Date32 | java.sql.Date | Значение в БД (количество дней) преобразуется в LocalDate, а затем в java.sql.Date с использованием полуночи локального часового пояса как временной части. Если используется объект календаря, будет использован его часовой пояс вместо локального. Пример: значение в БД 1970-01-10 → LocalDate — это 1970-01-10. |
Time или Time64 | java.time.LocalTime | Значение в БД преобразуется в LocalDateTime, а затем в LocalTime. Работает только для времени в пределах суток. |
Time или Time64 | java.time.LocalDateTime | Значение в БД преобразуется в LocalDateTime. |
Time или Time64 | java.sql.Time | Значение в БД преобразуется в LocalDateTime, а затем в java.sql.Time с использованием календаря по умолчанию. Работает только для времени в пределах суток. |
Time или Time64 | java.time.Duration | Значение в БД преобразуется в LocalDateTime, а затем в Duration. |
DateTime или DateTime64 | java.time.LocalDateTime | Значение в БД преобразуется в ZonedDateTime, затем в LocalDateTime. |
DateTime или DateTime64 | java.time.ZonedDateTime | Значение в БД преобразуется в ZonedDateTime. |
DateTime или DateTime64 | java.sql.Timestamp | Значение в БД преобразуется в ZonedDateTime, затем в java.sql.Timestamp с использованием часового пояса по умолчанию. |
Использование методов с Calendar
Используйте ResultSet#getTime(column, calendar) и ResultSet#getDate(column, calendar), если значения были записаны с помощью PreparedStatement#setTime(param, value, calendar) и PreparedStatement#setDate(param, value, calendar) соответственно.