记一次 MySQL timestamp 精度问题的排查 → 过程有点曲折

153 阅读7分钟

开心一刻

下午正准备出门,跟正刷着手机的老妈打个招呼
我:妈,今晚我跟朋友在外面吃,就不在家吃了
老妈拿着手机跟我说道:你看这叫朋友骗缅北去了,tm血都抽干了,多危险
我:那是他不行,你看要是吴京去了指定能跑回来
老妈:还吴京八经的,特么牛魔王去了都得耕地,唐三藏去了都得打出舍利,孙悟空去了都得演大马戏
我:那照你这么说,唐僧师徒取经走差地方了呗
老妈:那可没走错,他当年搁西安出发,他要是搁云南出发呀,上午到缅北,下午他就到西天
我:哈哈哈,那西游记就两级呗,那要是超人去了呢?
老妈:那超人去了,回来光剩超,人留那了

BD997914490@qq.com559029283177.gif

问题复现

我简化下业务与项目
数据库:MySQL 8.0.25
基于spring-boot 2.2.10.RELEASE搭建demo :spring-boot-jpa-demo
表:tbl_user

tbl_user.png

测试代码:
/**
 * @description: xxx描述
 * @author: 青石路
 * @date: 2024/1/9 21:42
 */
@RunWith(SpringRunner.class)
@SpringBootTest
@Slf4j
public class UserTest {

    @Resource
    private UserRepository userRepository;

    @Test
    public void get() {
        DateTimeFormatter dft = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
        Timestamp lastModifiedTime  = Timestamp.valueOf(LocalDateTime.parse("2024-01-11 09:33:26.643", dft));

        // 1.先保存一个user
        User user = new User();
        user.setUserName("zhangsan");
        user.setPassword("zhangsan");
        user.setBirthday(LocalDate.now().minusYears(25));
        user.setLastModifiedTime(lastModifiedTime);
        log.info("user.lastModifiedTime = {}", user.getLastModifiedTime());
        userRepository.save(user);
        log.info("user 保存成功,userId = {}", user.getUserId());

        // 2.然后再根据id查询这个user
        Optional<User> userOptional = userRepository.findById(user.getUserId());
        if (userOptional.isPresent()) {
            log.info("从数据库查询到的user,user.lastModifiedTime = {}", userOptional.get().getLastModifiedTime());
        }
    }
}

就分2步:1、先保存User,2、根据保存返回的userId再去数据库查User
这么清晰的代码,大家都能看懂吧?

很稳.png

我们来看下输出日志

输出日志.png

大家仔细看,保存的时候,lastModifiedTime的值是2024-01-11 09:33:26.643,从数据库查询得到的却是:2024-01-11 09:33:27.0
是不是有点懵,是不是很震惊?

无比震惊.jpg

曲折排查

先确认下MySQL表中存的值是多少

张三库中数据.png

数据库表中的值竟然是2024-01-11 09:33:27,而不是2024-01-11 09:33:26.643
此刻我只想来一句:卧槽!

卧槽.jpg

这说明数据入库有问题,而不是从数据库读取有问题,对吧?
那么接下来,我们梳理下数据入库经历了哪些环节

数据入库环节.png

大家应该能看懂吧,环节应该没有漏吧?
那问题肯定出在Spring Data JPAmysql-connector-java之间,你说是不是?
MySQL肯定是没问题的!

MySQL 真的没问题吗.gif

源码跟踪

既然问题出在Spring Data JPAmysql-connector-java之间,那么我们就直接来个一穿到底,翻了它的源码老底
大家请坐好,我要开始装逼了

装逼.jpg

JPA用的少,一时还不知道从哪里开始去跟源码,但不要慌,楼主有葵花宝典杂谈篇之我是怎么读源码的,授人以渔
断点追踪源码,一时用一时爽,一直用一直爽
直接在userRepository.save(user)前面打个断点,然后一步一步往下跟,我就不细跟了,我只在容易跟丢的地方指出来,给你们合适的方向
当断点到SessionImpl#firePersist方法时

SessionImpl#firePersist.png

我们应该去跟PersistEventListener::onPersist
一路跟下去,会来到AbstractSaveEventListener#performSaveOrReplicate方法
里面有如下代码

AbstractSaveEventListener#performSaveOrReplicate.png

添加的Action的实际类型是: EntityIdentityInsertAction
这里涉及到了Hibernate事件机制 ,简单来说就是EntityIdentityInsertActionexecute方法会被调用
所以我们继续从EntityIdentityInsertAction#execute
会来到GetGeneratedKeysDelegate#executeAndExtract
重点来了,大家打起精神

黑板和重点.png

继续跟进session.getJdbcCoordinator().getResultSetReturn().executeUpdate( insert )executeUpdate
它长这样

executeUpdate.png

面对statement.executeUpdate,如果不是断点跟的话,你可能就迷失在源码的海洋了

executeUpdate 实现类.png

你知道接下来跟谁吗?
当然,非常熟悉源码的人(比如我),肯定知道跟谁

鼓掌.png

但是用了断点,大家都知道接下来跟谁了

hikara executeUpdate.png

继续往下跟,当我们来到ClientPreparedStatement#executeInternal时,真相已经揭晓

lientPreparedStatement#executeInternal.png

此时已经来到了mysql-connector-java,发送给MySQL ServerSQL是:

MySQL sql.png

大家看仔细了,last_modified_time对应的值是:2024-01-11 09:33:26.643精度没丢
是不是很意外?

惊不惊喜.jpg

那问题出在哪?
还能出在哪,MySQL呗!
说好的MySQL没问题的了?

掀桌子.png

MySQL 时间精度

用排除法,排的只剩MySQL了,直接执行SQL试试

mysql 直接执行SQL.png

哦豁,敢情前面的源码分析全白分析了,我此刻的心情你们懂吗

懂吗.png

这必须得找MySQL要个说法,真是太狗了
我们去MySQL官方文档找找看(注意参考手册版本要和我们使用的MySQL版本一致,别搞鸡同鸭讲的乌龙)
大家不要通篇去读,那样太费时间,直接search用起来

search.png

The DATE, DATETIME, and TIMESTAMP Types 有这么一段比较关键

timestamp 说明.png

我给大家翻译一下

timestamp 翻译.png

继续看 Fractional Seconds in Time Values,内容不多,大家可以通篇读完
MySQLTIMEDATETIMETIMESTAMP都支持微妙级别(6位数)的小数位
精度直接在括号中指定,例如:CREATE TABLE t1 (t TIME(3), dt DATETIME(6))
小数位的范围是 0 到 6。0 表示没有小数部分,如果小数位缺省,则默认是0(SQL规范规定的默认是 6,MySQL8 默认值取 0 是为了兼容 MySQL 以前的版本
当插入带有小数部分的TIMEDATETIMETIMESTAMP值到相同类型的列时,如果值的小数位与精度不匹配时,会进行四舍五入

四舍五入 示例.png

四舍五入的判断位置是精度的后一位,比如精度是0,则看值的第1位小数,来决定是舍还是入,如果精度是2,则看值的第3位小数
简单来说:值的精度大于列类型的精度,就会存在四舍五入,否则值是多少就存多少
当发生四舍五入时,既不会告警也不会报错,因为这就是 SQL 规范
那如果我不想要四舍五入了,有没有什么办法?
MySQL也给出了支持,就是启用SQL modeTIME_TRUNCATE_FRACTIONAL
启用之后,当值的精度大于列类型的精度时,就是直接按列类型的精度截取,而不是四舍五入
那这么看下来,不是MySQL的锅呀,MySQL表示这锅我不背

背锅.jpg

那是谁的锅?
只能说是开发人员的锅,为什么不按MySQL使用说明书使用?
我要强调的是,产生这次问题的代码不是我写的,我写的代码怎么可能有bug

哪有bug.jpg

总结

源码debug堆栈

调用堆栈2.png

调用堆栈1.png

MySQL 时间精度

MySQLTIMEDATETIMETIMESTAMP类型都支持微妙级别(6位数)的精度,默认情况下会四舍五入,若想直接截断,则需要开启SQL modeTIME_TRUNCATE_FRACTIONAL

开发规范

阿里巴巴的开发手册中明确指出不能用:java.sql.Timestamp

阿里巴巴规范.png

另外很多公司的MySQL开发规范会强调:没有特殊要求,时间类型用 datetime
主要出于两点考虑:
    1、datetime可用于分区,而timestamp不行
    2、timestamp的范围只到2038-01-19 03:14:07.499999
有的开发小伙伴可能会问:如果到了2038-01-19 03:14:07.499999之后,timestamp该怎么办?
我只能说:小伙子你想的太远了,2038跟我们有什么关系,影响我们送外卖吗?

补充

关于上面讲到的timestamp不能分区,进行一下补充
它能分区,但是和DATEDATETIME有一丢丢区别
MySQL 5.7 说明如下

747662-20240118095600082-1127680634.png

MySQL 8.0 说明如下

747662-20240118095256383-896682731.png

timestamp类型的列只能基于UNIX_TIMESTAMP函数进行分区