给API和MySQL选择正确的时间类型

国际化业务对时间的处理非常重要。考虑到复杂的时区问题,在设计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.603Z2018-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的时间类型主要有timestampdatetime两种。其中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的timestampdatetime

映射到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和MySQL选择正确的时间类型_第1张图片
image.png

如何抉择

一般情况下,数据处理流程如API<->VO<->DTO<->DO<->MySQL。在不同的环节可以使用不同的时间类型。下面分别针对API和数据库讨论一下最佳选择。

API

我们只讨论直接跟用户打交道的VO

OpenAPI

OpenAPI 3.0 Specification里面字符串类型有时间格式,所以直接使用UTC时间也能得到良好的支持。

给API和MySQL选择正确的时间类型_第2张图片
image.png

Swagger-UI里面看到的效果如下所示。

给API和MySQL选择正确的时间类型_第3张图片
image.png

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这样的字符串会提示以下错误。

给API和MySQL选择正确的时间类型_第4张图片
image.png

统一时区

为了简化时间转换逻辑,避免出错,提升性能,将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

这也是一种常见方案。

结论

  1. 设置Java服务进程的时区为UTC,在边界将时间转换成UTC时间之后,内部无需再考虑时区问题,逻辑简单,性能也好。
  2. API入参和返回值大胆使用JDK 8的OffsetDateTime类型,SpringBoot也有良好的支持。
  3. 数据库URL设置serverTimezone=UTC,字段使用datetime类型,使用SQL语句更加方便。
  4. 本地时间交给前端或者SDK转换。根据用户的时区,随时切换到对应的本地时间。

参考资料

  1. 一次 JDBC 与 MySQL 因 “CST” 时区协商误解导致时间差了 14 或 13 小时的排错经历
  2. Java编程基础:在Mybatis注解中使用typeHandler实现Java枚举与数据库int值的自动转换

你可能感兴趣的:(给API和MySQL选择正确的时间类型)