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 и Date32. Оба типа используют количество дней с Epoch (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. Внутри этот класс представляет собой обёртку над моментом времени (значением времени, представляющим абсолютную точку на временной шкале). Поэтому toString() возвращает разную дату в зависимости от часового пояса JVM. Из-за этого драйвер должен тщательно формировать значения, а пользователь — учитывать эту особенность.
Переинтерпретация на основе календаря
У java.sql.ResultSet есть метод для получения значений даты, который принимает Calendar, и аналогичный метод есть у java.sql.PreparedStatement. Это сделано для того, чтобы JDBC-драйвер мог переинтерпретировать значение даты в указанном часовом поясе. Например, в DB хранится значение 2026-01-01, но приложение хочет видеть эту дату как полночь в Tokyo. Это означает, что возвращаемый объект java.sql.Date будет соответствовать конкретному моменту времени, и при преобразовании в локальный часовой пояс это уже может оказаться другой датой из-за разницы во времени. Того же эффекта можно добиться с LocalDate, используя java.time.LocalDate#atStartOfDay(java.time.ZoneId).
ClickHouse JDBC-драйвер всегда возвращает объект java.sql.Date, который указывает на локальную дату в полночь. Иными словами, если дата — 2026-01-01, имеется в виду 2026-01-01 12:00 AM в часовом поясе JVM (то же поведение, что и у JDBC-драйверов PostgreSQL и MariaDB).
Значения времени, как и значения Date, в большинстве случаев не привязаны к часовому поясу. ClickHouse не преобразует литералы времени в какой-либо часовой пояс — ’6:30’ везде интерпретируется одинаково.
Time и Time64 были добавлены в 25.6. До этого вместо них использовались типы временных меток DateTime и DateTime64 (они рассматриваются далее в этом руководстве). Time хранится как 32-битное целое число, представляющее количество секунд, и имеет диапазон [-999:59:59, 999:59:59]. Time64 кодируется как беззнаковый Decimal64 и хранит разные единицы времени в зависимости от точности. Обычно используются значения 3 (миллисекунды), 6 (микросекунды) и 9 (наносекунды). Диапазон значений точности — [0, 9].
Клиент считывает значения Time и Time64 и сохраняет их как LocalDateTime. Это сделано для поддержки отрицательного диапазона времени (LocalTime его не поддерживает). В этом случае в качестве даты используется дата эпохи 1970-01-01, поэтому отрицательные значения будут приходиться на время до этой даты.
Основная поддержка типов времени реализована с помощью LocalTime (когда значение укладывается в пределы суток) и Duration для использования полного диапазона значений. LocalDateTime можно использовать только для чтения.
Использование java.sql.Time
Использование java.sql.Time ограничено диапазоном значений LocalTime. Внутри java.sql.Time преобразуется в строковый литерал. Значение можно изменить, передав параметр Calendar в PreparedStatement#setTime().
Временная метка — это определённый момент времени. Например, Unix-временная метка представляет любой момент времени как число секунд относительно 1970-01-01 00:00:00 UTC (отрицательное число секунд обозначает временную метку до эпохи Unix, а положительное — после неё). Такое представление легко вычислять и обрабатывать, если наблюдатель находится в часовом поясе UTC или использует его вместо локального.
Типы временных меток в ClickHouse
В ClickHouse есть типы временных меток DateTime (32-битное целое число, разрешение всегда в секундах) и DateTime64 (64-битное целое число, разрешение зависит от определения). Значения всегда хранятся как временные метки UTC. Это означает, что при представлении в виде чисел преобразование часового пояса не выполняется.
Строковое представление и работа с часовыми поясами
Со строковым представлением связаны некоторые особенности:
- Если в определении столбца не указан часовой пояс и при записи передаётся строка, она преобразуется из часового пояса сервера в числовую UTC-временную метку. При чтении значения из такого столбца оно преобразуется из UTC-временной метки в буквальную временную метку с использованием часового пояса сервера или сеанса (аналогичный подход применяется к литералам временных меток в выражениях, где часовой пояс явно не задан).
- Если в определении столбца указан часовой пояс, то во всех строковых преобразованиях используется только он. Это отличается от логики, применяемой, когда часовой пояс не указан, поэтому важно хорошо понимать, как данные записываются в каждый столбец в запросе.
- Если дата передаётся в виде строки в формате, включающем часовой пояс, требуется функция преобразования. Обычно используется
parseDateTimeBestEffort.
Как JDBC-драйвер обрабатывает временные метки
В JDBC-драйвере мы преобразуем временные метки в числовое представление:
"fromUnixTimestamp64Nano(" + epochSeconds * 1_000_000_000L + nanos + ")"
Такое представление решает большинство проблем с преобразованием значений временных меток, поскольку данные отправляются на сервер в едином формате. Однако такой подход требует небольшой корректировки SQL-команд, но при этом остаётся самым простым и понятным способом записывать временные метки в любой столбец.
DateTime и DateTime64 считываются и хранятся на клиенте как java.time.ZonedDateTime, что позволяет преобразовывать такие значения в любой другой часовой пояс (информация о часовом поясе при этом сохраняется).
Распространённая ошибка при использовании toDateTime64
Следующий пример кода выглядит корректно, но проверка утверждения не проходит:
String sql = "SELECT toDateTime64(?, 3)";
try (PreparedStatement stmt = conn.prepareStatement(sql)) {
LocalDateTime localTs = LocalDateTime.parse("2021-01-01T01:34:56");
stmt.setObject(1, localTs);
try (ResultSet rs = stmt.executeQuery()) {
rs.next();
assertEquals(rs.getObject(1, LocalDateTime.class), localTs);
}
}
Это происходит потому, что toDateTime64 использует часовой пояс сервера и не знает о часовом поясе источника данных.
Если пара преобразования не указана в таблицах ниже, значит, такое преобразование не поддерживается. Например, столбцы Date нельзя читать как java.sql.Timestamp, потому что у них нет части, отвечающей за время.
Драйвер не преобразует целочисленные значения ни в какие значения даты/времени. Вызов pstmt.setLong("timestamp", 1772132359L) приведет к тому, что 1772132359 будет записано на сервер как число, которое будет интерпретировано как
Unix-временная метка UTC в секундах.
Запись значений с помощью 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 | Значение DB (количество дней) преобразуется в LocalDate. |
Date или Date32 | java.sql.Date | Значение DB (количество дней) сначала преобразуется в LocalDate, а затем — в java.sql.Date, где в качестве времени используется полночь в локальном часовом поясе. Если используется календарь, вместо локального часового пояса будет использован его часовой пояс. Пример: значение DB 1970-01-10 → LocalDate равно 1970-01-10. |
Time или Time64 | java.time.LocalTime | Значение DB преобразуется в LocalDateTime, а затем в LocalTime. Это работает только для времени в пределах суток. |
Time или Time64 | java.time.LocalDateTime | Значение DB преобразуется в LocalDateTime. |
Time или Time64 | java.sql.Time | Значение DB преобразуется в LocalDateTime, а затем в java.sql.Time с использованием календаря по умолчанию. Это работает только для времени в пределах суток. |
Time или Time64 | java.time.Duration | Значение DB преобразуется в LocalDateTime, а затем в Duration. |
DateTime или DateTime64 | java.time.LocalDateTime | Значение DB преобразуется в ZonedDateTime, а затем в LocalDateTime. |
DateTime или DateTime64 | java.time.ZonedDateTime | Значение DB преобразуется в ZonedDateTime. |
DateTime или DateTime64 | java.sql.Timestamp | Значение DB преобразуется в ZonedDateTime, а затем в java.sql.Timestamp с использованием часового пояса по умолчанию. |
Использование методов с календарём
Используйте ResultSet#getTime(column, calendar) и ResultSet#getDate(column, calendar), если значения были сохранены с помощью PreparedStatement#setTime(param, value, calendar) и PreparedStatement#setDate(param, value, calendar) соответственно. Последнее изменение 10 июня 2026 г.