一次因JDK夏令時致使接口輸出日期格式的時間與預期時間不一致的bug排查總結

bug描述

問題起源於同事在項目中新增一個統計用戶生日明細的接口,其中一個用戶在數據庫中的生日日期是「1988-07-29」,然而經過rest接口獲得該用戶的生日日期卻爲 「1988-07-28」。php

環境說明

開始bug排查以前,先說明下項目環境:java

  • 系統: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>

數據庫時間和時區都沒有問題。spring

確認操做系統和java進程時區

  • 查看操做系統時區
[test@test ~]$ date -R
Wed, 17 Jul 2019 16:48:32 +0800
[test@test ~]$ cat /etc/timezone
Asia/Shanghai
  • 查看java進程時區
[test@test ~]$ jinfo 7490 |grep user.timezone
user.timezone = Asia/Shanghai

能夠看出咱們操做系統使用的時區和java進程使用的時區一致,都是東八區。sql

用debug繼續往上層查找查看mybatis和JDBC層

查看了問題字段mapper映射字段的jdbcType類型爲jdbcType="TIMESTAMP",在mybatis中類型處理註冊類TypeHandlerRegistry.java 中對應的處理類爲 DateTypeHandler.java。數據庫

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

進一步查看 DateTypeHandler.java 類:apache

//
// 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類型的值。json

debugDate

由上圖debug看到 Timestamp 是JDK中的類,也就是說這裏看到的是JDK使用的時間和時區,從圖中標註2處能夠看出JDK使用的時區也是東八區,可是從1和3處看起來彷佛有點不同,首先1處變化爲UTC/GMT+0900,3處有一個daylightSaving的這樣一個時間,換算爲小時恰好爲1個小時。這個值經過google搜索知道叫作夏令時。centos

經常使用時間概念 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作了一下配置:

#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表示的時間戳存到BIGINTREAL類型的列中,徹底不用管數據庫本身提供的DATETIMETIMESTAMP,也不用擔憂應用服務器和數據庫服務器的時區設置問題,遇到Oracle數據庫你沒必要去理會with timezonewith local timezone到底有啥區別。讀取時間時,讀到的是一個Long或Float,只須要按照用戶的時區格式化爲字符串就能正確地顯示出來。

基於絕對時間戳的時間存儲,從根本上就沒有時區的問題。時區只是一個顯示問題。額外得到的好處還包括:

  • 兩個時間的比較就是數值的比較,根本不涉及時區問題,極其簡單;
  • 時間的篩選也是兩個數值之間篩選,寫出SQL就是between(?, ?)
  • 顯示時間時,把Long或Float傳到頁面,不管用服務端腳本仍是用JavaScript都能簡單而正確地顯示時間。

你惟一須要編寫的兩個輔助函數就是String->LongLong->StringString->Long的做用是把用戶輸入的時間字符串按照用戶指定時區轉換成Long存進數據庫。

惟一的缺點是數據庫查詢你看到的不是時間字符串,而是相似1413266801750之類的數字。

在不一樣的系統軟件或開發語言須要對時間處理的要注意時區的設置

這能夠從系統底層操做系統、數據庫、開發語言的時區保持一致開始。如使用java語言開發的系統,安裝好操做系統以後能夠先把系統時區和時間先調整好,而後再安裝數據庫和JDK,通常JDK安裝時默認會使用操做系統設置的時區,這樣就省去了從新再單獨設置JDK時區的步驟了。在代碼中若是須要使用什麼框架或者包處理時間,在設置的時候須要先確認下系統時區和所在時區是否有夏令時的存在,貌似如今老美也還在使用夏令時。

相關文章
相關標籤/搜索