問題起源於同事在項目中新增一個統計用戶生日明細的接口,其中一個用戶在數據庫中的生日日期是「1988-07-29」,然而經過rest接口獲得該用戶的生日日期卻爲 「1988-07-28」。php
開始bug排查以前,先說明下項目環境:java
SQL> SELECT SYSTIMESTAMP, SESSIONTIMEZONE FROM DUAL; SYSTIMESTAMP SESSIONTIMEZONE -------------------------------------------------------------------------------- --------------------------------------------------------------------------- 17-JUL-19 02.20.06.687149 PM +08:00 +08:00 SQL>
數據庫時間和時區都沒有問題。spring
[test@test ~]$ date -R Wed, 17 Jul 2019 16:48:32 +0800 [test@test ~]$ cat /etc/timezone Asia/Shanghai
[test@test ~]$ jinfo 7490 |grep user.timezone user.timezone = Asia/Shanghai
能夠看出咱們操做系統使用的時區和java進程使用的時區一致,都是東八區。sql
查看了問題字段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
由上圖debug看到 Timestamp 是JDK中的類,也就是說這裏看到的是JDK使用的時間和時區,從圖中標註2處能夠看出JDK使用的時區也是東八區,可是從1和3處看起來彷佛有點不同,首先1處變化爲UTC/GMT+0900,3處有一個daylightSaving的這樣一個時間,換算爲小時恰好爲1個小時。這個值經過google搜索知道叫作夏令時。centos
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)bash
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作了一下配置:
#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"。
定位到問題解決就很簡單了,只須要修改下設置:
#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表示的時間戳存到BIGINT
或REAL
類型的列中,徹底不用管數據庫本身提供的DATETIME
或TIMESTAMP
,也不用擔憂應用服務器和數據庫服務器的時區設置問題,遇到Oracle數據庫你沒必要去理會with timezone
和with local timezone
到底有啥區別。讀取時間時,讀到的是一個Long或Float,只須要按照用戶的時區格式化爲字符串就能正確地顯示出來。
基於絕對時間戳的時間存儲,從根本上就沒有時區的問題。時區只是一個顯示問題。額外得到的好處還包括:
between(?, ?)
;你惟一須要編寫的兩個輔助函數就是String->Long
和Long->String
。String->Long
的做用是把用戶輸入的時間字符串按照用戶指定時區轉換成Long存進數據庫。
惟一的缺點是數據庫查詢你看到的不是時間字符串,而是相似1413266801750
之類的數字。
這能夠從系統底層操做系統、數據庫、開發語言的時區保持一致開始。如使用java語言開發的系統,安裝好操做系統以後能夠先把系統時區和時間先調整好,而後再安裝數據庫和JDK,通常JDK安裝時默認會使用操做系統設置的時區,這樣就省去了從新再單獨設置JDK時區的步驟了。在代碼中若是須要使用什麼框架或者包處理時間,在設置的時候須要先確認下系統時區和所在時區是否有夏令時的存在,貌似如今老美也還在使用夏令時。