一次 MyBatis 返回值不一致引发的线上故障复盘
前言:基础不牢地动山摇
一、故障现象
业务方反馈,某个项目查询接口在代码提交后,返回的字段大幅减少 ,原本包含 director(负责人)、companyId(公司ID)、companyName(公司名称)、phone(电话)等字段全部丢失,仅剩下 id、name、email 三个字段,导致依赖这些字段的前端页面和下游服务出现异常。
二、故障定位
经排查,问题出在 commit e9d77fa3 对 findByIds 接口的"简化"改动上。该提交涉及两处变更:
1. SQL 语句变更
变更前:
sql
SELECT p.id, p.name, p.director,
o.id as companyId, o.name as companyName,
u.email as email, u.mobile as phone
FROM project p
LEFT JOIN project_extend e ON p.id = e.id
LEFT JOIN project_organization g ON e.id = g.id
LEFT JOIN sys_user u ON e.pm_login_name = u.login_name AND u.del_flag = '0'
INNER JOIN office_mapping o ON g.company_id = o.company_id AND g.office_id = o.office_id
变更后:
sql
SELECT p.id, p.name, u.email
FROM project p
LEFT JOIN project_extend e ON p.id = e.id
LEFT JOIN sys_user u ON e.pm_login_name = u.login_name AND u.del_flag = '0'
2. XML 返回值映射变更
变更前:
xml
resultType="java.util.Map"
变更后:
xml
resultType="com.example.modules.entity.SimpleProject"
正是这两处改动叠加,导致了本次线上数据丢失。
三、根因分析
3.1 变更前的"意外正确"
变更前的代码存在一个隐蔽的设计偏差:
Mapper 接口声明的返回类型为 List<SimpleProject>,但 XML 映射中的 resultType 配置为 java.util.Map。MyBatis 框架在执行 SQL 后,不是根据接口声明的类型来映射结果,而是根据 XML 中 resultType 的配置来决定每一行数据封装成什么对象。
因此,实际执行流程是:
SQL 查询 7 个字段
→ MyBatis 依据 XML 中 resultType="java.util.Map"
→ 将每行数据封装为 HashMap(包含全部 7 个键值对)
→ 返回 List<HashMap> 给调用方
调用方拿到返回值后,赋值给声明为 List<SimpleProject> 的变量。由于 Java 泛型在编译后会被擦除,运行时 JVM 不会校验集合元素的实际类型与泛型参数是否一致,因此这段代码能够正常执行而不抛异常。
最终,Controller 层将这个 List 序列化为 JSON 返回给前端时,JSON 序列化框架(如 Jackson 或 Fastjson)不会依据变量声明的泛型类型,而是依据对象的实际运行时类型 进行序列化。对象实际是 HashMap,因此其中包含的全部 7 个字段均被输出到 JSON 响应中。
这就是"原本接口返回了 7 个字段,但 SimpleProject 只有 4 个属性"的真实原因------返回的对象根本就不是 SimpleProject,而是包含全部查询列的 HashMap。
3.2 变更后的双重丢失
提交 e9d77fa3 将 resultType 修正为 SimpleProject,本意是做类型规范化,但同时带来了两个层面的数据丢失:
第一层:SQL 层面裁剪
- SQL 查询字段从 7 个减少到 3 个
- 移除了
project_organization和office_mapping两个 JOIN - 导致
director、companyId、companyName、phone列不会再出现在查询结果集中
第二层:映射层面过滤
resultType改为SimpleProject后,MyBatis 真正开始执行 ORM 映射- 而
SimpleProject类仅有id、name、source、email四个属性 - 即使将来 SQL 查询中重新加入其他字段列,缺失的属性也会被 MyBatis 忽略而无法映射
因此,这次的修改在数据来源和对象映射两个环节同时切断了原有字段的传递链路,导致数据彻底丢失。
四、技术原理总结
本次故障涉及以下关键技术点:
| 技术点 | 说明 |
|---|---|
| MyBatis 结果映射优先级 | 以 XML 中的 resultType/resultMap 为准,忽略 Mapper 接口的返回类型声明 |
| Java 泛型擦除 | List<SimpleProject> 在运行时仅被视为 List,JVM 不校验元素实际类型 |
| JSON 序列化依据 | 序列化框架依据对象运行时类型进行字段输出,而非编译期泛型声明 |
| Map 的无结构特性 | java.util.Map 作为返回值时,可将 SQL 查询结果集中的所有列名作为键值对原样输出,不受任何 Java Bean 的字段定义约束 |
五、改进建议
-
统一 Mapper 接口声明与 XML 配置 :确保接口返回类型的泛型参数与 XML 中的
resultType/resultMap保持一致,消除"意外正确"的隐患。 -
谨慎变更字段契约:对外暴露的接口字段变更,应视为接口契约变更,需评估下游影响并走完整的回归验证流程,避免主观判断为"简化"。
-
建立接口契约测试:对核心查询接口建立响应字段的结构化断言,将字段完整性检查纳入 CI/CD 流水线,防止字段因代码变更而静默丢失。
-
代码审查关注点补充:在 CR 流程中,当发现 Mapper 接口声明的返回类型与 XML 配置不一致时,应将其标记为高风险项重点审查。
本来是按要求规范代码,演变成了"你不改就不会出问题"的挨打立正现场
结语
本次故障的警示在于:一个看似正确的代码规范化改动 ,反而导致了线上数据丢失。根本原因是对既有实现的理解不够深入------旧代码之所以能返回预期字段,是靠 MyBatis 的 Map 返回值机制和 JSON 序列化框架的"巧合协作"。规范化的初衷是好的,但在动手前,必须充分理解代码"为什么这么写",评估改动是否会破坏隐式的契约,否则"修正"本身就可能成为故障之源。