MyBatis 踩坑实录:那些静默失败、不报错却让你通宵 Debug 的“幽灵 Bug”
在 Java 持久层开发中,MyBatis 因其灵活、轻量、贴近 SQL 的特性广受青睐。然而,正因其“灵活性”,也埋下了不少看似正常、实则逻辑错误的陷阱——这些 Bug 往往不抛异常、不报错、程序照常运行,但返回的数据就是不对,让人在深夜反复检查 SQL、日志、数据库,却始终找不到症结。
本文结合真实项目经验,盘点那些 “最隐蔽、最折磨人” 的 MyBatis 坑点,助你少走弯路,早下班!
一、#{ } 与 ${ } 混用:SQL 正确,结果却错得离谱
场景还原:
<select id="getUserByName" resultType="User">
SELECT * FROM user WHERE name = ${name}
</select>
你以为传入 "张三" 会生成:
SELECT * FROM user WHERE name = '张三'
但实际生成的是:
SELECT * FROM user WHERE name = 张三 -- 缺少引号!
→ 数据库报错?不一定!
如果 name 字段是数字类型(如 INT),而你传了个字符串 "123",可能碰巧能查到数据;但如果传 "abc",MySQL 可能静默转为 0,返回一堆 ID=0 的用户——毫无报错,但结果完全错误。
✅ 正确做法:
- 永远优先使用
#{}(预编译,防注入,自动加引号)。 - 仅在动态表名、列名等无法预编译的场景使用
${},且必须手动校验输入合法性。
二、resultMap 字段映射“大小写敏感”陷阱
场景还原:
数据库字段:user_name(下划线命名)
Java 实体类:userName(驼峰命名)
你在 resultMap 中写:
<result property="userName" column="username"/>
注意:column="username" 少了一个下划线!
MyBatis 不会报错,只是将该字段设为 null。如果你没打印完整对象,可能根本发现不了 userName 是空的,直到业务逻辑出错。
更隐蔽的是:某些数据库(如 MySQL)在 lower_case_table_names=1 时对列名不区分大小写,但在 MyBatis 映射时却严格匹配——导致本地 OK,上线后字段全 null。
✅ 正确做法:
-
开启 MyBatis mapUnderscoreToCamelCase(推荐):
mybatis: configuration: map-underscore-to-camel-case: true这样
user_name自动映射到userName,无需手写resultMap。 -
若必须用
resultMap,务必严格核对字段名,建议开启call-setters-on-nulls: true避免 null 被忽略。
三、缓存“静默污染”:改了数据,查的还是旧值
MyBatis 有一级缓存(SqlSession 级)和二级缓存(Mapper 级)。默认开启一级缓存。
经典 Bug:
User user1 = userMapper.selectById(1); // 从 DB 查
user.setName("新名字");
userMapper.update(user); // 更新 DB
User user2 = userMapper.selectById(1); // 从缓存拿!仍是旧值!
因为两次查询在同一个 SqlSession 中,MyBatis 直接返回缓存对象,update 操作并未清空 select 的缓存(除非 update 和 select 是同一个 statementId)。
→ 结果:程序逻辑基于过期数据运行,无任何警告。
✅ 正确做法:
- 在 Service 层确保 每次操作使用独立 SqlSession(Spring 中默认每次方法调用新开事务,可规避)。
- 或手动清除缓存:
sqlSession.clearCache()。 - 谨慎使用二级缓存,多实例部署时极易导致脏读。
四、foreach 批量操作:空集合导致 SQL 语法错误(但被吞掉)
场景:
<delete id="deleteByIds">
DELETE FROM user WHERE id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</delete>
当 ids 为空列表时,生成 SQL:
DELETE FROM user WHERE id IN ()
→ 语法错误! 但某些数据库驱动或 MyBatis 版本会静默忽略此错误,返回“影响 0 行”,你以为“没数据要删”,实则 SQL 根本没执行成功。
更糟的是:在 Spring 事务中,若后续操作成功,整个事务提交,你完全意识不到 delete 失败了。
✅ 正确做法:
- 在 XML 中加
<if test="ids != null and ids.size > 0"> - 或在 Java 层提前判断空集合,直接 return。
五、自动填充字段失效:insert 后主键为 null
使用 useGeneratedKeys 获取自增 ID:
<insert id="insertUser" useGeneratedKeys="true" keyProperty="id">
INSERT INTO user(name) VALUES(#{name})
</insert>
但插入后 user.getId() 仍是 null。
可能原因:
- 数据库表主键不是自增(如 UUID);
keyProperty写错(如写成userId而实体是id);- 使用了 Oracle 序列但未配置
<selectKey>; - MyBatis 版本 bug(老版本对某些数据库支持不佳)。
→ 最致命的是:不报错! 你只能靠肉眼发现 ID 为 null。
✅ 正确做法:
- 确认数据库主键策略;
- 对非自增主键(如雪花 ID),应在 Java 层生成后传入;
- 开启 MyBatis 日志,观察是否返回了 generated keys。
六、TypeHandler 被忽略:JSON 字段存取异常
你想把 List<String> 存为 JSON 字符串:
public class User {
private List<String> tags;
}
写了 JsonTypeHandler,但忘记在字段上声明:
// 错误:未指定 typeHandler
private List<String> tags;
结果:MyBatis 尝试用默认 TypeHandler 处理 List,可能存成 [Ljava.lang.String;@1a2b3c 这种 toString 结果,读取时直接 null 或报 ClassCastException——但有时因框架容错,只记录 warn 日志,你根本没注意到。
✅ 正确做法:
-
在字段上显式指定:
@Results({ @Result(property = "tags", column = "tags", typeHandler = JsonTypeHandler.class) }) -
或全局注册 TypeHandler(需确保能匹配到
List<String>类型)。
七、总结:如何避开这些“静默杀手”?
| 坑点 | 防御措施 |
|---|---|
#{} vs ${} | 默认用 #{},${} 仅用于元数据且严格校验 |
| 字段映射错误 | 开启 mapUnderscoreToCamelCase,打印完整返回对象 |
| 缓存污染 | 理解缓存作用域,必要时手动清除 |
| 空集合 foreach | 加 <if> 判断或 Java 层拦截 |
| 主键不回填 | 检查数据库策略 + MyBatis 配置 + 开启 SQL 日志 |
| TypeHandler 失效 | 显式声明或全局注册,验证序列化结果 |
🔍 终极建议:永远开启 MyBatis SQL 日志!
在开发环境打印完整 SQL 和参数,90% 的“幽灵 Bug”都会现出原形。
logging:
level:
com.yourpackage.mapper: debug
结语
MyBatis 的“自由”是一把双刃剑——它给你掌控 SQL 的能力,也要求你承担更多责任。那些不报错的 Bug,往往比抛异常的错误更危险,因为它们在系统中悄然蔓延,直到引发线上事故。
希望本文能帮你识别这些“沉默的陷阱”,下次再遇到诡异的数据问题,不妨先问一句:是不是 MyBatis 又在“静默作恶”了?