1. 概述

使用 Hibernate 构建持久层时,处理带时区的时间戳字段是个常见需求。Java 8 后,通常用 OffsetDateTimeZonedDateTime 表示带时区的时间戳。但根据 JPA 规范,它们并非有效属性类型,这给数据库存储带来了挑战。

Hibernate 6 引入了 @TimeZoneStorage 注解来解决上述问题。该注解提供了灵活的配置选项,用于控制时区信息在数据库中的存储和检索方式。

本教程将深入探讨 Hibernate 的 @TimeZoneStorage 注解及其各种存储策略。通过实际示例分析每种策略的行为,帮助你根据具体需求选择最佳方案。

2. 应用程序搭建

在探索 @TimeZoneStorage 注解前,我们先搭建一个贯穿本教程的简单应用。

2.1. 依赖配置

在项目的 pom.xml 中添加 Hibernate 依赖:

<dependency>
    <groupId>org.hibernate.orm</groupId>
    <artifactId>hibernate-core</artifactId>
    <version>6.6.0.Final</version>
</dependency>

此依赖提供了核心 Hibernate ORM 功能,包括我们即将讨论的 @TimeZoneStorage 注解。

2.2. 定义实体类和仓库层

定义实体类:

@Entity
@Table(name = "astronomical_observations")
class AstronomicalObservation {
    @Id
    private UUID id;

    private String celestialObjectName;

    private ZonedDateTime observationStartTime;

    private OffsetDateTime peakVisibilityTime;

    private ZonedDateTime nextExpectedAppearance;

    private OffsetDateTime lastRecordedSighting;

    // 标准的 setter 和 getter
}

为演示效果,我们以天文观测为例。AstronomicalObservation 类是示例的核心实体,后续将用它学习 @TimeZoneStorage 注解的工作原理。

创建对应的仓库接口:

@Repository
interface AstronomicalObservationRepository extends JpaRepository<AstronomicalObservation, UUID> {
}

AstronomicalObservationRepository 继承了 JpaRepository,用于与数据库交互。

2.3. 启用 SQL 日志

为深入理解 @TimeZoneStorage 的底层机制,在 application.yml 中添加 SQL 日志配置:

logging:
  level:
    org:
      hibernate:
        SQL: DEBUG
        orm:
          results: DEBUG
          jdbc:
            bind: TRACE
        type:
          descriptor:
            sql:
              BasicBinder: TRACE

通过此配置,我们能清晰看到 Hibernate 为 AstronomicalObservation 实体生成的 SQL 语句。

⚠️ 注意:以上配置仅用于演示,生产环境请勿使用

3. @TimeZoneStorage 存储策略

现在我们来看看 @TimeZoneStorage 注解提供的不同存储策略。

3.1. NATIVE 策略

在介绍 NATIVE 策略前,先了解 TIMESTAMP WITH TIME ZONE 数据类型。这是 SQL 标准类型,能同时存储时间戳和时区信息,但并非所有数据库都支持(如 PostgreSQL 和 Oracle 支持)。

observationStartTime 字段添加 @TimeZoneStorage 注解并使用 NATIVE 策略:

@TimeZoneStorage(TimeZoneStorageType.NATIVE)
@Column(name = "observation_start_time", columnDefinition = "TIMESTAMP(9) WITH TIME ZONE")
private ZonedDateTime observationStartTime;

Hibernate 默认使用 6 位微秒精度,但可根据需求调整。示例中我们指定了最大精度 9。

使用 NATIVE 策略时,Hibernate 会将 ZonedDateTimeOffsetDateTime 直接存入 TIMESTAMP WITH TIME ZONE 列。实际效果如下:

AstronomicalObservation observation = new AstronomicalObservation();
observation.setId(UUID.randomUUID());
observation.setCelestialObjectName("test-planet");
observation.setObservationStartTime(ZonedDateTime.now());

astronomicalObservationRepository.save(observation);

保存实体时生成的日志:

org.hibernate.SQL : insert into astronomical_observations (id, celestial_object_name, observation_start_time) values (?, ?, ?)

org.hibernate.orm.jdbc.bind : binding parameter (1:UUID) <- [ffc2f72d-bcfe-38bc-80af-288d9fcb9bb0]
org.hibernate.orm.jdbc.bind : binding parameter (2:VARCHAR) <- [test-planet]
org.hibernate.orm.jdbc.bind : binding parameter (3:TIMESTAMP_WITH_TIMEZONE) <- [2024-09-18T17:52:46.759673145+05:30[Asia/Kolkata]]

日志清晰显示:ZonedDateTime 值被直接映射到 TIMESTAMP_WITH_TIMEZONE 列,完整保留了时区信息。

✅ 如果数据库支持此数据类型,NATIVE 策略是存储带时区时间戳的首选

3.2. COLUMN 策略

COLUMN 策略将时间戳和时区偏移量存储在两个独立列中,时区偏移量使用 INTEGER 类型存储

AstronomicalObservation 实体的 peakVisibilityTime 属性上使用此策略:

@TimeZoneStorage(TimeZoneStorageType.COLUMN)
@TimeZoneColumn(name = "peak_visibility_time_offset")
@Column(name = "peak_visibility_time", columnDefinition = "TIMESTAMP(9)")
private OffsetDateTime peakVisibilityTime;

@Column(name = "peak_visibility_time_offset", insertable = false, updatable = false)
private Integer peakVisibilityTimeOffset;

我们新增了 peakVisibilityTimeOffset 属性,并用 @TimeZoneColumn 注解指定它存储时区偏移量。设置 insertableupdatablefalse 是必要的,避免映射冲突(Hibernate 通过注解管理该字段)。

**若不使用 @TimeZoneColumn 注解,Hibernate 默认时区偏移量列名为 字段名_tz**(本例中为 peak_visibility_time_tz)。

保存使用 COLUMN 策略的实体:

AstronomicalObservation observation = new AstronomicalObservation();
observation.setId(UUID.randomUUID());
observation.setCelestialObjectName("test-planet");
observation.setPeakVisibilityTime(OffsetDateTime.now());

astronomicalObservationRepository.save(observation);

生成的日志:

org.hibernate.SQL : insert into astronomical_observations (id, celestial_object_name, peak_visibility_time, peak_visibility_time_offset) values (?, ?, ?, ?)

org.hibernate.orm.jdbc.bind : binding parameter (1:UUID) <- [82d0a618-dd11-4354-8c99-ef2d2603cacf]
org.hibernate.orm.jdbc.bind : binding parameter (2:VARCHAR) <- [test-planet]
org.hibernate.orm.jdbc.bind : binding parameter (3:TIMESTAMP_UTC) <- [2024-09-18T12:37:43.441296836Z]
org.hibernate.orm.jdbc.bind : binding parameter (4:INTEGER) <- [+05:30]

可见:Hibernate 将无时区的时间戳存入 peak_visibility_time 列,时区偏移量存入 peak_visibility_time_offset 列。

❌ 当数据库不支持 TIMESTAMP WITH TIME ZONE 类型时,推荐使用 COLUMN 策略。需确保表结构中包含存储时区偏移量的列。

3.3. NORMALIZE 策略

NORMALIZE 策略的工作原理:Hibernate 将时间戳标准化为应用本地时区,存储时去除时区信息;读取时再添加本地时区

nextExpectedAppearance 属性添加注解:

@TimeZoneStorage(TimeZoneStorageType.NORMALIZE)
@Column(name = "next_expected_appearance", columnDefinition = "TIMESTAMP(9)")
private ZonedDateTime nextExpectedAppearance;

保存实体并分析日志:

TimeZone.setDefault(TimeZone.getTimeZone("Asia/Kolkata")); // UTC+05:30

AstronomicalObservation observation = new AstronomicalObservation();
observation.setId(UUID.randomUUID());
observation.setCelestialObjectName("test-planet");
observation.setNextExpectedAppearance(ZonedDateTime.of(1999, 12, 25, 18, 0, 0, 0, ZoneId.of("UTC+8")));

astronomicalObservationRepository.save(observation);

首先设置应用默认时区为 Asia/Kolkata (UTC+05:30),然后创建 ZonedDateTime 为 UTC+8 时区的实体。为观察完整行为,需在 application.yaml 中添加额外日志:

logging:
  level:
    org:
      hibernate:
        resource:
          jdbc:
            internal:
              ResourceRegistryStandardImpl: TRACE

执行后生成的日志:

org.hibernate.SQL : insert into astronomical_observations (id, celestial_object_name, next_expected_appearance) values (?, ?, ?)

org.hibernate.orm.jdbc.bind : binding parameter (1:UUID) <- [938bafb9-20a7-42f0-b865-dfaca7c088f5]
org.hibernate.orm.jdbc.bind : binding parameter (2:VARCHAR) <- [test-planet]
org.hibernate.orm.jdbc.bind : binding parameter (3:TIMESTAMP) <- [1999-12-25T18:00+08:00[UTC+08:00]]

o.h.r.j.i.ResourceRegistryStandardImpl : Releasing statement [HikariProxyPreparedStatement@971578330 wrapping prep1: insert into astronomical_observations (id, celestial_object_name, next_expected_appearance) values (?, ?, ?) {1: UUID '938bafb9-20a7-42f0-b865-dfaca7c088f5', 2: 'test-planet', 3: TIMESTAMP '1999-12-25 15:30:00'}]

**原始时间戳 1999-12-25T18:00+08:00 被标准化为应用本地时区 Asia/Kolkata,存储为 1999-12-25 15:30:00**。Hibernate 通过减去 2.5 小时(UTC+8 与 UTC+5:30 的差值)移除时区信息。

从数据库读取实体:

astronomicalObservationRepository.findById(observation.getId()).orElseThrow();

查询日志:

org.hibernate.SQL : select ao1_0.id, ao1_0.celestial_object_name, ao1_0.next_expected_appearance from astronomical_observations ao1_0 where ao1_0.id=?

org.hibernate.orm.jdbc.bind : binding parameter (1:UUID) <- [938bafb9-20a7-42f0-b865-dfaca7c088f5]
org.hibernate.orm.jdbc.results : Extracted JDBC value [1] - [test-planet]
org.hibernate.orm.jdbc.results : Extracted JDBC value [2] - [1999-12-25T15:30+05:30[Asia/Kolkata]]

Hibernate 重建 ZonedDateTime 时添加了本地时区 +05:30,而非原始的 UTC+8 时区。

⚠️ 跨时区部署应用时需谨慎使用此策略(如负载均衡后的多实例),必须确保所有实例使用相同默认时区,否则会导致数据不一致。

3.4. NORMALIZE_UTC 策略

NORMALIZE_UTC 策略与 NORMALIZE 类似,但始终将时间戳标准化为 UTC,而非应用本地时区。

lastRecordedSighting 属性上使用:

@TimeZoneStorage(TimeZoneStorageType.NORMALIZE_UTC)
@Column(name = "last_recorded_sighting", columnDefinition = "TIMESTAMP(9)")
private OffsetDateTime lastRecordedSighting;

保存 UTC+8 时区的实体:

AstronomicalObservation observation = new AstronomicalObservation();
observation.setId(UUID.randomUUID());
observation.setCelestialObjectName("test-planet");
observation.setLastRecordedSighting(OffsetDateTime.of(1999, 12, 25, 18, 0, 0, 0, ZoneOffset.ofHours(8)));

astronomicalObservationRepository.save(observation);

生成的日志:

org.hibernate.SQL : insert into astronomical_observations (id,celestial_object_name,last_recorded_sighting) values (?,?,?)

org.hibernate.orm.jdbc.bind : binding parameter (1:UUID) <- [c843a9db-45c7-44c7-a2de-f5f0c8947449]
org.hibernate.orm.jdbc.bind : binding parameter (2:VARCHAR) <- [test-planet]
org.hibernate.orm.jdbc.bind : binding parameter (3:TIMESTAMP_UTC) <- [1999-12-25T18:00+08:00]

o.h.r.j.i.ResourceRegistryStandardImpl : Releasing statement [HikariProxyPreparedStatement@1938138927 wrapping prep1: insert into astronomical_observations (id,celestial_object_name,last_recorded_sighting) values (?,?,?) {1: UUID 'c843a9db-45c7-44c7-a2de-f5f0c8947449', 2: 'test-planet', 3: TIMESTAMP WITH TIME ZONE '1999-12-25 10:00:00+00'}]

Hibernate 将 OffsetDateTime 1999-12-25T18:00+08:00 标准化为 UTC 时间 1999-12-25 10:00:00+00(减去 8 小时)后存储。

读取实体时的日志:

org.hibernate.SQL : select ao1_0.id,ao1_0.celestial_object_name,ao1_0.last_recorded_sighting from astronomical_observations ao1_0 where ao1_0.id=?

org.hibernate.orm.jdbc.bind : binding parameter (1:UUID) <- [9fd6cc61-ab7e-490b-aeca-954505f52603]
org.hibernate.orm.jdbc.results : Extracted JDBC value [1] - [test-planet]
org.hibernate.orm.jdbc.results : Extracted JDBC value [2] - [1999-12-25T10:00Z]

虽然丢失了原始 UTC+8 时区信息,但 OffsetDateTime 仍表示同一时间点

3.5. AUTO 策略

AUTO 策略让 Hibernate 根据数据库自动选择策略:

  • 若数据库支持 TIMESTAMP WITH TIME ZONE 类型,使用 NATIVE 策略
  • 否则使用 COLUMN 策略

多数情况下我们清楚使用的数据库类型,建议显式指定策略而非依赖 AUTO

3.6. DEFAULT 策略

DEFAULT 策略与 AUTO 类似,但选择逻辑不同:

  • 若数据库支持 TIMESTAMP WITH TIME ZONE 类型,使用 NATIVE 策略
  • 否则使用 NORMALIZE_UTC 策略

同样建议在已知数据库类型时显式指定策略。

4. 总结

本文探讨了如何使用 Hibernate 的 @TimeZoneStorage 注解在数据库中持久化带时区的时间戳。

我们分析了在 OffsetDateTimeZonedDateTime 字段上使用该注解的各种存储策略,并通过生成的 SQL 日志展示了每种策略的行为特点。

本文所有代码示例可在 GitHub 获取。


原始标题:Guide to Hibernate’s @TimeZoneStorage Annotation | Baeldung