JDBC-Mysql 时区问题详解

 

目录

一、前置准备

1.1 版本号列表

1.2 Sql脚本

1.3 application.yaml配置

1.4 数据库时区设置

二、java Date类型与(jdbcType)TIMESTAMP类型的转换

2.1 jdbc对serverTimeZone的处理

2.2 java Date转(jdbcType)TIMESTAMP

2.3 (jdbcType)TIMESTAMP转java Date 

三、结论 

四、扩展

4.1 将数据库时区设置为+8

4.2 数据库时间展示

4.3 java代码获取到的时间展示 


        之前对Mysql的两种时间类型有过简单的了解,知道「mysql中有两个时间类型,timestamp与datetime,其中timestamp在存储上是包含时区的,而datetime是不包含时区的字符串形式。而通常应用下所说的时区问题,也指的是Java应用使用了jdbc驱动时,存储和读取的时区不一致的问题」。

        但是mybatis是如何将java的java.util.Date类型转成mysql的datetime, timestamp类型存储到db,同时是如何将db中datetime, timestamp类型的数据转成java的Date类型的,本人并没有进行深入的研究,导致在出现时区问题时不能找到根源,故针对源码深入研究了一下,做此记录,供大家借鉴。

        在探讨时区问题之前,默认大家对GMT时间,UTC时间,时区等概念已经了解。需要注意的是,java的时间在存入db之前,是通过jdbc驱动转成jdbcTyep的TIMESTAMP类型的,同理查询时,也是由jdbcTyep的TIMESTAMP类型转成java时间类型。这个稍后我们会讲到,同样可以在mybatis的mapper.xml文件中得到印证:

<result property="gmtCreate" column="gmt_create" jdbcType="TIMESTAMP"/>
<result property="gmtModified" column="gmt_modified" jdbcType="TIMESTAMP"/>

        这里指定了jdbcType为TIMESTAMP,也就是说,不管mysql中是datetime还是timestamp类型,对应到jdbcTypef都是TIMESTAMP类型,所以研究java时间和 (jdbcType)TIMESTAMP的转换,是我们理解时区问题的关键。

一、前置准备

1.1 版本号列表

本文研究所得结论均基于此处列出的软件版本号,其他版本号结论或有出入,但原理可供参考。

springboot版本号

3.3.5
mybatis-plus版本号 3.5.7
mysql版本号 9.0.1
mysql驱动版本号 com.mysql.cj.jdbc.Driver: 8.2.0
jdk版本号 openjdk version "17.0.13"

1.2 Sql脚本

DROP TABLE IF EXISTS `pds_user`;
CREATE TABLE IF NOT EXISTS `pds_user`
(
    `id`           bigint PRIMARY KEY auto_increment,
    `name`         varchar(10) NOT NULL COMMENT '姓名',
    `email`        varchar(30) NOT NULL COMMENT '邮箱',
    `gmt_create`   datetime    NOT NULL DEFAULT (now()),
    `gmt_modified` timestamp   NOT NULL DEFAULT (now())
);

1.3 application.yaml配置

serverTimezone=GMT%2B9,即设置连接时区为+9时区。

server:
  port: 8080

spring:
  application:
    name: TestProgram
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/pds?serverTimezone=GMT%2B9
    username: root
    password: 123456

mybatis-plus:
  mapper-locations: classpath*:mapper/*.xml
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

1.4 数据库时区设置

SET GLOBAL time_zone = '+7:00';

SELECT @@global.time_zone;

二、java Date类型与(jdbcType)TIMESTAMP类型的转换

        先说结论:Java的Date类型实际上是经过jdbc转换为字符串的形式写入数据库,查询时同理以字符串的形式转换为Java Date类型。时区转换时只和jdbcUrl指定的serverTimeZone有关,和数据库配置的时区无关。

        下面我们进行验证:

2.1 jdbc对serverTimeZone的处理

        这个处理主要在com.mysql.cj.protocol.a.NativeProtocol#configureTimeZone的方法中进行的。jdbc驱动会从jdbcUrl中取时区设置到serverSession中,如果没有则取默认时区。我们断点来看。

  1. 首先去拿serverTimezone指定的时区,这里获取到的是GMT+9时区;
  2. 将serverSession的timeZone设置为+9时区;
  3. 需要注意的是,如果没有从连接中获取到时区,就会用TimeZone.getDefault()获取到应用运行的本地默认时区(我们这里是GMT+8)。

2.2 java Date转(jdbcType)TIMESTAMP

jdbc将java的Date根据指定的时区转成时间字符串形式,写入数据库,这一过程和数据库时区配置无关。

测试代码:

        因为是jdbc规范定义的接口,所以我们直接找到java.sql.PreparedStatement接口(稍早一些的同学应该都手写过jdbc的crud吧。。。),发现定义了一个setTimestamp()方法;我们断点到这里,发现会走到com.mysql.cj.jdbc.ClientPreparedStatement实现类的,ClientPreparedStatement#setTimestamp的1728行,会调到com.mysql.cj.NativeQueryBindings#setTimestamp方法,该方法第469行又调到com.mysql.cj.NativeQueryBindValue#setBinding。

 setBinding方法中第153行会初始化valueEncoder。我们发现这个valueEncoder是SqlTimestampValueEncoder类型,

 转到SqlTimestampValueEncoder发现会调用com.mysql.cj.protocol.a.SqlTimestampValueEncoder#getString方法,该方法会指定时间格式化字符串和时区(+9),将date转成string返回。

 

         可以看到,转化成的字符串是17点09,而此时,我本地的时间是+8时区的16点09。(截图延迟...),所以最终setTimestamp()方法会将java的date转成指定时区(此时是+9)的字符串返回

        断点继续往下走,会发现最终走到com.mysql.cj.NativeQueryBindValue#writeAsText方法,

         该方法的参数intoMessage底层是个byteBuffer,而byte根据ascii值转换后会发现,其实是拼好的sql语句。

         这里方法还没有执行完,所以sql语句没有拼完,如果执行完全后,就行拼成最终的sql语句。然后由mysql来执行语句。

        注意:控制台的打印还是服务器本地时间(GMT+8)时间,并不是拼好的sql语句中的GMT+9时间,这个算是个不好的体验吧。。。

        最终落库(时间为拼好的sql中的时间):

 

2.3 (jdbcType)TIMESTAMP转java Date 

jdbc将获取到的数据库时间字符串,根据时区转换成java的Date类型,这一过程和数据库的时区配置无关。

测试代码:

        同理,有setTimestamp方法就有getTimestamp方法,研究jdbc规范的java.sql.ResultSet接口,发现有getTimestamp方法,断点到这里,发现会进到com.mysql.cj.jdbc.result.ResultSetImpl#getTimestamp(int)方法中,

这个方法的943行会调到com.mysql.cj.protocol.a.result.ByteArrayRow#getValue方法,

继续断点下去,会进到com.mysql.cj.protocol.a.MysqlTextValueDecoder#decodeTimestamp方法。从这个类名和方法名我们大概能看出来,这个会将mysql的textValue值解码成timestamp类型。继续看这个方法的89行,会先调getTimestamp方法,创建出一个com.mysql.cj.protocol.InternalTimestamp类(时间为17点09)。

然后再调用createFromTimestamp方法,最终调到com.mysql.cj.result.SqlTimestampValueFactory#localCreateFromTimestamp方法,将InternalTimestamp时间根据+9时区转成当前时间的时间戳,再组装Timestamp对象返回(+8时间)。

 点进去看这个Timestamp,其实是继承自java的Date,new Date(long mills)同样接收一个时间戳作为参数。

 结果验证,输出的是(+8时间):

三、结论 

  1. Java的Date类型实际上是经过jdbc转换为字符串的形式写入数据库,查询时同理以字符串的形式转换为Java Date类型
  2. java代码时间转换的时区,只和jdbcUrl指定的serverTimeZone(本例是+9)有关,和mysql服务器的时区(本例是+7)无关。

四、扩展

        如果将数据库的时区再改成+8,那么数据库显示和java代码获取到的时间会发生怎么的变化?

4.1 将数据库时区设置为+8

SET GLOBAL time_zone = '+8:00';

SELECT @@global.time_zone;

4.2 数据库时间展示

        这个我们应该都知道,datetime类型的时间没有变化,timestamp类型的时间从之前+7时区的17点09变成+8时区的18点09

4.3 java代码获取到的时间展示 

        先猜一手结论,dateime类型的时间数据,和之前获取到的一样,展示16点09,timestamp类型的时间数据,先转成InternalTimestamp类(+9时区的18点09),然后转成时间毫秒值(距离1970-01-01)最后转成java.sql.Timestamp,也就是展示+8时间的17点09。

        结果展示:

 和我们预期的一模一样!这个案例再次印证了,jdbc通过字符串读写,且与数据库时区配置无关。

其他:本文其他代码全部是mybatisX-Generator生成的,过于简单,就不贴出源码了。

上一篇:KkFileView4.1.0部署文档


下一篇:【MYSQL】什么是关系型数据库与非关系型数据库?