排查诡异的夏令时问题
现象描述与复现
线上环境签署合同时发现某一员工生日为1988-04-15
,但合同上显示的是1988-04-14
缺少一天
初步怀疑是夏令时问题,所以一开始排查方向重点放在数据存储和读取时的时区转换,走了不少弯路
Tip: 夏令时是什么
1986年4月,中国中央有关部门发出“在全国范围内实行夏时制的通知”,具体做法是:每年从四月中旬第一个星期日的凌晨2时整(北京时间),将时钟拨快一小时,即将表针由2时拨至3时,夏令时开始;到九月中旬第一个星期日的凌晨2时整(北京夏令时),再将时钟拨回一小时,即将表针由2时拨至1时,夏令时结束。
1986年至1991年,中华人民共和国在全国范围实行了六年夏令时,每年从4月中旬的第一个星期日2时整(北京时间)到9月中旬第一个星期日的凌晨2时整(北京夏令时)。除1986年因是实行夏令时的第一年,从5月4日开始到9月14日结束外,其它年份均按规定的时段施行。夏令时实施期间,将时间调快一小时。1992年4月5日后不再实行。
问题排查
第一步: 日期的来源
日期从数据库读取而来,类型为DATE
类型,在数据库中保存的日期是1988-4-15
,所以写入日期功能应该没有问题的
第二步: 线下环境确认
测试环境发现改问题能复现,并且在页面显示上也能看出问题,代码如下所示:
1 | entryBasicInfo.setBirthday(new DateTime(entryInfoDetail.getBirthday(), DateTimeZone.forID("Asia/Shanghai")).plusHours(8).toDate()); |
可以看到页面展示的地方有+8小时的逻辑,也就是说读取到数据库时间为数据库中读取到的时间+8小时,代码如下所示:
1 | /** |
可以看到json序列化指定为GMT+8时区,如果是夏令时时间会转为为不执行夏令时的时间(-1hour), 返回值如下:
1 | { |
结合页面的返回值和阅读页面展示生日的代码,可以推断从数据库读取到的时间为夏令时时间1988-04-15 0:00:00(CDT)
再详细解释下为啥:
1988-04-15 07:00:00(GMT+8) = 1988-04-15 00:00:00(CDT) + 8 hours
为啥要从页面接口看?
因为生成合同逻辑在测试环境不好调试,所以借助页面展示接口推测从数据库读取到的Date数据
第三步 调试验证判断
如何验证我们前面推断出来的结论呢?肯定是依靠调试啦!造好数据,打上断点:
如我所料, 读取到的时间正是 1988-04-15 00:00:00(CDT)
下面继续追查,看在什么地方格式化为1988-04-14
第四步 本地与线上JDK版本不同引起的一段插曲
实际上在本人本地执行的结果如下,相同的输入下,本地的时间戳和远程调试的并不一样,很是困惑
对比发现zoneOffsets信息不同
经过光闪闪提醒,jdk会更新时区和夏令时信息,于是打开jdk更新公告查询,地址:https://www.oracle.com/java/technologies/tzdata-versions.html
Timezone Tzdata Version | Introduced in | Main Changes in this Timezone Data Release |
---|---|---|
tzdata2018f 2018/10/18 | 11.0.2 8u201 7u211 | Changes to past timestamps: China’s 1988 spring-forward transition was on April 17, not April 10. Its DST transitions in 1986/91 were at 02:00, not 00:00. Fix several issues for Macau before 1992. |
8u202版本后,1988年夏令时开始于1988-04-17
此前版本开始于1988-04-10
,而1988-04-15
正好落在此区间,因此本地与线上读取到的时间不同
再深究下不同JDK返回不同时间的原因:
找到mysql-connect-driver中解析DATE类型的代码:
1
2
3
4
5
6
7
8
9
10
11
12 >public <T> T decodeDate(byte[] bytes, int offset, int length, ValueFactory<T> vf) {
if (length == 0) {
return vf.createFromDate(0, 0, 0);
} else if (length != MysqlaConstants.BIN_LEN_DATE) {
throw new DataReadException(Messages.getString("ResultSet.InvalidLengthForType", new Object[] { length, "DATE" }));
}
int year = (bytes[offset] & 0xff) | ((bytes[offset + 1] & 0xff) << 8);
int month = bytes[offset + 2];
int day = bytes[offset + 3];
return vf.createFromDate(year, month, day);
}可以发现DATE类型 不包含时区信息,因此构建出来的是日期是本地时间+默认时区,因此在8u202版本后返回为1988-04-15 00:00:00(GMT+8) ,在此版本前返回为1988-04-15 00:00:00(CDT) 这两个时间相差1小时
关于JDK中存放时区信息的位置:
位于TzdbZoneRulesProvider类型,在我本地的位置为:/Library/Java/JavaVirtualMachines/jdk1.8.0_241.jdk/Contents/Home/jre/lib/tzdb.dat
1
2
3
4
5
6
7
8
9
10
11
12 >public TzdbZoneRulesProvider() {
try {
String libDir = System.getProperty("java.home") + File.separator + "lib";
try (DataInputStream dis = new DataInputStream(
new BufferedInputStream(new FileInputStream(
new File(libDir, "tzdb.dat"))))) {
load(dis);
}
} catch (Exception ex) {
throw new ZoneRulesException("Unable to load TZDB time-zone rules", ex);
}
>}在本地可以替换该文件为旧版本从而可以在本地复现该问题
第四步 定位问题代码
一路搜索代码,找到合同中设置生日的地方:
1 | // 出生日期 |
需要注意的是DateTime
属于第三方库joda time,基于上一段“小插曲”,我们大胆猜测下joda time是使用自己的时区信息还是读取jdk的时区信息?
铛铛铛揭晓下答案:
joda time使用自己的时区信息,不难推测,如果joda time的时区信息较新,在8u202版本前的jdk运行就会发生诡异的问题:
至此,我们已经完全查明少一天的前因后果
- 由于jdk版本较旧,时区信息较老, DATE类型数据
1988-04-15
被解析为1988-04-15 0:00:00(CDT)
- 由于joda time时区信息较新,1988-04-15 0:00:00(CDT) 被解析为
1988-04-14 23:00:00(GMT+8)
总结和收获
- Mysql DATE 类型不包含时区信息,使用配置的时区解析为
java.sql.Date
- 不同JDK版本的时区信息和夏令时信息可能会发生变化,信息存储在$JAVA_HOME/lib/tzdb.dat文件中
- 8u201 以前版本认为中国时区在1988年时于04-10开始夏令时,8u201 后认为于04-17开始夏令时, 1988-04-10~1988-04-17期间的日期会因jdk不同而返回不同数据
- joda time的时区信息使用自己独立的数据, 在和jdk Date类型转换时有转换错误的隐患
- 升级jdk8版本到8u201以上,同时使用新版本joda time (其实就是为了保证两者时区最新且一致) 能避免上述隐患