介绍
当涉及到时区时,处理全球生成的时间戳并没有简单的解决方案。在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渲染的前端节点。