介绍

当涉及到时区时,处理全球生成的时间戳并没有简单的解决方案。在Java 1.8之前,日期和日历API也无法解决这个问题。

虽然java.time包在处理关系数据库(尤其是JDBC)的日期/时间对象时提供了更好的处理,但API仍然很大程度上基于可怕的java.util.Calendar

在这篇文章中,我将揭示我们添加的新配置属性,以便我们可以在JDBC级别更好地处理时间戳。

问题

让我们假设我们有一个以下JPA实体

@Entity(name = "Person")
public class Person {

    @Id
    private Long id;

    private Timestamp createdOn;
}

由于我们的应用程序可以通过互联网访问,因此如果每个用户都以UTC格式保存所有时间戳值,将变得更加简单。这种约定非常普遍

当Alice(住在洛杉矶)将以下Person实体插入数据库时

doInHibernate( this::sessionFactory, session -> {
    Person person = new Person();
    person.id = 1L;

    //Y2K - 946684800000L
    long y2kMillis = LocalDateTime.of( 2000, 1, 1, 0, 0, 0 )
        .atZone( ZoneId.of( "UTC" ) )
        .toInstant()
        .toEpochMilli();
    assertEquals(946684800000L, y2kMillis);

    person.createdOn = new Timestamp(y2kMillis);
    session.persist( person );
} );

Hibernate执行以下INSERT语句

INSERT INTO Person (createdOn, id)
VALUES (?, ?)

-- binding parameter [1] as [TIMESTAMP] - [1999-12-31 16:00:00.0]
-- binding parameter [2] as [BIGINT]    - [1]

时间戳值被设置为1999-12-31 16:00:00.0,这是我们查询数据库时得到的结果

s.doWork( connection -> {
    try (Statement st = connection.createStatement()) {
        try (ResultSet rs = st.executeQuery(
                "SELECT to_char(createdon, 'YYYY-MM-DD HH24:MI:SS.US') " +
                "FROM person" )) {
            while ( rs.next() ) {
                assertEquals(
                    "1999-12-31 16:00:00.000000",
                    rs.getString( 1 )
                );
            }
        }
    }
} );

发生了什么?

因为Alice的时区,即太平洋标准时间(UTC-8),比UTC慢8个小时,时间戳值被转换到了本地JVM时区。但为什么是这样呢?

为了回答这个问题,我们首先需要检查Hibernate 5.2.2中如何保存底层的timestamp值,TimestampTypeDescriptor

st.setTimestamp( index, timestamp );

如果我们查看PostgreSQL JDBC驱动程序版本9.4.1210.jre7(2016年9月)的PreparedStatement.setTimestamp()实现,我们将找到以下逻辑

public void setTimestamp(int parameterIndex, Timestamp x)
    throws SQLException {
    setTimestamp(parameterIndex, x, null);
}

public void setTimestamp(int i, Timestamp t, java.util.Calendar cal)
    throws SQLException {
    checkClosed();

    if (t == null) {
      setNull(i, Types.TIMESTAMP);
      return;
    }

    int oid = Oid.UNSPECIFIED;
    if (t instanceof PGTimestamp) {
      PGTimestamp pgTimestamp = (PGTimestamp) t;
      if (pgTimestamp.getCalendar() == null) {
        oid = Oid.TIMESTAMP;
      } else {
        oid = Oid.TIMESTAMPTZ;
        cal = pgTimestamp.getCalendar();
      }
    }
    if (cal == null) {
      cal = getDefaultCalendar();
    }
    bindString(i, connection.getTimestampUtils().toString(cal, t), oid);
}

因此,如果没有传递Calendar,将使用以下默认的Calendar

private Calendar getDefaultCalendar() {
    TimestampUtils timestampUtils = connection.getTimestampUtils();

    if (timestampUtils.hasFastDefaultTimeZone()) {
      return timestampUtils.getSharedCalendar(null);
    }
    Calendar sharedCalendar = timestampUtils
        .getSharedCalendar(defaultTimeZone);
    if (defaultTimeZone == null) {
      defaultTimeZone = sharedCalendar.getTimeZone();
    }
    return sharedCalendar;
}

因此,除非我们提供了默认的 java.util.Calendar,否则PostgreSQL将使用默认的一个,它将回退到底层的JVM时区。

解决方案

传统上,为了克服这个问题,应该将JVM时区设置为UTC

可以声明性设置

java -Duser.timezone=UTC ...

或者程序性设置

TimeZone.setDefault( TimeZone.getTimeZone( "UTC" ) );

如果将JVM时区设置为UTC,Hibernate将执行以下插入语句

INSERT INTO Person (createdOn, id)
VALUES (?, ?)

-- binding parameter [1] as [TIMESTAMP] - [2000-01-01 00:00:00.0]
-- binding parameter [2] as [BIGINT]    - [1]

当从数据库获取时间戳值时也是如此

s.doWork( connection -> {
    try (Statement st = connection.createStatement()) {
        try (ResultSet rs = st.executeQuery(
                "SELECT to_char(createdon, 'YYYY-MM-DD HH24:MI:SS.US') " +
                "FROM person" )) {
            while ( rs.next() ) {
                String timestamp = rs.getString( 1 );
                assertEquals("2000-01-01 00:00:00.000000", timestamp);
            }
        }
    }
} );

不幸的是,有时我们无法更改JVM的默认时区,因为UI需要它将基于UTC的时间戳渲染到用户特定的区域设置和当前时区。

JDBC时区设置

从Hibernate 5.2.3版本开始,您将能够提供JDBC级别的时区,这样您就不必更改默认的JVM设置。

这是通过设置SessionFactory级别的配置属性hibernate.jdbc.time_zone来实现的

settings.put(
    AvailableSettings.JDBC_TIME_ZONE,
    TimeZone.getTimeZone( "UTC" )
);

一旦设置,Hibernate将调用以下JDBC PreparedStatement.setTimestamp() 方法,该方法接受一个特定的Calendar实例。

现在,当执行插入语句时,Hibernate将记录以下查询参数

INSERT INTO Person (createdOn, id)
VALUES (?, ?)

-- binding parameter [1] as [TIMESTAMP] - [1999-12-31 16:00:00.0]
-- binding parameter [2] as [BIGINT]    - [1]

这是预期的,因为java.sql.Timestamp使用Alice的JVM日历(例如,洛杉矶)来显示底层日期/时间值。当从数据库中获取实际的日期戳值时,我们可以看到实际保存的是UTC值

s.doWork( connection -> {
    try (Statement st = connection.createStatement()) {
        try (ResultSet rs = st.executeQuery(
                "SELECT " +
                "   to_char(createdon, 'YYYY-MM-DD HH24:MI:SS.US') " +
                "FROM person" )) {
            while ( rs.next() ) {
                String timestamp = rs.getString( 1 );
                assertEquals("2000-01-01 00:00:00.000000", timestamp);
            }
        }
    }
} );

您甚至可以在每个Session级别上覆盖此设置

Session session = sessionFactory()
    .withOptions()
    .jdbcTimeZone( TimeZone.getTimeZone( "UTC" ) )
    .openSession();

由于许多应用程序在存储时间戳时倾向于使用相同的时区(通常是UTC),因此这个更改将非常有用,特别是对于需要保留默认JVM时区进行UI渲染的前端节点。


回到顶部