跳转到主要内容
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 设置方式:
  1. 使用 Timestamp 类,它本质上是一个 Unix 时间戳。
    1. Calendar 对象一起使用时,可以按照该日历的时区重新解释 Timestamp
    2. Timestamp 有一个不太直观的内部日历。
  2. 使用 LocalDateTime 类,它很容易转换为任意时区,但没有方法允许你传入目标时区。
  3. 使用 ZonedDateTime 类,在写入不带时区的 DateTime 时,它有助于进行时区转换 (因为我们知道应使用服务器时区) 。
    1. 但将 ZonedDateTime 写入定义了时区的列时,用户需要自行补偿驱动程序的转换。
  4. 使用 Long 写入 Unix 时间戳 的毫秒值。
  5. 使用 String 在应用程序端完成所有转换 (这并不是很可移植) 。
按 ID 查找时区时,建议优先使用 java.time.ZoneId#of(java.lang.String)。 如果找不到该时区,此方法会抛出异常 (java.util.TimeZone#getTimeZone(java.lang.String) 则会静默回退到 GMT) 。获取 Tokyo 时区的正确方式是:TimeZone.getTimeZone(ZoneId.of("Asia/Tokyo"))

日期

日期天然不受时区影响。DateDate32 类型用于存储日期。这两种类型都使用自纪元 (1970-01-01) 以来的天数。Date 仅使用正整数天数,因此其范围截止于 2149-06-06Date32 支持负整数天数,以覆盖 1970-01-01 之前的日期,但它的范围更小 (从 1900-01-012100-01-01,其中 0 对应 1970-01-01) 。无论在哪个时区,ClickHouse 都会将 2026-01-01 视为 2026-01-01,并且列定义中没有时区参数。

使用 java.time.LocalDate

在 Java 中,最适合表示日期值的类是 java.time.LocalDate。客户端使用该类存储 DateDate32 列的值 (读取 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,它表示的是 JVM 时区中的 2026-01-01 12:00 AM (其行为与 PostgreSQL 和 MariaDB 的 JDBC 驱动相同) 。

Time

Time 值和 Date 值类似,在大多数情况下都与时区无关。ClickHouse 不会将时间字面量转换为任何时区——’6:30’ 在任何地方读取都一样。

ClickHouse Time 类型

TimeTime64 是在 25.6 版本中引入的。在此之前,使用的是时间戳类型 DateTimeDateTime64 (将在本指南后文讨论) 。Time 以 32 位整数的秒数形式存储,取值范围为 [-999:59:59, 999:59:59]Time64 编码为无符号 Decimal64,并会根据精度存储不同的时间单位。常见的精度选择有 3 (毫秒) 、6 (微秒) 和 9 (纳秒) 。精度取值范围为 [0, 9]

Java 类型映射

客户端会读取 TimeTime64,并将其存储为 LocalDateTime。这样做是为了支持负时间范围 (LocalTime 不支持) 。在这种情况下,日期部分为纪元日期 1970-01-01,因此负值会落在该日期之前。 对时间类型的主要支持通过 LocalTime (当值在一天以内时) 和 Duration 实现,以覆盖完整的取值范围。LocalDateTime 只能用于读取。

使用 java.sql.Time

java.sql.Time 的使用仅限于 LocalTime 的取值范围。在内部,java.sql.Time 会被转换为字符串字面量。可以通过在 PreparedStatement#setTime() 中传入 Calendar 参数来更改该值。

toTime 函数

时间戳

时间戳是某一特定的时间点。例如,Unix 时间戳将任意时间点表示为相对于 1970-01-01 00:00:00 UTC 的秒数 (负数表示 Unix 时间之前的时间戳,正数表示之后的时间戳) 。如果观察者处于 UTC 时区,或者使用 UTC 而非本地时区,这种表示方式就很容易计算和处理。

ClickHouse 时间戳类型

ClickHouse 中有两种时间戳类型:DateTime (32 位整数,分辨率始终为秒) 和 DateTime64 (64 位整数,分辨率取决于定义) 。值始终以 UTC 时间戳存储。这意味着,当它们以数字形式表示时,不会进行任何时区转换。

字符串表示形式和时区行为

字符串表示形式有一些复杂之处:
  • 如果列定义中未指定时区,且写入时传入的是字符串,它会从服务器时区转换为 UTC timestamp 数值。从这类列中读取值时,则会将 UTC timestamp 转换为字面 timestamp,并使用服务器时区或会话时区 (对于 expression 中未显式定义时区的 timestamp 字面量,也采用类似的处理方式) 。
  • 如果列定义中指定了时区,那么所有字符串转换都只会使用该时区。这与未指定时区时的逻辑不同,因此需要充分理解查询中每一列的数据是如何写入的。
  • 如果以包含时区的格式将日期作为字符串传入,则需要使用转换函数。通常使用 parseDateTimeBestEffort

JDBC 驱动如何处理时间戳

在 JDBC 驱动中,我们会将时间戳转换为数值形式:
"fromUnixTimestamp64Nano(" + epochSeconds * 1_000_000_000L + nanos + ")"
这种表示方式解决了 timestamp 值的大多数转换问题,因为它会以统一的格式将数据发送到服务器。不过,这种方法需要对 SQL 语句做一些小调整,但它也是将时间戳写入任意列的最简单、最直接的方法。 DateTimeDateTime64 在客户端会以 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 作为数字写入服务器,并被视为 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 Typeclass 的值转换
DateDate32java.time.LocalDateDB 值 (天数) 会转换为 LocalDate
DateDate32java.sql.DateDB 值 (天数) 会先转换为 LocalDate,然后以本地时区的午夜作为时间部分转换为 java.sql.Date。如果使用了 calendar,则会使用其时区而不是本地时区。示例:DB 值 1970-01-10LocalDate1970-01-10
TimeTime64java.time.LocalTimeDB 值会转换为 LocalDateTime,然后再转换为 LocalTime。这仅适用于一天内的时间。
TimeTime64java.time.LocalDateTimeDB 值会转换为 LocalDateTime
TimeTime64java.sql.TimeDB 值会转换为 LocalDateTime,然后使用默认 calendar 转换为 java.sql.Time。这仅适用于一天内的时间。
TimeTime64java.time.DurationDB 值会转换为 LocalDateTime,然后再转换为 Duration
DateTimeDateTime64java.time.LocalDateTimeDB 值会转换为 ZonedDateTime,然后再转换为 LocalDateTime
DateTimeDateTime64java.time.ZonedDateTimeDB 值会转换为 ZonedDateTime
DateTimeDateTime64java.sql.TimestampDB 值会转换为 ZonedDateTime,然后使用默认时区转换为 java.sql.Timestamp

使用基于 Calendar 的方法

如果值分别通过 PreparedStatement#setTime(param, value, calendar)PreparedStatement#setDate(param, value, calendar) 存储,则应相应使用 ResultSet#getTime(column, calendar)ResultSet#getDate(column, calendar)
最后修改于 2026年6月10日