找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4475

积分

0

好友

625

主题
发表于 1 小时前 | 查看: 2| 回复: 0

老项目的遗留代码确实不能轻易改动,稍有不慎就可能引发各种意想不到的问题。有句话说得对:改好“屎山”代码没有功劳,改出问题却要背锅,典型的吃力不讨好,关键是它本来还能跑!

今天就来分享一个真实案例:一位新同事将项目中的 MyBatis 替换为 MyBatis-Plus,结果上线后引发了生产问题。

干练熊猫表情包:干就完了

MyBatis 替换成 MyBatis-Plus 的踩坑之旅

背景介绍

一个老项目,数据库用的是 MySQL 5.7.36,ORM框架用的是 MyBatis 3.5.0,数据库驱动 mysql-connector-java 的版本是 5.1.26

团队新来了一位精力充沛的同事,看着就是个喜欢折腾技术的主。他觉得原生 MyBatis 使用起来不够简洁,需要手写的代码较多,于是提议将其替换成功能更强大的 MyBatis-Plus。

替换过程与初次异常

首先,准备一张测试表 tbl_order 并初始化两条数据。

DROP TABLE IF EXISTS  `tbl_order`;
CREATE TABLE  `tbl_order` (
 `id` bigint(0) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增主键',
 `order_no` varchar(50) NOT NULL COMMENT '订单号',
 `pay_time` datetime(3) DEFAULT NULL COMMENT '付款时间',
 `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
 `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后修改时间',
 PRIMARY KEY ( (`id`) USING BTREE
) ENGINE = InnoDB COMMENT = '订单';
INSERT INTO  `tbl_order` VALUES (1, '123456', '2024-02-21 18:38:32.000', '2024-02-21 18:37:34.000', '2024-02-21 18:40:01.720');
INSERT INTO  `tbl_order` VALUES (2, '654321', '2024-02-21 19:33:32.000', '2024-02-21 19:32:12.020', '2024-02-21 19:34:03.727');

为了简化演示,我们直接用一个示例Demo来模拟替换过程。核心思路是仅将 MyBatis 替换为 MyBatis-Plus,其他组件版本暂时不变。MyBatis-Plus 版本采用当时引入的 3.1.1mysql-connector-java 保持 5.1.26 不变。

此时运行单元测试 com.qsl.OrderTest#orderListAllTest,会立即抛出异常。

Java异常堆栈截图:Conversion not supported for type java.time.LocalDateTime

关键错误信息是:不支持的转换类型:java.time.LocalDateTime
是谁不支持?是 mysql-connector-java 驱动不支持!那么,哪个版本才开始支持呢?答案是:5.1.37

GitHub提交截图:JDBC 4.2 support for java.time classes

首次修复:升级驱动

mysql-connector-java 升级到 5.1.37,再次执行测试。

测试通过截图:查询返回两条订单数据

异常消失,查询结果正确。看起来,MyBatis-Plus 替换 MyBatis 的工作似乎已经顺利完成。
一切顺利得让人有点难以置信。

困惑熊猫表情包:就这么简单?

深挖异常根源

我们回过头仔细分析那个异常:Conversion not supported for type java.time.LocalDateTime。为什么替换前没这个异常,替换后就出现了?这真的是 MyBatis-Plus 的问题吗?

最好的办法就是从异常堆栈入手。

调试界面截图:高亮显示异常抛出点

点击堆栈跟踪,可以定位到 LocalDateTimeTypeHandler 的具体代码。

LocalDateTimeTypeHandler.java 源码截图

这么简单的代码能有什么问题?
请注意图中左上角显示的 MyBatis 版本是 3.5.1,而非项目最初的 3.5.0。可能有读者会问:不是用 MyBatis-Plus 替换了 MyBatis 吗,怎么还有 MyBatis?实际上,MyBatis-Plus 是一个增强工具,它底层仍然依赖 MyBatis 核心包。

既然基于 MyBatis 3.5.0 没有异常,而基于 3.5.1 却抛出异常,那问题很可能出在 LocalDateTimeTypeHandler 从 3.5.0 到 3.5.1 的变动上。

我们来对比一下这两个版本的差异。

MyBatis 3.5.0 与 3.5.1 的 LocalDateTimeTypeHandler 代码对比

看出端倪了吗?
在 MyBatis 3.5.0 中,框架会主动处理 LocalDateTime 类型的转换(通过 getTimestamp() 获取 java.sql.Timestamp,再转换为 java.time.LocalDateTime)。
然而,从 MyBatis 3.5.1 开始,它不再处理 LocalDateTime(以及 LocalDateLocalTime)的转换,而是直接将这个任务委托给 JDBC 驱动(也就是 mysql-connector-java)去调用 getObject(columnName, LocalDateTime.class)

巧的是,mysql-connector-java5.1.37 版本之前,根本不支持 LocalDateTime 这个类型。

“哎呦,巧了~”表情包

那么 5.1.26 支持哪些类型呢?我们继续从异常堆栈深入源码。

调试界面截图:定位到 ResultSetImpl.getObject 方法

进入方法后,可以看到一段类型判断逻辑。

ResultSetImpl.java 源码截图:类型判断逻辑

向上查看源码,确实找不到对 LocalDateTimeLocalDateLocalTime 的支持。这正是 mysql-connector-java 从 5.1.37 才开始新增的功能。

总结第一个异常的根因:MyBatis 从 3.5.1 版本起,将 java.time 包下的时间类型转换委托给 JDBC 驱动处理,而 mysql-connector-java 在 5.1.37 之前不支持这些类型。

风暴来临:空指针异常

版本上线没两天,真正的问题终于来了。
我们在 tbl_order 表中插入一条 pay_time 为 NULL 的记录:

INSERT INTO `tbl_order` VALUES (3, 'asdfgh', NULL, '2024-02-21 20:01:31.111', '2024-02-21 20:02:56.764');

再次执行查询测试。

测试失败截图:抛出 NullPointerException

此刻只想问一句:刺不刺激?

“刺...刺激”熊猫表情包

遇到问题就继续分析。从异常堆栈看,问题出在 JDBC42ResultSet.getObject 方法中。

调试界面截图:定位到 JDBC42ResultSet.getObject 第67行

看代码逻辑,如果 getTimestamp(columnIndex) 返回的是 NULL,那么调用 toLocalDateTime() 时自然就会抛出 NullPointerException。这是驱动在空值处理上的一个Bug。

修复迫在眉睫,我们先查一下哪个版本修复了这个问题。

GitHub提交截图:Fix for Bug#84189, Allow null when extracting java.time.* classes

mysql-connector-java 驱动升级到修复了该问题的 5.1.42 版本。

测试通过截图:成功查询包含null支付时间的三条数据

问题得以解决。经过这番折腾,当事人眼里“进步”的光芒似乎暗淡了不少,但经验值肯定涨了。

关于 MyBatis-Plus 的 Issue-1114

后来,我无意中看到了 MyBatis-Plus 仓库的 issue-1114,这跟我们前面分析的 Conversion not supported for type java.time.LocalDateTime 是不是同一个问题?
区别可能在于,他们使用的数据库连接池是 Druid,而我们用的是默认的 HikariCP。结合 Druid 项目的 issue-3302 来看,使用不同连接池时出现的异常信息可能略有差异。但分析异常的根本思路和方法是通用的。

另一个故事:修复了“不该修”的 Bug

这是我亲身经历的一次线上事故,至今回想起来,依然觉得这锅背得有点冤。

“你背”锅表情包

背景与“Bug”

业务场景很简单:文件分为主文件和附属文件。先生成主文件,再生成附属文件。生成附属文件时,需要校验它所依赖的所有主文件是否都已生成;如果有任意一个主文件未生成,则附属文件生成失败并抛出异常。

在优化附属文件的校验逻辑时,我“成功”地背上了一个生产事故。先看看优化前的代码:

// 模拟数据
Long executionId = 100L;
List<String> fileIds = Arrays.asList("1", "2", "3");
String status = "success";
String dataDate = "2022-02-21";

// 优化前的校验逻辑
List<String> dbFileIds = listFileGenerateLog(executionId, fileIds, status, dataDate);
if(CollectionUtils.isEmpty(dbFileIds)) {
    throw new RuntimeException("依赖的资源未生成");
}

listFileGenerateLog 方法的作用是根据参数查询已生成的文件记录。这段校验逻辑的问题在于:只要有一个主文件生成了,校验就算通过。这显然与业务要求(所有主文件必须全部生成)不符。
这难道不是一个妥妥的 Bug?我当时的想法是:自信点,这就是Bug!

“自信点,就说这是Bug”表情包

优化与上线

遇到Bug你能忍?反正我忍不了,反手就是一个优化:

// 模拟数据
Long executionId = 100L;
List<String> fileIds = Arrays.asList("1", "2", "3");
String status = "success";
String dataDate = "2022-02-21";

// 优化后的校验逻辑
List<String> dbFileIds = listFileGenerateLog(executionId, fileIds, status, dataDate);
List<String> notGeneratedFileIds = fileIds.stream().filter(fileId -> !dbFileIds.contains(fileId)).collect(Collectors.toList());
if (!CollectionUtils.isEmpty(notGeneratedFileIds)) {
    throw new RuntimeException("依赖的资源" + StringUtils.join(notGeneratedFileIds, ",") + "未生成");
}

这下逻辑对了吧?找出所有未生成的主文件ID,只要列表非空就报错。

生产事故

中午升级后,系统稳定运行了一段时间,文件生成一切正常。
到了晚上19点,监控告警:一个附属文件生成失败,异常提示是:依赖的资源[abc_{yyyyMMdd}.txt]未生成
第一眼看到这个异常,感觉既熟悉又陌生。熟悉的是异常信息的格式,陌生的是 abc_{yyyyMMdd}.txt——这明明是一个文件名模板,而我们期望的应该是 fileId(一个自增的数字ID)才对。

一个不好的预感闪过脑海:数据库里存在脏数据?
一查果然如此。这个附属文件关联主文件的字段值竟然是:4356,abc_{yyyyMMdd}.txt,最后修改时间是 2021-08-21。实际上,4356号文件的文件名就是 abc_{yyyyMMdd}.txt。正常来说,关联字段应该只存 4356 这个ID。
真相大白:原来那个旧的、“错误”的校验逻辑,阴差阳错地“兼容”了这条历史脏数据,所以几年来一直相安无事。

漏水管道配图:写了一大堆业务代码不管用,最后靠bug运行起来

是不是有内味儿了?我把Bug修好了,系统反而出问题了。你说我是不是“手贱”?经此一役,我眼里的光芒也暗淡了些许。

总结与思考

这两个案例都告诉我们,无论是升级底层框架组件,还是修改遗留的业务代码,都可能产生“牵一发而动全身”的影响,风险不容小觑。

我的个人观点是:对于还能稳定运行的“屎山”代码,能不动就不要动。因为改好了通常没有显性绩效,改出问题却要实实在在背锅,属于典型的高风险、低收益操作。

如果到了非改不可的地步(比如安全漏洞、严重性能问题),那就必须进行全面、彻底的测试,包括单元测试、集成测试以及对历史脏数据的兼容性测试。理解组件之间的依赖关系和版本兼容性矩阵至关重要,就像我们遇到的 MyBatis、MyBatis-Plus 与 MySQL 驱动版本之间的耦合问题。

技术之路充满挑战,每一次踩坑都是成长的阶梯。你在升级框架或重构代码时遇到过哪些意想不到的坑?欢迎到 云栈社区Java数据库/中间件 板块分享你的经历,与其他开发者一起交流避坑经验。




上一篇:HAMi 官网重构全解析:从信息架构到用户体验的系统升级
下一篇:35岁职场人的真实感悟:理解、接受、放弃与一切终将过去
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-3-18 06:02 , Processed in 0.466333 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表