给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
image.png

如何抉择

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

API

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

OpenAPI

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

image.png

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

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

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<OffsetDateTime> {

  @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值的自动转换
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,793评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,567评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,342评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,825评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,814评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,680评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,033评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,687评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,175评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,668评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,775评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,419评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,020评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,206评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,092评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,510评论 2 343