国际化业务对时间的处理非常重要。考虑到复杂的时区问题,在设计API和MySQL表的时候,需要谨慎选择时间类型。
对API而言,要定义好入参和返回值里面的时间格式。使用带时区的时间,如2018-12-06T11:22:00.000+08:00
,对用户而言,是比较直观和易于调试的。
对于数据库而言,时间处理的流程如:Java<--MyBatis(TypeHandler)转换-->JDBC<--转换->MySQL
。数据库里面必须保存UTC时间。另外需要避免多余的转换,耗费性能。
Java 8时间类型
Java 8对时间类型做了大的改动,设计更加合理,提高了易用性。基于Java 8的服务不妨使用新的类型。
jshell> ZonedDateTime zonedDateTime = ZonedDateTime.of(LocalDateTime.now(), ZoneId.of("UTC+08:00"));
zoned_now ==> 2018-12-05T10:55:52.895542+08:00[UTC+08:00]
| 已修改 变量 zonedDateTime : ZonedDateTime
| 更新已覆盖 变量 zonedDateTime : ZonedDateTime
jshell> zonedDateTime.withZoneSameInstant(ZoneOffset.UTC)
$18 ==> 2018-12-05T02:55:52.895542Z
| 已创建暂存变量 $18 : ZonedDateTime
jshell> OffsetDateTime offsetDateTime = OffsetDateTime.now();
offsetDateTime ==> 2018-12-11T17:48:25.087066+08:00
有一个很有趣的问题是,Objects.equals居然会认为2018-12-12T12:19:13.603Z
和2018-12-12T20:19:13.603+0800
这两个时间不相等。
jshell> OffsetDateTime t1 = OffsetDateTime.parse("2018-12-12T12:19:13.603Z")
t1 ==> 2018-12-12T12:19:13.603Z
jshell> OffsetDateTime t2= OffsetDateTime.parse("2018-12-12T20:19:13.603+08:00")
t2 ==> 2018-12-12T20:19:13.603+08:00
jshell> t1.toEpochSecond()
$18 ==> 1544617153
jshell> t2.toEpochSecond()
$19 ==> 1544617153
jshell> Objects.equals(t1, t2)
$17 ==> false
jshell> ZonedDateTime t1 = ZonedDateTime.parse("2018-12-12T12:19:13.603Z")
t1 ==> 2018-12-12T12:19:13.603Z
jshell> ZonedDateTime t2 = ZonedDateTime.parse("2018-12-12T20:19:13.603+08:00")
t2 ==> 2018-12-12T20:19:13.603+08:00
jshell> Objects.equals(t1, t2)
$22 ==> false
MySQL时间类型
MySQL的时间类型主要有timestamp
和datetime
两种。其中timestamp
只有32位长,有2038年溢出问题,不能使用。
timestamp
会按照UTC+0的时间保存,但是存取都受会话的time zone影响,比较敏感,容易出问题。datetime
则不受时区影响,很稳定。
这两种类型的对比,请看MySQL的官方文档:11.3.1 The DATE, DATETIME, and TIMESTAMP Types。
MySQL converts TIMESTAMP values from the current time zone to UTC for
storage, and back from UTC to the current time zone for retrieval. (This
does not occur for other types such as DATETIME.) By default, the current
time zone for each connection is the server's time. The time zone can be set
on a per-connection basis. As long as the time zone setting remains
constant, you get back the same value you store. If you store a TIMESTAMP
value, and then change the time zone and retrieve the value, the retrieved
value is different from the value you stored.
JDBC时间类型
JDBC Timestamp
类型其实相当于Java DateTime类型,能包含年月日时分秒信息。JDBC Timestamp
可以映射到MySQL的timestamp
和datetime
。
映射到MySQL的timestamp
,需要考虑Java进程的时区和MySQL连接的时区。而映射到datetime
类型,则只需要考虑Java进程的时区。
因为MySQL datetime
没有时区信息了,JDBC Timestamp
转换成MySQL datetime
,会根据MySQL的serverTimezone
做一次转换。
//此时instant依旧是UTC+0的时间格式
Instant instant = offsetDateTime.toInstant();
//timestamp会变成本地时间的格式
Timestamp timestamp = Timestamp.from(instant);
//会根据MySQL的serverTimezone做一次转换
timestamp-->MySQL datetime
如何抉择
一般情况下,数据处理流程如API<->VO<->DTO<->DO<->MySQL
。在不同的环节可以使用不同的时间类型。下面分别针对API和数据库讨论一下最佳选择。
API
我们只讨论直接跟用户打交道的VO
。
OpenAPI
OpenAPI 3.0 Specification里面字符串类型有时间格式,所以直接使用UTC时间也能得到良好的支持。
Swagger-UI里面看到的效果如下所示。
Swagger偏爱OffsetDateTime
,swagger-codegen会将type=string,format=date-time
映射成OffsetDateTime
,而不是ZonedDateTime
。
SpringBoot
SpringBoot对时间类型的属性提供了校验功能。比如对OffsetDateTime
类型,加上@DateTimeFormat
注解,就可以校验时间是否合法。只要符合UTC时间格式的规范,都可以被自动转换成OffsetDateTime
对象。@Future
注解同样可用。
@DateTimeFormat
OffsetDateTime datetimeStartTask;
@DateTimeFormat
OffsetDateTime datetimeEndTask;
datetimeStartTask
输入abc
这样的字符串会提示以下错误。
统一时区
为了简化时间转换逻辑,避免出错,提升性能,将Java服务进程的时区设置成UTC+0是一种比较好的办法。设置方式如下面代码所示。
@Configuration
public class UTCTimeZoneConfiguration implements ServletContextListener {
public void contextInitialized(ServletContextEvent event) {
System.setProperty("user.timezone", "UTC");
TimeZone.setDefault(TimeZone.getTimeZone("UTC"));
}
public void contextDestroyed(ServletContextEvent event) {}
}
数据库
Mybatis
Mybatis默认实现了很多TypeHandler
用于做Java类型和JDBC类型之间的转换,比如OffsetDateTimeTypeHandler
,用于处理OffsetDateTime
到JDBC Timestamp
类型的转换。
/**
* @since 3.4.5
* @author Tomas Rohovsky
*/
@UsesJava8
public class OffsetDateTimeTypeHandler extends BaseTypeHandler {
@Override
public void setNonNullParameter(PreparedStatement ps, int i, OffsetDateTime parameter, JdbcType jdbcType)
throws SQLException {
ps.setTimestamp(i, Timestamp.from(parameter.toInstant()));
}
@Override
public OffsetDateTime getNullableResult(ResultSet rs, String columnName) throws SQLException {
Timestamp timestamp = rs.getTimestamp(columnName);
return getOffsetDateTime(timestamp);
}
@Override
public OffsetDateTime getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
Timestamp timestamp = rs.getTimestamp(columnIndex);
return getOffsetDateTime(timestamp);
}
@Override
public OffsetDateTime getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
Timestamp timestamp = cs.getTimestamp(columnIndex);
return getOffsetDateTime(timestamp);
}
private static OffsetDateTime getOffsetDateTime(Timestamp timestamp) {
if (timestamp != null) {
return OffsetDateTime.ofInstant(timestamp.toInstant(), ZoneId.systemDefault());
}
return null;
}
}
使用datetime
数据库使用datetime
保存时间。其好处是,datetime
是MySQL原生类型,时间信息一目了然,有利于人们执行SQL分析和排查问题。
Java服务进程将时区统一设置为UTC+0之后,数据库也需要将时区设置为UTC+0。在数据库url加上serverTimezone=UTC
参数即可。
spring.datasource.url = jdbc:mysql://127.0.0.1:3306/xxx?serverTimezone=UTC
这个方案的缺点就是,每次连接数据库一定要带上serverTimezone=UTC
参数,否则插入数据会有问题。操作数据库的渠道很多,除了Java服务,还会有一些控制台,甚至码农手动连接上去操作,这些渠道需要注意这个参数。
使用int64
这也是一种常见方案。
结论
- 设置Java服务进程的时区为UTC,在边界将时间转换成UTC时间之后,内部无需再考虑时区问题,逻辑简单,性能也好。
- API入参和返回值大胆使用JDK 8的
OffsetDateTime
类型,SpringBoot也有良好的支持。 - 数据库URL设置
serverTimezone=UTC
,字段使用datetime
类型,使用SQL语句更加方便。 - 本地时间交给前端或者SDK转换。根据用户的时区,随时切换到对应的本地时间。
参考资料
- 一次 JDBC 与 MySQL 因 “CST” 时区协商误解导致时间差了 14 或 13 小时的排错经历
- Java编程基础:在Mybatis注解中使用typeHandler实现Java枚举与数据库int值的自动转换