美文网首页
一次因JDK夏令时导致接口输出日期格式的时间与预期时间不一致的b

一次因JDK夏令时导致接口输出日期格式的时间与预期时间不一致的b

作者: alexpdh | 来源:发表于2019-07-18 14:17 被阅读0次

bug描述

问题起源于同事在项目中新增一个统计用户生日明细的接口,其中一个用户在数据库中的生日日期是“1988-07-29”,然而通过rest接口得到该用户的生日日期却为 “1988-07-28”。

环境说明

开始bug排查之前,先说明下项目环境:

  • 系统:centos 7.5
  • JDK:1.8.0_171
  • 技术栈:spring boot、Jackson、Druid、mybatis、oracle。

bug 排查

从数据层开始查找,先查询数据库时间和时区。

SQL> SELECT SYSTIMESTAMP, SESSIONTIMEZONE FROM DUAL;
SYSTIMESTAMP                                                                     SESSIONTIMEZONE
-------------------------------------------------------------------------------- ---------------------------------------------------------------------------
17-JUL-19 02.20.06.687149 PM +08:00                                              +08:00

SQL>

数据库时间和时区都没有问题。

确认操作系统和java进程时区

  • 查看操作系统时区
[test@test ~]$ date -R
Wed, 17 Jul 2019 16:48:32 +0800
[test@test ~]$ cat /etc/timezone
Asia/Shanghai
  • 查看java进程时区
[test@test ~]$ jinfo 7490 |grep user.timezone
user.timezone = Asia/Shanghai

可以看出我们操作系统使用的时区和java进程使用的时区一致,都是东八区。

用debug继续往上层查找查看mybatis和JDBC层

查看了问题字段mapper映射字段的jdbcType类型为jdbcType="TIMESTAMP",在mybatis中类型处理注册类TypeHandlerRegistry.java 中对应的处理类为 DateTypeHandler.java。

this.register((JdbcType)JdbcType.TIMESTAMP, (TypeHandler)(new DateTypeHandler()));

进一步查看 DateTypeHandler.java 类:

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//

package org.apache.ibatis.type;

import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.util.Date;

public class DateTypeHandler extends BaseTypeHandler<Date> {
  public DateTypeHandler() {
  }

  public void setNonNullParameter(PreparedStatement ps, int i, Date parameter, JdbcType jdbcType) throws SQLException {
    ps.setTimestamp(i, new Timestamp(parameter.getTime()));
  }

  public Date getNullableResult(ResultSet rs, String columnName) throws SQLException {
    Timestamp sqlTimestamp = rs.getTimestamp(columnName);
    return sqlTimestamp != null ? new Date(sqlTimestamp.getTime()) : null;
  }

  public Date getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
    Timestamp sqlTimestamp = rs.getTimestamp(columnIndex);
    return sqlTimestamp != null ? new Date(sqlTimestamp.getTime()) : null;
  }

  public Date getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
    Timestamp sqlTimestamp = cs.getTimestamp(columnIndex);
    return sqlTimestamp != null ? new Date(sqlTimestamp.getTime()) : null;
  }
}

因为使用的数据源为Druid,其中 getNullableResult(ResultSet rs, String columnName) 方法参数中 ResultSet使用了DruidPooledResultSet.java 的 getTimestamp(String columnLabel) ,通过列名称获取值然后转换为Date类型的值。

debugDate.jpg

由上图debug看到 Timestamp 是JDK中的类,也就是说这里看到的是JDK使用的时间和时区,从图中标注2处可以看出JDK使用的时区也是东八区,但是从1和3处看起来似乎有点不一样,首先1处变化为UTC/GMT+0900,3处有一个daylightSaving的这样一个时间,换算为小时刚好为1个小时。这个值通过google搜索知道叫做夏令时。

常用时间概念 UTC,GMT,CST,DST

中国夏时制实施时间规定(夏令时)
1935年至1951年,每年5月1日至9月30日。
1952年3月1日至10月31日。
1953年至1954年,每年4月1日至10月31日。
1955年至1956年,每年5月1日至9月30日。
1957年至1959年,每年4月1日至9月30日。
1960年至1961年,每年6月1日至9月30日。
1974年至1975年,每年4月1日至10月31日。
1979年7月1日至9月30日。
1986年至1991年,每年4月中旬的第一个星期日1时起至9月中旬的第一个星期日1时止。具体如下:
1986年4月13日至9月14日,
1987年4月12日至9月13日,
1988年4月10日至9月11日,
1989年4月16日至9月17日,
1990年4月15日至9月16日,
1991年4月14日至9月15日。

通过对比我们可以看到应用中的对应的用户生日"1988-07-29"刚好在中国的夏令时区间内,因为我们操作系统、数据库、JDK使用的都是 "Asia/Shanghai" 时区,应该不会错,通过上图中debug结果我们也证实了结果是没问题的。

继续往外排查业务层和接口层,定位到问题

项目使用的是spring boot提供rest接口返回json报文,使用spring 默认的Jackson框架解析。项目中有需要对外输出统一日期格式,对Jackson做了一下配置:

#jackson
#日期格式化
spring.jackson.date-format=yyyy-MM-dd HH:mm:ss
spring.jackson.time-zone=GMT+8

我们通过查看 JacksonProperties.java源码:

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//

package org.springframework.boot.autoconfigure.jackson;

import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.core.JsonParser.Feature;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.SerializationFeature;
import java.util.EnumMap;
import java.util.Locale;
import java.util.Map;
import java.util.TimeZone;
import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(
  prefix = "spring.jackson"
)
public class JacksonProperties {
  private String dateFormat;
  private String jodaDateTimeFormat;
  private String propertyNamingStrategy;
  private Map<SerializationFeature, Boolean> serialization = new EnumMap(SerializationFeature.class);
  private Map<DeserializationFeature, Boolean> deserialization = new EnumMap(DeserializationFeature.class);
  private Map<MapperFeature, Boolean> mapper = new EnumMap(MapperFeature.class);
  private Map<Feature, Boolean> parser = new EnumMap(Feature.class);
  private Map<com.fasterxml.jackson.core.JsonGenerator.Feature, Boolean> generator = new EnumMap(com.fasterxml.jackson.core.JsonGenerator.Feature.class);
  private Include defaultPropertyInclusion;
  private TimeZone timeZone = null;
  private Locale locale;

  public JacksonProperties() {
  }

  public String getDateFormat() {
    return this.dateFormat;
  }

  public void setDateFormat(String dateFormat) {
    this.dateFormat = dateFormat;
  }

  public String getJodaDateTimeFormat() {
    return this.jodaDateTimeFormat;
  }

  public void setJodaDateTimeFormat(String jodaDataTimeFormat) {
    this.jodaDateTimeFormat = jodaDataTimeFormat;
  }

  public String getPropertyNamingStrategy() {
    return this.propertyNamingStrategy;
  }

  public void setPropertyNamingStrategy(String propertyNamingStrategy) {
    this.propertyNamingStrategy = propertyNamingStrategy;
  }

  public Map<SerializationFeature, Boolean> getSerialization() {
    return this.serialization;
  }

  public Map<DeserializationFeature, Boolean> getDeserialization() {
    return this.deserialization;
  }

  public Map<MapperFeature, Boolean> getMapper() {
    return this.mapper;
  }

  public Map<Feature, Boolean> getParser() {
    return this.parser;
  }

  public Map<com.fasterxml.jackson.core.JsonGenerator.Feature, Boolean> getGenerator() {
    return this.generator;
  }

  public Include getDefaultPropertyInclusion() {
    return this.defaultPropertyInclusion;
  }

  public void setDefaultPropertyInclusion(Include defaultPropertyInclusion) {
    this.defaultPropertyInclusion = defaultPropertyInclusion;
  }

  public TimeZone getTimeZone() {
    return this.timeZone;
  }

  public void setTimeZone(TimeZone timeZone) {
    this.timeZone = timeZone;
  }

  public Locale getLocale() {
    return this.locale;
  }

  public void setLocale(Locale locale) {
    this.locale = locale;
  }
}

得知 spring.jackson.time-zone 属性操作的就是java.util.TimeZone。于是我们通过一段测试代码模拟转换过程:

package com.test;

import java.sql.Date;
import java.util.TimeZone;

/**
 * @author alexpdh
 * @date 2019/07/17
 */
public class Test {

    public static void main(String[] args) {
        System.out.println("当前的默认时区为: " + TimeZone.getDefault().getID());
        Date date1 = Date.valueOf("1988-07-29");
        Date date2 = Date.valueOf("1983-07-29");
        System.out.println("在中国夏令时范围内的时间 date1=" + date1);
        System.out.println("正常东八区时间 date2=" + date2);
//      模拟 spring.jackson.time-zone=GMT+8 属性设置
        TimeZone zone = TimeZone.getTimeZone("GMT+8");
        TimeZone.setDefault(zone);
        System.out.println(TimeZone.getDefault().getID());
        Date date3 = date1;
        Date date4 = date2;
        System.out.println("转换后的在中国夏令时范围内的时间date3=" + date3);
        System.out.println("转换后的正常东八区时间 date4=" + date4);
    }
}

运行后输出结果:

当前的默认时区为: Asia/Shanghai
在中国夏令时范围内的时间 date1=1988-07-29
正常东八区时间 date2=1983-07-29
GMT+08:00
转换后的在中国夏令时范围内的时间date3=1988-07-28
转换后的正常东八区时间 date4=1983-07-29

从这里终于找到问题发生点了,从debug那张图我们看出了因为那个日期是在中国的夏令时区间内,要快一个小时,使用了UTC/GMT+0900的格式,而jackjson在将报文转换为json格式的时候使用的是UTC/GMT+0800的格式。也就是说我们将JDK时区为UTC/GMT+0900的"1988-07-29 00:00:00"这样的一个时间转换为了标准东八区的UTC/GMT+0800格式的时间,需要先调慢一个小时变成了"1988-07-28 23:00:00"。

bug解决

定位到问题解决就很简单了,只需要修改下设置:

#jackson
#日期格式化
spring.jackson.date-format=yyyy-MM-dd HH:mm:ss
spring.jackson.time-zone=Asia/Shanghai

保持时区一致问题得到解决。

总结

通过这次bug排查个人得到了一些收获。

时间的正确的存储方式

看过廖雪峰老师的一篇"如何正确地处理时间"的文章说到时间的正确的存储方式:

摘自:https://www.liaoxuefeng.com/article/978494994163392

基于“数据的存储和显示相分离”的设计原则,我们只要把表示绝对时间的时间戳(无论是Long型还是Float)存入数据库,在显示的时候根据用户设置的时区格式化为正确的字符串。所以,数据库存储时间和日期时,只需要把Long或者Float表示的时间戳存到BIGINTREAL类型的列中,完全不用管数据库自己提供的DATETIMETIMESTAMP,也不用担心应用服务器和数据库服务器的时区设置问题,遇到Oracle数据库你不必去理会with timezonewith local timezone到底有啥区别。读取时间时,读到的是一个Long或Float,只需要按照用户的时区格式化为字符串就能正确地显示出来。

基于绝对时间戳的时间存储,从根本上就没有时区的问题。时区只是一个显示问题。额外获得的好处还包括:

  • 两个时间的比较就是数值的比较,根本不涉及时区问题,极其简单;
  • 时间的筛选也是两个数值之间筛选,写出SQL就是between(?, ?)
  • 显示时间时,把Long或Float传到页面,无论用服务端脚本还是用JavaScript都能简单而正确地显示时间。

你唯一需要编写的两个辅助函数就是String->LongLong->StringString->Long的作用是把用户输入的时间字符串按照用户指定时区转换成Long存进数据库。

唯一的缺点是数据库查询你看到的不是时间字符串,而是类似1413266801750之类的数字。

在不同的系统软件或开发语言需要对时间处理的要注意时区的设置

这可以从系统底层操作系统、数据库、开发语言的时区保持一致开始。如使用java语言开发的系统,安装好操作系统之后可以先把系统时区和时间先调整好,然后再安装数据库和JDK,一般JDK安装时默认会使用操作系统设置的时区,这样就省去了重新再单独设置JDK时区的步骤了。在代码中如果需要使用什么框架或者包处理时间,在设置的时候需要先确认下系统时区和所在时区是否有夏令时的存在,貌似现在老美也还在使用夏令时。

gongzhongzhanghao.jpg

相关文章

网友评论

      本文标题:一次因JDK夏令时导致接口输出日期格式的时间与预期时间不一致的b

      本文链接:https://www.haomeiwen.com/subject/ypgclctx.html