MySQL Connect/J 8.0時區陷阱

在這裏插入圖片描述

最近公司正在升級Spring Boot版本(從1.5升級到2.1),其間踩到一個非常隱晦的MySQL時區陷阱,具體來說,就是數據庫讀出的歷史數據的時間和實際時間差了14個小時,而新寫入的數據又都正常。如果你之前也是使用默認的MySQL時區配置,那麼大概率會碰到這個問題,深究其背後的原因又涉及到很多技術細節,故整理出來分享給大家。

首先來看一下原因。升級到Boot 2.1之後,MySQL Connect/J版本也隨之升級到8.0,會優先使用連接參數(serverTimezone)中指定的時區,如果沒有指定,則再使用數據庫配置的時區,參考下面的官宣(對應的源代碼是com.mysql.cj.protocol.a.NativeProtocol#configureTimezone())。由於我們之前數據庫連接參數沒有指定時區,並且數據庫配置的是默認的CST時區(美國中部時區,即-6:00),所以讀取出來的時間出現偏差。

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

找到原因之後,解決辦法就比較直白了,

方法一:數據庫的連接參數添加serverTimezone=Asia/Shanghai或者serverTimezone=GMT%2B8。Boot 1.5下不需要添加此參數,但添加了也無妨。

方法二:修改MySQL數據庫的time_zone配置,改爲+8:00(默認是SYSTEM)。採用此方法,則不需要修改數據庫連接參數。

方法二顯然更優,一次修改,終生受益。但要注意,對於升級到Boot 2.1之後新生成的那批數據,如果包含時間類型的字段並且該字段值是應用指定的而不是數據庫生成的(例如DEFAULT CURRENT_TIMESTAMP),那麼需要手動修復(加上偏差的小時數)。

兩個解決辦法都很簡單,有同學馬上會問,爲什麼Boot 1.5下沒有這個問題?爲什麼Boot 2.0下讀取歷史數據存在14個小時的偏差,而新生成的數據又是好的?要回答這兩個問題,看官宣就不夠了,需要讀一下MySQL Connect/J的源代碼。

謎題一,爲什麼Boot 1.5下沒有這個問題?答案隱藏在com.mysql.jdbc.ResultSetImplcom.mysql.jdbc.ConnectionImpl兩個類的源代碼中。

// 源代碼:com.mysql.jdbc.ResultSetImpl
private TimeZone getDefaultTimeZone() {
        // useLegacyDatetimeCode默認爲true,因此使用connection的默認時區
        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的默認時區使用的是JVM的默認時區,一般爲操作系統的時區
        // 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默認使用操作系統的時區(Asia/Shanghai,即+8:00),而忽略連接參數或者數據庫指定的時區,因此不管是讀數據還是寫數據都是使用統一的時區,因此不存在時間偏差。

謎題二,爲什麼Boot 2.0下讀取歷史數據存在14個小時的偏差,而新生成的數據又是好的?升級到Boot 2.0之後,MySQL Connect/J改爲使用數據庫配置的CST時區,而歷史數據是在Boot 1.5下的Asia/Shanghai時區生成的,因此讀出來存在14(-6:00和+8:00之間)個小時的偏差。對於新生成的數據,由於同處在CST時區下,因此沒有偏差。

解完這兩個謎題,你可能還有些疑惑。那麼接下來,結合數據流轉的順序,我們再來分析一下數據流轉過程中時區的變化。

image

設定Application-1爲數據生產方,Application-2爲數據消費方,TZ-IN1爲Application-1所處的時區,TZ-IN2爲Application-1寫入數據庫的時區,TZ-OUT1爲Application-2讀出數據庫的時區,TZ-OUT2爲Application-2所處的時區。如前所述,TZ-IN2和TZ-OUT1由連接參數或者數據庫配置決定。

整個數據流轉過程,會涉及3次顯式的時區轉換和1次隱式的時區轉換。

  • 轉換①(顯式):TZ-IN1轉TZ-IN2,這個轉換由MySQL Connect/J完成(參考com.mysql.cj.ClientPreparedQueryBindings#setTimestamp(),限於篇幅,此處不再展開分析)。
  • 轉換②(隱式):TZ-IN2轉無時區,MySQL內部存儲時間類型的字段時或者忽略時區(DateTime類型)或者使用UTC(Timestamp類型),參考MySQL官宣的時間類型部分。
  • 轉換③(顯式):無時區轉TZ-OUT1,將MySQL讀出的無時區時間置爲TZ-OUT1時區(參考com.mysql.cj.result.SqlTimestampValueFactory#localCreateFromTimestamp())。
  • 轉換④(顯式):TZ-OUT1轉TZ-OUT2,這個轉換由Application-2負責,一般在DAO層完成。

仔細分析這4次時區轉換,其中①、②、③都是由MySQL完成,正確性不用懷疑,但由於TZ-IN2和TZ-OUT1都是由應用指定,如果兩者值不相同,那麼最後結果就會出現偏差(我們踩到的就是這個坑)。至於④,那麼就得靠應用來保證正確性了,一般也不會出錯。說句題外話,不管是時區轉換,還是其他類型的數據轉換(比如字符集轉換),我們可以發現,正確轉換的關鍵在於數據接收方必須使用和數據發送方相同的格式。這看上去像是一句廢話,卻是解決此類問題的底層心法。

至此,這個MySQL Connect/J 8.0的時區陷阱就算被填平了,希望你從中有所收穫。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章