一次因JDK夏令时致使接口输出日期格式的时间与预期时间不一致的bug排查总结

bug描述

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

环境说明

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

  • 系统: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>
复制代码

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

确认操做系统和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进程使用的时区一致,都是东八区。sql

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

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

this.register((JdbcType)JdbcType.TIMESTAMP, (TypeHandler)(new DateTypeHandler()));
复制代码

进一步查看 DateTypeHandler.java 类:apache

//
// 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类型的值。json

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

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

  • UTC 协调世界时(英语:Coordinated Universal Time,法语:Temps Universel Coordonné,简称UTC)是最主要的世界时间标准,其以原子时秒长为基础,在时刻上尽可能接近于格林尼治标准时间中华民国采用CNS 7648的《资料元及交换格式–资讯交换–日期及时间的表示法》(与ISO 8601相似)称之为世界协调时间。中华人民共和国采用ISO 8601:2000的国家标准GB/T 7408-2005《数据元和交换格式 信息交换 日期和时间表示法》中亦称之为协调世界时。(摘自:zh.wikipedia.org/wiki/%E5%8D…bash

  • GMT 格林尼治标准时间(英语:Greenwich Mean Time,GMT)是指位于英国伦敦郊区的皇家格林尼治天文台当地的平太阳时,由于本初子午线被定义为经过那里的经线。(摘自:zh.wikipedia.org/wiki/%E6%A0…服务器

  • CST 北京时间,又名中国标准时间,是中国大陆标准时间,比世界协调时快八小时(即UTC+8),与香港澳门台北吉隆坡新加坡等地的标准时间相同。

    北京时间并非北京市的地方平太阳时间(东经116.4°),而是东经120°的地方平太阳时间,两者相差约14.5分钟[1]。北京时间由位于中国版图几何中心位置陕西临潼中国科学院国家授时中心的9台铯原子钟和2台氢原子钟组经过精密比对和计算实现报时,并经过人造卫星与世界各国授时部门进行实时比对。(摘自:zh.wikipedia.org/wiki/%E5%8C…

  • DST 夏时制(英语:daylight time,英国与其余地区),又称夏令时、日光节约时间(英语:daylight saving time, DST,美国),是一种在夏季月份牺牲正常的日出时间,而将时间调快的作法。一般使用夏时制的地区,会在接近春季开始的时候,将时间调快一小时,并在秋季调回正常时间[1]。实际上,夏时制会形成在春季转换当日的睡眠时间减小一小时,而在秋季转换当日则会多出一小时的睡眠时间[2][3]。(摘自:zh.wikipedia.org/wiki/%E5%A4…

  • 中国夏令时 1986年4月,中国中央有关部门发出“在全国范围内实行夏时制的通知”,具体做法是:每一年从四月中旬第一个星期日的凌晨2时整(北京时间),将时钟拨快一小时,即将表针由2时拨至3时,夏令时开始;到九月中旬第一个星期日的凌晨2时整(北京夏令时),再将时钟拨回一小时,即将表针由2时拨至1时,夏令时结束。从1986年到1991年的六个年度,除1986年因是实行夏时制的第一年,从5月4日开始到9月14日结束外,其它年份均按规定的时段施行。在夏令时开始和结束前几天,新闻媒体均刊登有关部门的通告。1992年起,夏令时暂停实行。(摘自:baike.baidu.com/item/%E5%A4…

中国夏时制实施时间规定(夏令时)
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排查我的获得了一些收获。

时间的正确的存储方式

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

摘自:www.liaoxuefeng.com/article/978…

基于“数据的存储和显示相分离”的设计原则,咱们只要把表示绝对时间的时间戳(不管是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
相关文章
相关标签/搜索