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. SQL>

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

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

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

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

  3. [test@test ~]$ cat /etc/timezone

  4. Asia/Shanghai

  • 查看java进程时区
 
  1. [test@test ~]$ jinfo 7490 |grep user.timezone

  2. user.timezone = Asia/Shanghai

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

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

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

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

进一步查看 DateTypeHandler.java 类:

 
  1. //

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

  3. // (powered by Fernflower decompiler)

  4. //

  5. package org.apache.ibatis.type;

  6. import java.sql.CallableStatement;

  7. import java.sql.PreparedStatement;

  8. import java.sql.ResultSet;

  9. import java.sql.SQLException;

  10. import java.sql.Timestamp;

  11. import java.util.Date;

  12. public class DateTypeHandler extends BaseTypeHandler<Date> {

  13. public DateTypeHandler() {

  14. }

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

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

  17. }

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

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

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

  21. }

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

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

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

  25. }

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

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

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

  29. }

  30. }

因为使用的数据源为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做了一下配置:

 
  1. #jackson

  2. #日期格式化

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

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

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

 
  1. //

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

  3. // (powered by Fernflower decompiler)

  4. //

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

  6. import com.fasterxml.jackson.annotation.JsonInclude.Include;

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

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

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

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

  11. import java.util.EnumMap;

  12. import java.util.Locale;

  13. import java.util.Map;

  14. import java.util.TimeZone;

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

  16. @ConfigurationProperties(

  17. prefix = "spring.jackson"

  18. )

  19. public class JacksonProperties {

  20. private String dateFormat;

  21. private String jodaDateTimeFormat;

  22. private String propertyNamingStrategy;

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

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

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

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

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

  28. private Include defaultPropertyInclusion;

  29. private TimeZone timeZone = null;

  30. private Locale locale;

  31. public JacksonProperties() {

  32. }

  33. public String getDateFormat() {

  34. return this.dateFormat;

  35. }

  36. public void setDateFormat(String dateFormat) {

  37. this.dateFormat = dateFormat;

  38. }

  39. public String getJodaDateTimeFormat() {

  40. return this.jodaDateTimeFormat;

  41. }

  42. public void setJodaDateTimeFormat(String jodaDataTimeFormat) {

  43. this.jodaDateTimeFormat = jodaDataTimeFormat;

  44. }

  45. public String getPropertyNamingStrategy() {

  46. return this.propertyNamingStrategy;

  47. }

  48. public void setPropertyNamingStrategy(String propertyNamingStrategy) {

  49. this.propertyNamingStrategy = propertyNamingStrategy;

  50. }

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

  52. return this.serialization;

  53. }

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

  55. return this.deserialization;

  56. }

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

  58. return this.mapper;

  59. }

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

  61. return this.parser;

  62. }

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

  64. return this.generator;

  65. }

  66. public Include getDefaultPropertyInclusion() {

  67. return this.defaultPropertyInclusion;

  68. }

  69. public void setDefaultPropertyInclusion(Include defaultPropertyInclusion) {

  70. this.defaultPropertyInclusion = defaultPropertyInclusion;

  71. }

  72. public TimeZone getTimeZone() {

  73. return this.timeZone;

  74. }

  75. public void setTimeZone(TimeZone timeZone) {

  76. this.timeZone = timeZone;

  77. }

  78. public Locale getLocale() {

  79. return this.locale;

  80. }

  81. public void setLocale(Locale locale) {

  82. this.locale = locale;

  83. }

  84. }

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

 
  1. package com.test;

  2. import java.sql.Date;

  3. import java.util.TimeZone;

  4. /**

  5. * @author alexpdh

  6. * @date 2019/07/17

  7. */

  8. public class Test {

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

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

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

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

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

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

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

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

  17. TimeZone.setDefault(zone);

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

  19. Date date3 = date1;

  20. Date date4 = date2;

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

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

  23. }

  24. }

运行后输出结果:

 
  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

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

总结

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

转载, 夏令时导致的时间问题相关推荐

  1. Java夏令时导致的时间问题

    1986年至1991年,中华人民共和国在全国范围实行了六年夏令时,每年从4月中旬的第一个星期日2时整(北京时间)到9月中旬第一个星期日的凌晨2时 整(北京夏令时) 我们可以看出在中国的夏令时区间内,因 ...

  2. JDBC与mysql同为CST时区导致数据库时间和客户端时间差13或者14小时

    摘要 线上排查问题时候碰到一个奇怪的问题,代码中读取一天的记录.代码中设置时间是从零点到夜里二十四点.但是读取出来的记录的开始是既然是从13点开始的.然后看了JDBC的源码发现主要原因是Mysql的C ...

  3. HBase实战:记一次Safepoint导致长时间STW的踩坑之旅

    本文记录了HBase中Safepoint导致长时间STW此问题的解决思路及办法. 上篇文章回顾:HBase Replication详解 ​过 程 记 录 现象:小米有一个比较大的公共离线HBase集群 ...

  4. Java夏令时导致的问题

    Java夏令时导致的问题 一.查询报错提示 报错提示: HOUR_OF_DAY: 0 -> 1 二.问题原因 通过网上查找资料得知,是中国有一段时间实施过夏令时导致的 1986-1991年 , ...

  5. 百度贴吧——因百度账号策略调整导致长时间未登录的账号(最后登录在2017年6月1日以前)网页端无法登陆、移动端异常解决方案

    问题描述 尝试登录一个最后登录在2017年6月1日以前的百度账号时, 网页端在完成登录程序以后,仍然没有登录状态. 移动端APP在完成登录程序后,可以进行一般操作(查看.发布等),但是不能进行账号安全 ...

  6. mysql cst_JDBC与mysql同为CST时区导致数据库时间和客户端时间差13或者14小时

    摘要 线上排查问题时候碰到一个奇怪的问题,代码中读取一天的记录.代码中设置时间是从零点到夜里二十四点.但是读取出来的记录的开始是既然是从13点开始的.然后看了JDBC的源码发现主要原因是Mysql的C ...

  7. GMT、UTC、时区、夏令时、北京时间、本地时间

    关于时区.时间很多开发人员都弄不懂下面我们就来所下这方面的概念,格林威治时间.GMT.UTC.跨时区.夏令时我们彻底来梳理一下它们. GMT GMT(Greenwich Mean Time)格林威治时 ...

  8. 夏令时引起的时间问题

    一.问题描述 日期字符串1990-6-16转java的Date对象,落库(mysql)后,DB日期显示减少一天(1990-6-15) 二.原因 根本原因:java使用的时区与数据库使用的时区不匹配 1 ...

  9. 【转载】Python日期时间模块datetime详解与Python 日期时间的比较,计算实例代码

    本文转载自脚本之家,源网址为:https://www.jb51.net/article/147429.htm 一.Python中日期时间模块datetime介绍 (一).datetime模块中包含如下 ...

最新文章

  1. 【IntelliJ IDEA】创建 导入 Java 项目
  2. 数据结构和算法分析:B树 B+树 和B*树的总结
  3. Ubuntu 安装和修改Apache2端口
  4. Android之XML序列化和解析
  5. 雷军发“玄妙”知识微博:暗示小米MIX4 将采用42W快充?
  6. python创建二维数组的方法_Python创建二维数组的正确姿势
  7. 使用dx命令在cmd环境下执行的正确方法,我用的版本android4.4.2,jdk1.8
  8. Android设置Gmail邮箱
  9. 漫游配置文件修改为强制配置文件|ntuser.dat ntuser.man
  10. php 语句以句号结尾,短句末尾是否用句号
  11. 专题八图形窗口与坐标轴
  12. java获取一段话的首字母或拼音
  13. CSU 1725 加尔鲁什·地狱咆哮对阵虚灵大盗拉法姆
  14. 使用spilt截取文件名后缀时出现的问题
  15. 【光照感知子场:差分感知融合模块与中间融合策略相结合】
  16. Unity位运算符和Layers
  17. int函数python_int()函数
  18. JS中typeof() !== 'undefined'的解释
  19. ecs云服务器上传文件,ECS文件传输
  20. springboot 在线程中注入bean,解决注入bean为null的问题

热门文章

  1. .net5 Nginx 反向代理部署
  2. 免签支付是什么意思,个人和企业该如何使用免签支付?
  3. 「技术播客月」 Day 9 :未来我们还需要浏览器吗?
  4. 通孔的作用是什么linux,什么是通孔回流焊?有什么优点?
  5. 探究如何将自己的个人简历发布到网页上
  6. Photoshop CS5软件
  7. python图片转手绘_通过Python将故宫的建筑物图片,转化为手绘图
  8. matlab 输入普朗克常量,利用matlab和excel进行光电效应测普朗克常量实验中的数据处理...
  9. 服务器共享文件夹指定ip访问,教大家设置禁止特定IP访问共享文件
  10. 冒泡排序 以及利用函数升序 降序