MyBatis 踩坑实录:那些静默失败、不报错却让你通宵 Debug 的“幽灵 Bug”

16 阅读2分钟

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

可能原因

  1. 数据库表主键不是自增(如 UUID);
  2. keyProperty 写错(如写成 userId 而实体是 id);
  3. 使用了 Oracle 序列但未配置 <selectKey>
  4. 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 又在“静默作恶”了?