最近在一個新項目裡嘗試使用<code>mysql-connector-java-6.0.5.jar</code>,但是從maxcompute(原名odps)中導入mysql的資料在控制台中看到是正常的,從java應用裡讀取的卻是相差13或14小時的。甚至sql裡限定了資料的時間在某一天,應用查出來的資料還能是不在那天的。這就很奇怪了,本着求根問底,踩坑識坑的精神,好好地研究了一把。

5.1.x和6.x版本現在正在雙線演進。
那麼首先寫一個小的jdbc程式來驗證一下兩個版本driver行為的不一緻,mysql中的表如下:
程式如下:
使用兩個不同版本的driver執行效果如下:
上圖v5.1.36版本的driver插入日期,雖然都是同一個時間點(誤差一秒以内),但是表現在資料庫中的時間看上去相差很大,一個是東8區的當地時間以yyyy-mm-dd hh:mm:ss格式化後的時間,另一個是西6區格式化後的當地時間,也就相當于是java中的localdatetime那種不含時區的時間。
是以,一般做全球化的應用時,建議時間存儲成bigint型的,避免相同的時間點,用不同時區帶來的誤差。
上圖v6.0.5版本的driver插入日期,在同一個時間點,插入資料庫中的時間一緻,不管是哪個時區,都以資料庫伺服器所在時區進行重新格式化。
而demo程式中第三個用例插入的資料效果都是一樣的,是因為sql文本本身不含時區資訊,sql中的日期被當做資料庫伺服器的當地時間。
友情提示:此處源碼較多,如果無耐心,可以假裝已經看了源碼,直接看結論就行了。
為了證明問題确實存在,我們上mysql網站看了mysql-connector-java 5.1的文檔,文檔第16章節選如下:
<a href="https://dev.mysql.com/doc/connector-j/5.1/en/connector-j-usagenotes-known-issues-limitations.html">https://dev.mysql.com/doc/connector-j/5.1/en/connector-j-usagenotes-known-issues-limitations.html</a>
可見,v5.1.x版中的行為,在官方開發者看來是一個issue,在v6.x中進行了fix。
深入mysql driver的代碼,以<code>preparedstatement.settimestamp(int, timestamp)</code>為例我們可以發現:
<code>connection.getdefaulttimezone</code>跟進去,核心實作是:
也就是<code>settimestampinternal</code>中傳入的第四個參數<code>timezone</code>是應用伺服器的時區資訊。那麼再看<code>settimestampinternal</code>具體做了什麼事:
可以看到,在沒有設定<code>uselegacydatetimecode</code>連接配接參數的情況下,<code>binding.value = timeutil.changetimezone(this.connection, sessioncalendar, targetcalendar, x, tz, this.connection.getservertimezonetz(), rollforward);</code>傳入了應用伺服器的時區資訊,和貌似資料庫伺服器端的時區資訊(從<code>getservertimezonetz()</code>名字猜測,有興趣的話可以追朔一下<code>com.mysql.jdbc.connectionpropertiesimpl</code>的1050行)。那麼<code>timeutil.changetimezone()</code>的實作如下:
顯然,沒有設定<code>usetimezone</code>連接配接參數的話,直接傳回的就是我們一開始<code>settimestamp(int, timestamp)</code>時的那個<code>timestamp</code>的值。在<code>preparedstatement</code>設定完所有參數後,一般調用的是<code>executeupdate()</code>,細究裡面代碼,略去一堆中間的,拎出最核心的代碼:
可以看到,應用端的driver實質上是把年、月、日、時、分、秒、毫秒資訊分别寫入到伺服器端。
如果以應用伺服器端的時區來讀取年月日時分秒資訊,那就是應用伺服器的時間,去掉時區資訊,給了資料庫伺服器。
那麼如果東8區的2017/03/31 01:02:03和西4區的2017/03/31 01:02:03發送給資料庫伺服器,資料庫伺服器收到的資料是一樣的,而真實的這兩個時間應該相差12小時才對。
對于v6.0.5版本的driver的行為是怎樣呢,照着v5.1.35的經驗來探索一下:
和v5.1.35相比,把<code>this.connection.getdefaulttimezone()</code>改為了<code>this.session.getdefaulttimezone()</code>,而<code>session.getdefaulttimezone()</code>中<code>defaulttimezone</code>參數初始化時預設給了<code>timezone.getdefault()</code>,在<code>com.mysql.cj.jdbc.connectionimpl</code>建立連接配接時,會調用到<code>com.mysql.cj.mysqla.mysqlasession.configuretimezone()</code>,把session的預設時區設定為資料庫伺服器的預設時區:
再看<code>settimestampinternal()</code>的實作:
這裡和v5.1.35的差別是,binding裡除了存放value,還能存放timezone的資訊。預設情況下,傳入的是資料庫伺服器的timezone。
那麼再看<code>executeupdate()</code>相關的實作,跟進去深入,依然可以追朔到<code>com.mysql.cj.jdbc.serverpreparedstatement.storedatetime(packetpayload, date, timezone, int)</code>方法:
這裡的前幾行直接把日期對象轉化為基于資料庫伺服器時區的對象,然後再寫入年、月、日、時、分、秒、毫秒的資訊。
在這種情況下,東8區的2017/03/31 01:02:03和西4區的2017/03/31 01:02:03發送給東7區的資料庫伺服器,資料庫收到前者的時間是2017/03/31 00:02:03,後者的時間是2017/03/30 12:02:03,恰好相差12小時,與實際相符。
對于從資料庫取日期時間,和寫入日期時間類似:
在v5.1.x的driver中,取到的是資料庫存儲的年月日時分秒字面上的時間再附上應用伺服器的時區資訊;
在v6.x的driver中,取到的是資料庫存儲的年月日時分秒字面上的時間和資料庫伺服器的時區資訊,然後再轉換為應用伺服器所在時區的年月日時分秒;
既然發現了問題,那麼就根據目前的情況來分析不同情況下使用不同的方案可能帶來的結果或問題。因為多數同學系統中多多少少會用到maxcompute(odps),是以這裡也把maxcompute牽扯進來。
相同driver版本的讀寫:
v5.1.x中,完全無問題,整條鍊路時間一緻;
v6.x中,應用伺服器讀寫的時間一緻,但是從資料庫伺服器到maxcompute時時間會發生異常,需要同步到maxcompute時使用long型時間戳來解決問題;
不同driver版本的讀寫:
不同應用不同版本driver下,讀取同一個資料源,可能發生時間錯亂,需要整條鍊路各伺服器、伺服器上的程式時區保持一緻才能避免,或者讓v5.1.x帶上usetimezone參數,以便行為與6.x一緻;
第三方系統作為資料源的應用:
第三方系統過來的時間,有資料從maxcompute同步到mysql時會發生時區異常,進而導緻v6.x下應用讀取時間異常。這時候需要設定mysql的時區與第三方系統的時區保持一緻。
從上述分析來看,5.1.x的确存在一些問題,隻是我們沒有開發用于多時區的應用,或者已經習以為常認為合理罷了,甚至總結出了用bigint存儲跨時區的資訊那種經驗。
6.x徹底解決了跨時區的應用問題,讓我們開發中顧慮更少。
針對實際的開發,建議:
資料庫伺服器的時間設定:
對于純産生資料的應用,沒有資料回流到數倉的話,資料庫時區随意設定,知道機制就可以了。
對于需要資料回流到數倉,或者數倉的資料會回流到前台應用資料庫的話,那麼需要設定成和數倉的時區一緻,避免兩個庫之間同步時發生時區異常的問題;
對于應用:
老應用用5.1.x,如果沒有usetimezone的參數,那麼謹慎更新driver到6.x,如果要更新,記得做好測試,先修改資料庫的timezone,然後更新driver;
新的應用建議使用6.x的driver,但必須事先調整資料庫伺服器時區,做好測試,避免一開始資料庫時區有問題,造成髒資料或資料不一緻現象。