最近公司正在升級Spring Boot版本(從1.5升級到2.1)阎抒,其間踩到一個非常隱晦的MySQL時區(qū)陷阱坯门,具體來說返敬,就是數(shù)據(jù)庫讀出的歷史數(shù)據(jù)的時間和實際時間差了14個小時椅挣,而新寫入的數(shù)據(jù)又都正常头岔。如果你之前也是使用默認的MySQL時區(qū)配置,那么大概率會碰到這個問題鼠证,深究其背后的原因又涉及到很多技術(shù)細節(jié)峡竣,故整理出來分享給大家。
首先來看一下原因量九。升級到Boot 2.1之后适掰,MySQL Connect/J版本也隨之升級到8.0颂碧,會優(yōu)先使用連接參數(shù)(serverTimezone
)中指定的時區(qū),如果沒有指定类浪,則再使用數(shù)據(jù)庫配置的時區(qū)载城,參考下面的官宣(對應的源代碼是com.mysql.cj.protocol.a.NativeProtocol#configureTimezone()
)。由于我們之前數(shù)據(jù)庫連接參數(shù)沒有指定時區(qū)费就,并且數(shù)據(jù)庫配置的是默認的CST
時區(qū)(美國中部時區(qū)诉瓦,即-6:00),所以讀取出來的時間出現(xiàn)偏差力细。
Connector/J 8.0 always performs time offset adjustments on date-time values, and the adjustments require one of the following to be true:
- The MySQL server is configured with a canonical time zone that is recognizable by Java (for example, Europe/Paris, Etc/GMT-5, UTC, etc.)
- The server's time zone is overridden by setting the Connector/J connection property
serverTimezone
(for example,serverTimezone=Europe/Paris
).
找到原因之后睬澡,解決辦法就比較直白了,
方法一:數(shù)據(jù)庫的連接參數(shù)添加serverTimezone=Asia/Shanghai
或者serverTimezone=GMT%2B8
眠蚂。Boot 1.5下不需要添加此參數(shù)煞聪,但添加了也無妨。
方法二:修改MySQL數(shù)據(jù)庫的time_zone配置逝慧,改為+8:00
(默認是SYSTEM
)昔脯。采用此方法,則不需要修改數(shù)據(jù)庫連接參數(shù)馋艺。
方法二顯然更優(yōu)栅干,一次修改,終生受益捐祠。但要注意碱鳞,對于升級到Boot 2.1之后新生成的那批數(shù)據(jù),如果包含時間類型的字段并且該字段值是應用指定的而不是數(shù)據(jù)庫生成的(例如DEFAULT CURRENT_TIMESTAMP
)踱蛀,那么需要手動修復(加上偏差的小時數(shù))窿给。
兩個解決辦法都很簡單,有同學馬上會問率拒,為什么Boot 1.5下沒有這個問題崩泡?為什么Boot 2.0下讀取歷史數(shù)據(jù)存在14個小時的偏差,而新生成的數(shù)據(jù)又是好的猬膨?要回答這兩個問題角撞,看官宣就不夠了,需要讀一下MySQL Connect/J的源代碼勃痴。
謎題一谒所,為什么Boot 1.5下沒有這個問題?答案隱藏在com.mysql.jdbc.ResultSetImpl
和com.mysql.jdbc.ConnectionImpl
兩個類的源代碼中沛申。
// 源代碼:com.mysql.jdbc.ResultSetImpl
private TimeZone getDefaultTimeZone() {
// useLegacyDatetimeCode默認為true劣领,因此使用connection的默認時區(qū)
return this.useLegacyDatetimeCode ? this.connection.getDefaultTimeZone() : this.serverTimeZoneTz;
}
// 源代碼:com.mysql.jdbc.ConnectionImpl
public ConnectionImpl(String hostToConnectTo, int portToConnectTo, Properties info, String databaseToConnectTo, String url) throws SQLException {
// connection的默認時區(qū)使用的是JVM的默認時區(qū),一般為操作系統(tǒng)的時區(qū)
// We store this per-connection, due to static synchronization issues in Java's built-in TimeZone class...
this.defaultTimeZone = TimeUtil.getDefaultTimeZone(getCacheDefaultTimezone());
}
Boot 1.5下铁材,MySQL Connect/J默認使用操作系統(tǒng)的時區(qū)(Asia/Shanghai尖淘,即+8:00)奕锌,而忽略連接參數(shù)或者數(shù)據(jù)庫指定的時區(qū),因此不管是讀數(shù)據(jù)還是寫數(shù)據(jù)都是使用統(tǒng)一的時區(qū)村生,因此不存在時間偏差惊暴。
謎題二,為什么Boot 2.0下讀取歷史數(shù)據(jù)存在14個小時的偏差梆造,而新生成的數(shù)據(jù)又是好的缴守?升級到Boot 2.0之后,MySQL Connect/J改為使用數(shù)據(jù)庫配置的CST時區(qū)镇辉,而歷史數(shù)據(jù)是在Boot 1.5下的Asia/Shanghai時區(qū)生成的屡穗,因此讀出來存在14(-6:00和+8:00之間)個小時的偏差。對于新生成的數(shù)據(jù)忽肛,由于同處在CST時區(qū)下村砂,因此沒有偏差。
解完這兩個謎題屹逛,你可能還有些疑惑础废。那么接下來,結(jié)合數(shù)據(jù)流轉(zhuǎn)的順序罕模,我們再來分析一下數(shù)據(jù)流轉(zhuǎn)過程中時區(qū)的變化评腺。
設定Application-1為數(shù)據(jù)生產(chǎn)方,Application-2為數(shù)據(jù)消費方淑掌,TZ-IN1為Application-1所處的時區(qū)蒿讥,TZ-IN2為Application-1寫入數(shù)據(jù)庫的時區(qū),TZ-OUT1為Application-2讀出數(shù)據(jù)庫的時區(qū)抛腕,TZ-OUT2為Application-2所處的時區(qū)芋绸。如前所述,TZ-IN2和TZ-OUT1由連接參數(shù)或者數(shù)據(jù)庫配置決定担敌。
整個數(shù)據(jù)流轉(zhuǎn)過程摔敛,會涉及3次顯式的時區(qū)轉(zhuǎn)換和1次隱式的時區(qū)轉(zhuǎn)換。
- 轉(zhuǎn)換①(顯式):TZ-IN1轉(zhuǎn)TZ-IN2全封,這個轉(zhuǎn)換由MySQL Connect/J完成(參考
com.mysql.cj.ClientPreparedQueryBindings#setTimestamp()
马昙,限于篇幅,此處不再展開分析)刹悴。 - 轉(zhuǎn)換②(隱式):TZ-IN2轉(zhuǎn)無時區(qū)给猾,MySQL內(nèi)部存儲時間類型的字段時或者忽略時區(qū)(DateTime類型)或者使用UTC(Timestamp類型),參考MySQL官宣的時間類型部分颂跨。
- 轉(zhuǎn)換③(顯式):無時區(qū)轉(zhuǎn)TZ-OUT1,將MySQL讀出的無時區(qū)時間置為TZ-OUT1時區(qū)(參考
com.mysql.cj.result.SqlTimestampValueFactory#localCreateFromTimestamp()
)扯饶。 - 轉(zhuǎn)換④(顯式):TZ-OUT1轉(zhuǎn)TZ-OUT2恒削,這個轉(zhuǎn)換由Application-2負責池颈,一般在DAO層完成。
仔細分析這4次時區(qū)轉(zhuǎn)換钓丰,其中①躯砰、②、③都是由MySQL完成携丁,正確性不用懷疑琢歇,但由于TZ-IN2和TZ-OUT1都是由應用指定,如果兩者值不相同梦鉴,那么最后結(jié)果就會出現(xiàn)偏差(我們踩到的就是這個坑)李茫。至于④,那么就得靠應用來保證正確性了肥橙,一般也不會出錯魄宏。說句題外話,不管是時區(qū)轉(zhuǎn)換存筏,還是其他類型的數(shù)據(jù)轉(zhuǎn)換(比如字符集轉(zhuǎn)換)宠互,我們可以發(fā)現(xiàn),正確轉(zhuǎn)換的關鍵在于數(shù)據(jù)接收方必須使用和數(shù)據(jù)發(fā)送方相同的格式椭坚。這看上去像是一句廢話予跌,卻是解決此類問題的底層心法。
至此善茎,這個MySQL Connect/J 8.0的時區(qū)陷阱就算被填平了券册,希望你從中有所收獲。