Hibernate @TimeZoneStorage 注释指南

在本文中,我们探讨了如何使用 Hibernate 的@TimeZoneStorage注释在我们的数据库中保存带有时区详细信息的时间戳。
我们研究了在OffsetDateTime和ZonedDateTime字段上使用@TimeZoneStorage注释时可用的各种存储策略。我们通过分析它生成的 SQL 日志语句来查看每种策略的行为。

在使用Hibernate构建持久层并使用时间戳字段时,我们通常还需要处理时区详细信息。自 Java 8 以来,表示带有时区的时间戳的最常见方法是使用OffsetDateTime和ZonedDateTime类。但是,将它们存储在数据库中是一项挑战,因为根据 JPA 规范,它们不是有效的属性类型。

Hibernate 6 引入了@TimeZoneStorage注释来解决上述挑战。此注释提供了灵活的选项,用于配置如何在数据库中存储和检索时区信息。

在本教程中,我们将探索 Hibernate 的@TimeZoneStorage注释及其各种存储策略。我们将通过实际示例来了解每种策略的行为,使我们能够根据特定需求选择最佳策略。

应用程序设置
在我们探索Hibernate 中的@TimeZoneStorage注释之前,让我们先设置一个将在本教程中使用的简单应用程序。

依赖项
让我们首先将Hibernate 依赖项添加到项目的pom.xml文件中:

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

此依赖项为我们提供了核心 Hibernate ORM功能,包括我们在本教程中讨论的@TimeZoneStorage注释。

定义实体类和存储库层
现在,让我们定义实体类:

@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;
   
// standard setters and getters
}

为了演示,我们将戴上天文学极客的帽子。AstronomicalObservation类是我们示例中的核心实体,我们将使用它来了解@TimeZoneStorage注释在接下来的部分中的工作原理。

定义好实体类后,让我们创建其对应的存储库接口:

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

我们的AstronomicalObservationRepository接口扩展了JpaRepository并允许我们与数据库进行交互。

启用 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。

值得注意的是,上述配置仅用于实际演示,并不用于生产用途。

@TimeZoneStorage策略
现在我们已经设置了应用程序,让我们看看使用@TimeZoneStorage注释时可用的不同存储策略。

1.原生NATIVE
在研究NATIVE策略之前,我们先来谈谈TIMESTAMP WITH TIME ZONE数据类型。它是一种 SQL 标准数据类型,能够存储时间戳和时区信息。但是,并非所有数据库供应商都支持它。PostgreSQL 和 Oracle 是支持它的流行数据库。

让我们用@TimeZoneStorage注释我们的observationStartTime字段并使用NATIVE策略:

@TimeZoneStorage(TimeZoneStorageType.NATIVE)
private ZonedDateTime observationStartTime;

当我们使用NATIVE策略时,Hibernate 将ZonedDateTime或OffsetDateTime值直接存储在TIMESTAMP WITH TIME ZONE类型的列中。让我们看看实际效果:

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

让我们看一下执行上述代码保存新的AstronomicalObservation对象时生成的日志:

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.759673+05:30[Asia/Kolkata]]

从日志中可以明显看出,我们的ZonedDateTime值直接映射到TIMESTAMP_WITH_TIMEZONE列,保留了时区信息。

如果我们的数据库支持这种数据类型,那么建议采用NATIVE策略来存储带有时区的时间戳。

2.柱COLUMN
COLUMN策略将时间戳和时区偏移存储在单独的表列中。时区偏移存储在INTEGER类型的列中。

让我们在AstronomicalObservation实体类的peakVisibilityTime属性上使用此策略:

@TimeZoneStorage(TimeZoneStorageType.COLUMN)
@TimeZoneColumn(name = "peak_visibility_time_offset")
private OffsetDateTime peakVisibilityTime;
@Column(name =
"peak_visibility_time_offset", insertable = false, updatable = false)
private Integer peakVisibilityTimeOffset;

我们还声明了一个新的peakVisibilityTimeOffset属性,并使用@TimeZoneColumn注释来告诉 Hibernate 使用它来存储时区偏移量。然后,我们将insertable和updatable属性设置为false ,这是必要的,因为 Hibernate 通过@TimeZoneColumn注释来管理它,以防止映射冲突。

如果我们不使用@TimeZoneColumn注释,Hibernate 会假定时区偏移列名称以_tz为后缀。在我们的示例中,它将是peak_visibility_time_tz。

现在,让我们看看当我们使用COLUMN策略保存AstronomicalObservation实体时会发生什么:

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.441296Z]
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.标准化NORMALIZE
接下来,我们来看看NORMALIZE策略。当我们使用此策略时,Hibernate 会将时间戳标准化为我们应用程序的本地时区,并存储不带时区信息的时间戳值。当我们从数据库获取记录时,Hibernate 会将我们的本地时区添加到时间戳值中。

让我们仔细看看这个行为。首先,让我们用@TimeZoneStorage注释我们的nextExpectedAppearance属性并指定NORMALIZE策略:

@TimeZoneStorage(TimeZoneStorageType.NORMALIZE)
private ZonedDateTime nextExpectedAppearance;

现在,让我们保存一个AstronomicalObservation实体并分析 SQL 日志以了解发生了什么:

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)。然后,我们创建一个新的AstronomicalObservation实体,并将其nextExpectedAppearance设置为时区为UTC+8的ZonedDateTime。最后,我们将实体保存在数据库中。

在执行上述代码并分析日志之前,我们需要在application.yaml文件中为 Hibernate 的ResourceRegistryStandardImpl类添加一些额外的日志记录:

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 )之间的差值,从而存储的时间为15: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.results : Extracted JDBC value [1] - [test-planet]
org.hibernate.orm.results : Extracted JDBC value [2] - [1999-12-25T15:30+05:30[Asia/Kolkata]]

Hibernate 重建ZonedDateTime值并添加我们应用程序的本地时区+05:30。我们可以看到,该值不在我们存储的UTC+8时区内。

当我们的应用程序在多个时区运行时,我们需要谨慎使用此策略。例如,在负载均衡器后面运行应用程序的多个实例时,我们需要确保我们的实例具有相同的默认时区,以避免不一致。

4.标准化_UTC NORMALIZE_UTC
NORMALIZE_UTC策略与我们在上一节中探讨的NORMALIZE策略类似。唯一的区别是,它始终将时间戳标准化为 UTC ,而不是使用我们应用程序的本地时区。

让我们看看这个策略是如何工作的。我们将在AstronomicalObservation类的lastRecordingSighting属性上指定它:

@TimeZoneStorage(TimeZoneStorageType.NORMALIZE_UTC)
private OffsetDateTime lastRecordedSighting;

现在,让我们保存一个AstronomicalObservation实体,并将其lastRecordedSighting属性设置为具有UTC+8偏移量的OffsetDateTime:

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,方法是减去八小时,然后将其存储到数据库中。

为了确保从数据库检索时间戳值时不会将本地时区偏移量添加到时间戳值中,让我们查看获取之前保存的对象时生成的日志:

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.results : Extracted JDBC value [1] - [test-planet]
org.hibernate.orm.results : Extracted JDBC value [2] - [1999-12-25T10:00Z]

虽然我们丢失了UTC+8的原始时区信息,但OffsetDateTime仍然代表相同的时间点。

5.自动
AUTO策略让 Hibernate 根据我们的数据库选择适当的策略。

如果我们的数据库支持TIMESTAMP WITH TIME ZONE数据类型,Hibernate 将使用NATIVE策略。否则,它将使用COLUMN策略。

在大多数情况下,我们都会知道我们正在使用的数据库,因此明确使用适当的策略而不是依赖AUTO策略通常是一个好主意。

6.默认
DEFAULT策略与AUTO策略非常相似。它允许 Hibernate 根据我们使用的数据库选择适当的策略。

如果我们的数据库支持TIMESTAMP WITH TIME ZONE数据类型,Hibernate 将使用NATIVE策略。否则,它将使用NORMALIZE_UTC策略。

再次,当我们知道正在使用什么数据库时,明确使用适当的策略通常是一个好主意。