目录
一、前置准备
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中,如果没有则取默认时区。我们断点来看。
- 首先去拿serverTimezone指定的时区,这里获取到的是GMT+9时区;
- 将serverSession的timeZone设置为+9时区;
- 需要注意的是,如果没有从连接中获取到时区,就会用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时间):
三、结论
- Java的Date类型实际上是经过jdbc转换为字符串的形式写入数据库,查询时同理以字符串的形式转换为Java Date类型。
- 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生成的,过于简单,就不贴出源码了。