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进程时区
- 查看操作系统时区
-
[[email protected] ~]$ date -R -
Wed, 17 Jul 2019 16:48:32 +0800 -
[[email protected] ~]$ cat /etc/timezone -
Asia/Shanghai
- 查看java进程时区
-
[[email protected] ~]$ 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类型的值。
由上图debug看到 Timestamp 是JDK中的类,也就是说这里看到的是JDK使用的时间和时区,从图中标注2处可以看出JDK使用的时区也是东八区,但是从1和3处看起来似乎有点不一样,首先1处变化为UTC/GMT+0900,3处有一个daylightSaving的这样一个时间,换算为小时刚好为1个小时。这个值通过google搜索知道叫做夏令时。
常用时间概念 UTC,GMT,CST,DST
-
UTC 协调世界时(英语:Coordinated Universal Time,法语:Temps Universel Coordonné,简称UTC)是最主要的世界时间标准,其以原子时秒长为基础,在时刻上尽量接近于格林尼治标准时间。中华民国采用CNS 7648的《资料元及交换格式–资讯交换–日期及时间的表示法》(与ISO 8601类似)称之为世界协调时间。中华人民共和国采用ISO 8601:2000的国家标准GB/T 7408-2005《数据元和交换格式 信息交换 日期和时间表示法》中亦称之为协调世界时。(摘自:https://zh.wikipedia.org/wiki/%E5%8D%8F%E8%B0%83%E4%B8%96%E7%95%8C%E6%97%B6)
-
GMT 格林尼治标准时间(英语:Greenwich Mean Time,GMT)是指位于英国伦敦郊区的皇家格林尼治天文台当地的平太阳时,因为本初子午线被定义为通过那里的经线。(摘自:https://zh.wikipedia.org/wiki/%E6%A0%BC%E6%9E%97%E5%B0%BC%E6%B2%BB%E6%A8%99%E6%BA%96%E6%99%82%E9%96%93)
-
CST 北京时间,又名中国标准时间,是中国大陆的标准时间,比世界协调时快八小时(即UTC+8),与香港、澳门、台北、吉隆坡、新加坡等地的标准时间相同。
北京时间并不是北京市的地方平太阳时间(东经116.4°),而是东经120°的地方平太阳时间,二者相差约14.5分钟[1]。北京时间由位于中国版图几何中心位置陕西临潼的中国科学院国家授时中心的9台铯原子钟和2台氢原子钟组通过精密比对和计算实现报时,并通过人造卫星与世界各国授时部门进行实时比对。(摘自:https://zh.wikipedia.org/wiki/%E5%8C%97%E4%BA%AC%E6%97%B6%E9%97%B4)
-
DST 夏时制(英语:daylight time,英国与其他地区),又称夏令时、日光节约时间(英语:daylight saving time, DST,美国),是一种在夏季月份牺牲正常的日出时间,而将时间调快的做法。通常使用夏时制的地区,会在接近春季开始的时候,将时间调快一小时,并在秋季调回正常时间[1]。实际上,夏时制会造成在春季转换当日的睡眠时间减少一小时,而在秋季转换当日则会多出一小时的睡眠时间[2][3]。(摘自:https://zh.wikipedia.org/wiki/%E5%A4%8F%E6%97%B6%E5%88%B6)
-
中国夏令时 1986年4月,中国中央有关部门发出“在全国范围内实行夏时制的通知”,具体作法是:每年从四月中旬第一个星期日的凌晨2时整(北京时间),将时钟拨快一小时,即将表针由2时拨至3时,夏令时开始;到九月中旬第一个星期日的凌晨2时整(北京夏令时),再将时钟拨回一小时,即将表针由2时拨至1时,夏令时结束。从1986年到1991年的六个年度,除1986年因是实行夏时制的第一年,从5月4日开始到9月14日结束外,其它年份均按规定的时段施行。在夏令时开始和结束前几天,新闻媒体均刊登有关部门的通告。1992年起,夏令时暂停实行。(摘自:https://baike.baidu.com/item/%E5%A4%8F%E4%BB%A4%E6%97%B6)
中国夏时制实施时间规定(夏令时) 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表示的时间戳存到BIGINT或REAL类型的列中,完全不用管数据库自己提供的DATETIME或TIMESTAMP,也不用担心应用服务器和数据库服务器的时区设置问题,遇到Oracle数据库你不必去理会with timezone和with local timezone到底有啥区别。读取时间时,读到的是一个Long或Float,只需要按照用户的时区格式化为字符串就能正确地显示出来。
基于绝对时间戳的时间存储,从根本上就没有时区的问题。时区只是一个显示问题。额外获得的好处还包括:
- 两个时间的比较就是数值的比较,根本不涉及时区问题,极其简单;
- 时间的筛选也是两个数值之间筛选,写出SQL就是
between(?, ?); - 显示时间时,把Long或Float传到页面,无论用服务端脚本还是用JavaScript都能简单而正确地显示时间。
你唯一需要编写的两个辅助函数就是String->Long和Long->String。String->Long的作用是把用户输入的时间字符串按照用户指定时区转换成Long存进数据库。
唯一的缺点是数据库查询你看到的不是时间字符串,而是类似1413266801750之类的数字。