排查诡异的夏令时问题

排查诡异的夏令时问题

现象描述与复现

线上环境签署合同时发现某一员工生日为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
2
3
4
5
6
/**
* 出生日期
*/
@NotNull(message = "出生日期不能为空", groups = EntryInfoV1.Group_EntryInfo.class)
@JsonFormat(pattern="yyyy-MM-dd HH:mm:ss",timezone = "GMT+8")
private Date birthday;

可以看到json序列化指定为GMT+8时区,如果是夏令时时间会转为为不执行夏令时的时间(-1hour), 返回值如下:

1
2
3
4
5
{
entryBasicInfo: {
birthday: "1988-04-15 07:00:00"
}
}

结合页面的返回值和阅读页面展示生日的代码,可以推断从数据库读取到的时间为夏令时时间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数据

第三步 调试验证判断

如何验证我们前面推断出来的结论呢?肯定是依靠调试啦!造好数据,打上断点:

2664c4efc0c6ede126b16614c5d05446.png

如我所料, 读取到的时间正是 1988-04-15 00:00:00(CDT) 下面继续追查,看在什么地方格式化为1988-04-14

第四步 本地与线上JDK版本不同引起的一段插曲

实际上在本人本地执行的结果如下,相同的输入下,本地的时间戳和远程调试的并不一样,很是困惑

a00ebef19599799f392dd238ef75b045.png

对比发现zoneOffsets信息不同

iShot2021-07-15 19.19.57.png

经过光闪闪提醒,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
2
//  出生日期
template.setPbBirthDate(new DateTime(eu.getBirthday()).toString("yyyy-MM-dd"));

需要注意的是DateTime属于第三方库joda time,基于上一段“小插曲”,我们大胆猜测下joda time是使用自己的时区信息还是读取jdk的时区信息?

铛铛铛揭晓下答案:

iShot2021-07-15 20.27.23.png

joda time使用自己的时区信息,不难推测,如果joda time的时区信息较新,在8u202版本前的jdk运行就会发生诡异的问题:

iShot2021-07-15 20.34.58.png

至此,我们已经完全查明少一天的前因后果

  1. 由于jdk版本较旧,时区信息较老, DATE类型数据1988-04-15被解析为 1988-04-15 0:00:00(CDT)
  2. 由于joda time时区信息较新,1988-04-15 0:00:00(CDT) 被解析为1988-04-14 23:00:00(GMT+8)

总结和收获

  1. Mysql DATE 类型不包含时区信息,使用配置的时区解析为java.sql.Date
  2. 不同JDK版本的时区信息和夏令时信息可能会发生变化,信息存储在$JAVA_HOME/lib/tzdb.dat文件中
  3. 8u201 以前版本认为中国时区在1988年时于04-10开始夏令时,8u201 后认为于04-17开始夏令时, 1988-04-10~1988-04-17期间的日期会因jdk不同而返回不同数据
  4. joda time的时区信息使用自己独立的数据, 在和jdk Date类型转换时有转换错误的隐患
  5. 升级jdk8版本到8u201以上,同时使用新版本joda time (其实就是为了保证两者时区最新且一致) 能避免上述隐患

评论

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×