Date、Time 和 Timestamp 需要特别注意,因为它们涉及几个常见问题。
最常见的问题是如何处理时区。另一个问题是它们的字符串表示形式以及如何使用这种表示形式。
除此之外,每个数据库和驱动程序也都有各自的特性和限制。
本文旨在通过描述任务、提供实现细节并解释相关问题,帮助你做出决策。
我们都知道,时区很难处理 (比如夏令时、固定偏移量的变化) 。但本节要讨论的是另一个与时区相关的问题:时区与时间戳字符串表示形式之间的关系。
ClickHouse 如何转换 DateTime 字符串
ClickHouse 使用以下规则转换 DateTime 字符串值:
- 如果某一列定义了时区 (
DateTime64(9, ‘Asia/Tokyo’)) ,则该字符串值会被视为该时区中的时间戳。2026-01-01 13:00:00 换算为 UTC 时间后将是 2026-01-01 04:00:00。
- 如果某一列没有定义时区,则只使用服务器时区。注意:
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 时间戳 作为数值写入)
Java 和 JDBC 的 timestamp API
Java 和 JDBC 提供了不同的 timestamp 设置方式:
- 使用
Timestamp 类,它本质上是一个 Unix 时间戳。
- 与
Calendar 对象一起使用时,可以按照该日历的时区重新解释 Timestamp。
Timestamp 有一个不太直观的内部日历。
- 使用
LocalDateTime 类,它很容易转换为任意时区,但没有方法允许你传入目标时区。
- 使用
ZonedDateTime 类,在写入不带时区的 DateTime 时,它有助于进行时区转换 (因为我们知道应使用服务器时区) 。
- 但将
ZonedDateTime 写入定义了时区的列时,用户需要自行补偿驱动程序的转换。
- 使用
Long 写入 Unix 时间戳 的毫秒值。
- 使用
String 在应用程序端完成所有转换 (这并不是很可移植) 。
按 ID 查找时区时,建议优先使用 java.time.ZoneId#of(java.lang.String)。
如果找不到该时区,此方法会抛出异常 (java.util.TimeZone#getTimeZone(java.lang.String) 则会静默回退到 GMT) 。获取 Tokyo 时区的正确方式是:TimeZone.getTimeZone(ZoneId.of("Asia/Tokyo"))
日期天然不受时区影响。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 中,最适合表示日期值的类是 java.time.LocalDate。客户端使用该类存储 Date 和 Date32 列的值 (读取 LocalDate.ofEpochDay((long)readUnsignedShortLE())) 。
我们建议使用 java.time.LocalDate,因为它不受时区转换影响,并且属于现代时间 API。
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,它表示的是 JVM 时区中的 2026-01-01 12:00 AM (其行为与 PostgreSQL 和 MariaDB 的 JDBC 驱动相同) 。
Time 值和 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 的使用仅限于 LocalTime 的取值范围。在内部,java.sql.Time 会被转换为字符串字面量。可以通过在 PreparedStatement#setTime() 中传入 Calendar 参数来更改该值。
时间戳是某一特定的时间点。例如,Unix 时间戳将任意时间点表示为相对于 1970-01-01 00:00:00 UTC 的秒数 (负数表示 Unix 时间之前的时间戳,正数表示之后的时间戳) 。如果观察者处于 UTC 时区,或者使用 UTC 而非本地时区,这种表示方式就很容易计算和处理。
ClickHouse 中有两种时间戳类型:DateTime (32 位整数,分辨率始终为秒) 和 DateTime64 (64 位整数,分辨率取决于定义) 。值始终以 UTC 时间戳存储。这意味着,当它们以数字形式表示时,不会进行任何时区转换。
字符串表示形式有一些复杂之处:
- 如果列定义中未指定时区,且写入时传入的是字符串,它会从服务器时区转换为 UTC timestamp 数值。从这类列中读取值时,则会将 UTC timestamp 转换为字面 timestamp,并使用服务器时区或会话时区 (对于 expression 中未显式定义时区的 timestamp 字面量,也采用类似的处理方式) 。
- 如果列定义中指定了时区,那么所有字符串转换都只会使用该时区。这与未指定时区时的逻辑不同,因此需要充分理解查询中每一列的数据是如何写入的。
- 如果以包含时区的格式将日期作为字符串传入,则需要使用转换函数。通常使用
parseDateTimeBestEffort。
在 JDBC 驱动中,我们会将时间戳转换为数值形式:
"fromUnixTimestamp64Nano(" + epochSeconds * 1_000_000_000L + nanos + ")"
这种表示方式解决了 timestamp 值的大多数转换问题,因为它会以统一的格式将数据发送到服务器。不过,这种方法需要对 SQL 语句做一些小调整,但它也是将时间戳写入任意列的最简单、最直接的方法。
DateTime 和 DateTime64 在客户端会以 java.time.ZonedDateTime 的形式读取和存储,这有助于将这类值转换为任何其他时区 (时区信息会被保留) 。
下面的代码示例看起来没问题,但会在断言处失败:
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 作为数字写入服务器,并被视为
UTC Unix 时间戳 (以秒为单位) 。
使用 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) 读取时,值会如何转换:
column 的 ClickHouse Data Type | class 的值 | 转换 |
|---|
Date 或 Date32 | java.time.LocalDate | DB 值 (天数) 会转换为 LocalDate。 |
Date 或 Date32 | java.sql.Date | DB 值 (天数) 会先转换为 LocalDate,然后以本地时区的午夜作为时间部分转换为 java.sql.Date。如果使用了 calendar,则会使用其时区而不是本地时区。示例: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,然后使用默认 calendar 转换为 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。 |
如果值分别通过 PreparedStatement#setTime(param, value, calendar) 和 PreparedStatement#setDate(param, value, calendar) 存储,则应相应使用 ResultSet#getTime(column, calendar) 和 ResultSet#getDate(column, calendar)。