bug描述

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

环境说明

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

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

bug 排查

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

 
  1. SQL> SELECT SYSTIMESTAMP, SESSIONTIMEZONE FROM DUAL;

  2. SYSTIMESTAMP SESSIONTIMEZONE

  3. -------------------------------------------------------------------------------- ---------------------------------------------------------------------------

  4. 17-JUL-19 02.20.06.687149 PM +08:00 +08:00

  5.  
  6. SQL>

  7.  
  8.  

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

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

  • 查看操作系统时区
 
  1. [[email protected] ~]$ date -R

  2. Wed, 17 Jul 2019 16:48:32 +0800

  3. [[email protected] ~]$ cat /etc/timezone

  4. Asia/Shanghai

  5.  
  6.  
  • 查看java进程时区
 
  1. [[email protected] ~]$ jinfo 7490 |grep user.timezone

  2. user.timezone = Asia/Shanghai

  3.  
  4.  

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

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

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

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

  2.  
  3.  

进一步查看 DateTypeHandler.java 类:

 
  1. //

  2. // Source code recreated from a .class file by IntelliJ IDEA

  3. // (powered by Fernflower decompiler)

  4. //

  5.  
  6. package org.apache.ibatis.type;

  7.  
  8. import java.sql.CallableStatement;

  9. import java.sql.PreparedStatement;

  10. import java.sql.ResultSet;

  11. import java.sql.SQLException;

  12. import java.sql.Timestamp;

  13. import java.util.Date;

  14.  
  15. public class DateTypeHandler extends BaseTypeHandler<Date> {

  16. public DateTypeHandler() {

  17. }

  18.  
  19. public void setNonNullParameter(PreparedStatement ps, int i, Date parameter, JdbcType jdbcType) throws SQLException {

  20. ps.setTimestamp(i, new Timestamp(parameter.getTime()));

  21. }

  22.  
  23. public Date getNullableResult(ResultSet rs, String columnName) throws SQLException {

  24. Timestamp sqlTimestamp = rs.getTimestamp(columnName);

  25. return sqlTimestamp != null ? new Date(sqlTimestamp.getTime()) : null;

  26. }

  27.  
  28. public Date getNullableResult(ResultSet rs, int columnIndex) throws SQLException {

  29. Timestamp sqlTimestamp = rs.getTimestamp(columnIndex);

  30. return sqlTimestamp != null ? new Date(sqlTimestamp.getTime()) : null;

  31. }

  32.  
  33. public Date getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {

  34. Timestamp sqlTimestamp = cs.getTimestamp(columnIndex);

  35. return sqlTimestamp != null ? new Date(sqlTimestamp.getTime()) : null;

  36. }

  37. }

  38.  
  39.  
  40.  

因为使用的数据源为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

中国夏时制实施时间规定(夏令时) 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做了一下配置:

 
  1. #jackson

  2. #日期格式化

  3. spring.jackson.date-format=yyyy-MM-dd HH:mm:ss

  4. spring.jackson.time-zone=GMT+8

  5.  
  6.  

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

 
  1. //

  2. // Source code recreated from a .class file by IntelliJ IDEA

  3. // (powered by Fernflower decompiler)

  4. //

  5.  
  6. package org.springframework.boot.autoconfigure.jackson;

  7.  
  8. import com.fasterxml.jackson.annotation.JsonInclude.Include;

  9. import com.fasterxml.jackson.core.JsonParser.Feature;

  10. import com.fasterxml.jackson.databind.DeserializationFeature;

  11. import com.fasterxml.jackson.databind.MapperFeature;

  12. import com.fasterxml.jackson.databind.SerializationFeature;

  13. import java.util.EnumMap;

  14. import java.util.Locale;

  15. import java.util.Map;

  16. import java.util.TimeZone;

  17. import org.springframework.boot.context.properties.ConfigurationProperties;

  18.  
  19. @ConfigurationProperties(

  20. prefix = "spring.jackson"

  21. )

  22. public class JacksonProperties {

  23. private String dateFormat;

  24. private String jodaDateTimeFormat;

  25. private String propertyNamingStrategy;

  26. private Map<SerializationFeature, Boolean> serialization = new EnumMap(SerializationFeature.class);

  27. private Map<DeserializationFeature, Boolean> deserialization = new EnumMap(DeserializationFeature.class);

  28. private Map<MapperFeature, Boolean> mapper = new EnumMap(MapperFeature.class);

  29. private Map<Feature, Boolean> parser = new EnumMap(Feature.class);

  30. private Map<com.fasterxml.jackson.core.JsonGenerator.Feature, Boolean> generator = new EnumMap(com.fasterxml.jackson.core.JsonGenerator.Feature.class);

  31. private Include defaultPropertyInclusion;

  32. private TimeZone timeZone = null;

  33. private Locale locale;

  34.  
  35. public JacksonProperties() {

  36. }

  37.  
  38. public String getDateFormat() {

  39. return this.dateFormat;

  40. }

  41.  
  42. public void setDateFormat(String dateFormat) {

  43. this.dateFormat = dateFormat;

  44. }

  45.  
  46. public String getJodaDateTimeFormat() {

  47. return this.jodaDateTimeFormat;

  48. }

  49.  
  50. public void setJodaDateTimeFormat(String jodaDataTimeFormat) {

  51. this.jodaDateTimeFormat = jodaDataTimeFormat;

  52. }

  53.  
  54. public String getPropertyNamingStrategy() {

  55. return this.propertyNamingStrategy;

  56. }

  57.  
  58. public void setPropertyNamingStrategy(String propertyNamingStrategy) {

  59. this.propertyNamingStrategy = propertyNamingStrategy;

  60. }

  61.  
  62. public Map<SerializationFeature, Boolean> getSerialization() {

  63. return this.serialization;

  64. }

  65.  
  66. public Map<DeserializationFeature, Boolean> getDeserialization() {

  67. return this.deserialization;

  68. }

  69.  
  70. public Map<MapperFeature, Boolean> getMapper() {

  71. return this.mapper;

  72. }

  73.  
  74. public Map<Feature, Boolean> getParser() {

  75. return this.parser;

  76. }

  77.  
  78. public Map<com.fasterxml.jackson.core.JsonGenerator.Feature, Boolean> getGenerator() {

  79. return this.generator;

  80. }

  81.  
  82. public Include getDefaultPropertyInclusion() {

  83. return this.defaultPropertyInclusion;

  84. }

  85.  
  86. public void setDefaultPropertyInclusion(Include defaultPropertyInclusion) {

  87. this.defaultPropertyInclusion = defaultPropertyInclusion;

  88. }

  89.  
  90. public TimeZone getTimeZone() {

  91. return this.timeZone;

  92. }

  93.  
  94. public void setTimeZone(TimeZone timeZone) {

  95. this.timeZone = timeZone;

  96. }

  97.  
  98. public Locale getLocale() {

  99. return this.locale;

  100. }

  101.  
  102. public void setLocale(Locale locale) {

  103. this.locale = locale;

  104. }

  105. }

  106.  

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

 
  1. package com.test;

  2.  
  3. import java.sql.Date;

  4. import java.util.TimeZone;

  5.  
  6. /**

  7. * @author alexpdh

  8. * @date 2019/07/17

  9. */

  10. public class Test {

  11.  
  12. public static void main(String[] args) {

  13. System.out.println("当前的默认时区为: " + TimeZone.getDefault().getID());

  14. Date date1 = Date.valueOf("1988-07-29");

  15. Date date2 = Date.valueOf("1983-07-29");

  16. System.out.println("在中国夏令时范围内的时间 date1=" + date1);

  17. System.out.println("正常东八区时间 date2=" + date2);

  18. // 模拟 spring.jackson.time-zone=GMT+8 属性设置

  19. TimeZone zone = TimeZone.getTimeZone("GMT+8");

  20. TimeZone.setDefault(zone);

  21. System.out.println(TimeZone.getDefault().getID());

  22. Date date3 = date1;

  23. Date date4 = date2;

  24. System.out.println("转换后的在中国夏令时范围内的时间date3=" + date3);

  25. System.out.println("转换后的正常东八区时间 date4=" + date4);

  26. }

  27. }

  28.  

运行后输出结果:

 
  1. 当前的默认时区为: Asia/Shanghai

  2. 在中国夏令时范围内的时间 date1=1988-07-29

  3. 正常东八区时间 date2=1983-07-29

  4. GMT+08:00

  5. 转换后的在中国夏令时范围内的时间date3=1988-07-28

  6. 转换后的正常东八区时间 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解决

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

 
  1. #jackson

  2. #日期格式化

  3. spring.jackson.date-format=yyyy-MM-dd HH:mm:ss

  4. spring.jackson.time-zone=Asia/Shanghai

  5.  
  6.  

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

总结

通过这次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之类的数字。

相关文章: