最近在一个新项目里尝试使用<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,但必须事先调整数据库服务器时区,做好测试,避免一开始数据库时区有问题,造成脏数据或数据不一致现象。