ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

MyBatis实战笔记:从XML映射到缓存机制的高频踩坑与解决方案

2026/9/17 4:25:55 拓冰建站 浏览量
MyBatis实战笔记:从XML映射到缓存机制的高频踩坑与解决方案 如果你在项目里用过 MyBatis大概率遇到过这种场景明明 SQL 没写错但页面上查出来的结果就是不对或者某个条件判断死活不生效排查半天最后发现是 XML 里的一个类型问题。我自己就是踩了太多类似的坑才下定决心整理了一份名为Mybatis-day的持续更新笔记。这个仓库不是我一时兴起写的“三十天速成教程”而是按照工作日拆分出来的一个个可验证主题每一个都对应一个现象、一个原因、一套解决方案顺带把源码、面试题和工程实践也串了进去。这份内容适合刚接手老项目、对 XML 映射还停留在“能跑就行”的人也适合准备面试但不想死记硬背源码的人。你可以把它当成一份“踩坑实录 源码导航 面试速查”三合一的手册来用。我尽量用工程里真实能落地的写法来讲不写教科书式的空话。今天这篇文章就是把这份笔记里最值钱的部分拿出来聊一聊。1. 这个“Mybatis-day”项目到底在沉淀什么项目定位与学习思路1.1 项目由来为什么要按天沉淀很多后端工程师学习 MyBatis 的方式是打开官方文档从头读或者在网上找一套视频看完就放着。这种方式的典型问题是知识点进了脑子但没有和真实场景挂钩。实际操作里你遇到的不再是“怎么用foreach”而是“为什么我传了单个字符1进if就是匹配不上”这种问题官方文档反而不会细讲。所以我把学习策略改成了“每天搞定一个小主题”。下班后抽出 30 分钟左右针对一个具体问题写验证代码再把它整理成简短笔记代码提交到仓库里。项目名里的day代表的是“每天进步一点”这个节奏而不是说学完这几十天就天下无敌。到今天仓库里已经积累了缓存机制、批量写操作、拦截器、源码阅读、面试题、IDE 配置等十几个大方向的内容。这种整理方式对我自己的帮助非常大。以前遇到问题靠搜索引擎查完就关三个月后同一个坑再踩一遍现在每个问题都有对应的验证代码和结论再遇到类似场景直接翻仓库里那篇笔记就行修复效率高了一个量级。1.2 内容归纳一张表看全仓库覆盖范围我把整个仓库的内容分成了六类每一类都有实际代码或可运行的 demo 支撑不是几张截图应付了事。下面这张表基本就是当前整个仓库的目录缩影分类覆盖内容典型主题入门基础XML 映射、注解映射、参数传递、结果集映射#{}与${}的区别resultMap 和 resultType 怎么选核心机制生命周期、动态 SQL、缓存、类型处理器一级缓存为什么失效二级缓存脏读问题设计扩展拦截器、类型处理器、脚本驱动用拦截器统一填充创建时间/更新时间工程实践Spring Boot 集成、批量写、慢 SQL 定位Mapper 扫描不到怎么办批量插入性能优化源码阅读SqlSession、Executor、StatementHandler、MapperProxyMapper 接口在没有实现类的情况下为什么能调用面试沉淀常见面试题、高频疑难问题分页插件原理、一级缓存和二级缓存区别每个主题下面我都固定用“问题描述 - 原因分析 - 解决方案 - 扩展思考”这个结构来写。这样做的最大好处是笔记不仅记录了“怎么做”还记录了“为什么这么做”过几个月回头再看也不会看不懂。1.3 项目设计思路为什么“按天”比“按章节”更容易坚持我的亲身体会章节式学习计划特别容易被一次加班打断然后就没有然后了。按章节规划往往意味着你想在某个周末一口气看完五章一旦没完成心里会有很强的挫败感。按天拆解则完全不同。一个主题花 30 分钟就能验证完比如“动态 SQL 的choose到底怎么用”任务量小到几乎不可能被拖延。这种设计背后还有一个动机很多技术知识本来就应该高频反复接触你不需要一次记住所有细节但要保证自己经常处在“今天也看了一个相关知识点”的状态里。另外一个关键设计是每个知识点都尽量配一个“最小可执行示例”。因为 MyBatis 的行为很多时候取决于上下文比如 SqlSession 是否同一个、配置里有没有开启某个开关。光看文档不动手很难形成真实判断。2. 很容易翻车的高频知识点几个需要刨根问底的细节2.1 单个数字字符比较一个字符引发的血案“单个数字字符比较”这个点仓库里单独开了一个 topic因为它在真实项目里出现的频率比想象中高得多。很多人在 XML 里写条件判断会自然写出这样的代码if teststatus 1 AND order_status 1 /if看起来没毛病但运行起来你会发现当status的值为1时这个if根本不走。原因藏在 OGNL 的类型转换里这里的1不是字符串1而是一个Character字面量。当status是Integer类型时Integer和Character做比较结果永远是false于是整个条件被跳过了。正确的写法是分情况处理如果 status 是数值类型直接用数值比较if teststatus 1如果 status 是字符串类型用字符串的 equalsif test1.equals(status)如果参数可能是字符串1也可以先转再比if teststatus 1.toString()我的建议是能不用字符字面量就不用。数值比较写数字、字符串比较写 equals规则统一别人接手你的代码也不会踩坑。你可以在日常编码时把它类比成你约了“1号桌”的客人系统记录的却是数字“1”两边名字对不上那当然匹配不到。2.2 缓存一级、二级和本地缓存的使用边界缓存是 MyBatis 里最容易产生“玄学问题”的部分。很多面试题爱问一级缓存和二级缓存的区别但实际工程里如果没理解它们的作用边界就可能在线上看到诡异的数据。一级缓存默认开启作用域是SqlSession。同一个 SqlSession 里执行两次相同的查询并且中间没有任何更新操作第二次查询会直接命中缓存。这里的关键是“同一个 SqlSession”——在 Spring Boot 集成环境下SqlSession 的生命周期由框架管理你很难控制它何时创建、何时关闭。所以如果你在 Service 层调用了两次 mapper 方法第一次和第二次之间如果发生了侵入操作一级缓存可能根本帮不上忙。二级缓存则是跨 SqlSession 的作用域是同一个namespace。它需要手动开启而且要满足“查询结果对应的实体类可序列化”等条件。但二级缓存有个工程上非常棘手的问题如果你的 SQL 涉及多表关联而缓存只挂在其中一个表的 namespace 上另一个表更新了这个缓存并不会自动失效用户看到的就是脏数据。我在实际项目里通常这样处理保留一级缓存关闭二级缓存。更复杂的查询直接在业务层用 Redis 或者本地 Caffeine 做缓存因为到了业务层你对缓存失效条件的控制力要强得多。仓库里专门写了一个示例模拟二级缓存导致不同租户数据串掉的问题想看细节的可以在我的演示代码里找到。2.3 批量写操作实际开发中到底多不多“使用 MyBatis 进行批量写操作实际开发时这种情况多吗”是常见的新手疑问我的回答是非常多。数据导入、定时任务跑批、批量初始化状态、Excel 导入模块几乎每个业务系统都逃不掉。批量写有两种主流的实现方式一种是用foreach拼一条大的 INSERT INTO ... VALUES (...),(...) 语句另一种是使用ExecutorType.BATCH的 SqlSession多次调用单条 insert由批量执行器攒一批后统一提交。两者各有适用场景方案SQL 条数事务开销内存占用适用场景foreach 拼接1 条大 SQL低高SQL 过长单次批量几百到一千条左右ExecutorType.BATCH多次单条 insert 累计提交中低上万条数据导入、跑批任务用 foreach 方案时要注意数据库对单条 SQL 大小有硬性限制。比如 MySQL 的max_allowed_packet默认只有几 MB如果一次插入一万条数据SQL 文本可能直接超限报错。用 BATCH 模式时要小心事务边界不要在一个大事务里攒几十万条数据否则一旦失败回滚会非常痛苦。我个人习惯是千条以内用 foreach简单直接万条以上就切成 BATCH 模式并且分批提交每批 5000 左右实测比一把梭稳定很多。2.4 动态 SQL 里的 switch/choose 分支写法Java 里有 switchMyBatis 的动态 SQL 里对应的就是choose、when、otherwise这一组标签。很多人喜欢到处用if但if本质是多个独立条件彼此之间没有“排他”关系如果你要的是“满足条件 A 就执行 A 分支否则走默认分支”就必须用choose。一个典型的例子select idselectByType resultTypeUser SELECT * FROM user where choose when testtype 1 AND name #{name} /when when testtype 2 AND age #{age} /when otherwise AND status 0 /otherwise /choose /where /select这里有一个必须注意的规则choose只会执行第一个满足条件的when后面的when不管满不满足都不再看。所以分支顺序很重要要把最精确、最优先的判断放在最前面。我还见过有人把if包在otherwise里面试图做“默认情况下再补充额外条件”这种嵌套写法会让 SQL 的阅读难度急剧上升。遇到这种逻辑我建议在 Service 层把参数先计算好把动态 SQL 的判断逻辑尽可能简化这才是长期维护的正路。3. 把运行过程“看”清楚日志、插件与集成实操3.1 配置 SQL 打印的多种姿势排查 MyBatis 问题时第一步永远是看 SQL 到底执行了什么。最简单的做法是在配置文件里打开日志。使用 Spring Boot 时在application.yml里加上logging: level: com.example.demo.mapper: debug注意这里配的是 Mapper 接口所在的包路径不是随便写个mybatis就完事。只有这个级别配置成 debugMyBatis 才会打印出预编译 SQL 和参数列表。如果你想看整个框架内部的执行过程可以配logging.level.org.mybatis: debug但这样输出量会非常大。如果你用的 IDE 是 IntelliJ IDEA也可以装插件直接还原最终执行的 SQL。这类插件的原理是读取 MyBatis 的预编译语句和参数拼接成一条可直接执行的 SQL对排查参数位置错乱的问题帮助很大。但生产环境不建议开这种插件也不建议长期开着 SQL 日志因为敏感数据可能被打进日志而且高并发下日志本身就会拖垮性能。3.2 与 Spring Boot 集成时的配置细节Spring Boot 集成 MyBatis 后有两个最容易出错的位置一个是mybatis.configuration和spring前缀里的配置混淆另一个是 Mapper 扫描路径不一致。我常用的配置骨架如下mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.demo.entity configuration: map-underscore-to-camel-case: true default-fetch-size: 100 default-statement-timeout: 30mapper-locationsXML 文件的位置如果漏配MyBatis 会把 XML 里的 SQL 全部忽略运行时报“Invalid bound statement”。type-aliases-package实体类别名包路径配了之后 resultType 可以直接写类名不用写全限定名。map-underscore-to-camel-case开启下划线转驼峰。数据库字段是create_time实体属性是createTime开启后自动映射。Mapper 接口的扫描方式我推荐在启动类或配置类用MapperScan统一扫描而不是每个接口都加Mapper。统一扫描可以让包路径一目了然也方便后面用拦截器或者做单元测试时统一处理。集成后还有一个容易被忽略的点Spring 管理的 SqlSession 和原生 MyBatis 的 SqlSession 生命周期不同。手动使用SqlSessionTemplate时ExecutorType.BATCH的生效需要单独声明一个 Bean不是改个接口方法就能生效的。具体写法我后面在批量写部分会提到。3.3 拦截器Interceptor的实用场景与注意MyBatis 的拦截器是扩展性很强的一个点但你真正能在业务里动手写的场景没有那么多。常见用途就三类统一填充审计字段、分页、读写日志。以“统一填充创建时间/更新时间”为例如果用拦截器实现核心思路是拦截 Executor 的update方法当 SQL 是插入或更新操作时通过反射修改参数对象里的createTime和updateTime字段。这样业务代码里就不用每处都去 set 这两个字段了。一段最小拦截器示例如下Intercepts({ Signature(type Executor.class, method update, args {MappedStatement.class, Object.class}) }) public class AuditInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { Object parameter invocation.getArgs()[1]; if (parameter instanceof BaseEntity) { BaseEntity entity (BaseEntity) parameter; entity.setUpdateTime(new Date()); // 插入时额外设置 createTime } return invocation.proceed(); } }使用拦截器时有几个坑要注意。第一拦截器类一定要交给 Spring 管理同时要让 MyBatis 知道这个拦截器存在两者缺一不可。第二签名里的 method 名和参数类型必须完全匹配否则拦截不生效。第三拦截器会增加一次额外的方法调用如果项目对性能极度敏感不要搞一堆拦截器和日志统计全部叠加后开销还是很可观的。3.4 MyBatis-Plus DDL自动建表能力是便捷还是约束现在很多团队直接用 MyBatis-Plus 作为增强组件其中“实体类自动建表”这个功能确实很好用。它本质上是解析你实体类上的注解比如TableName、TableField、TableId然后生成对应的 DDL 语句去建表。这个能力适合的场景是原型开发、内部工具、Demo 演示。它的好处是省去了手写建表语句的麻烦而且实体类一旦变更重新启动时表结构能自动跟上。但生产环境不建议依赖它做表结构变更原因很简单它没有成熟的迁移版本机制你无法回答“上个月的表结构是什么样子”这类问题。生产库的表结构变更应该交给数据库版本管理工具去处理。如果你在项目里使用它可以把 DDL 相关配置限定在本地开发环境并用一个单独的数据库实例做验证别让它指向生产库。它能帮你加速原型验证但替代不了规范的数据库管理流程。4. 源码阅读与面试题收集让仓库里的代码拥有“依据”4.1 源码阅读路径建议从哪几个类看起很多新手一提起读源码就发怵。MyBatis 的源码不算特别复杂但如果你从Configuration这种大而全的类开始读很容易懵。我建议按照一条“请求路径”来看启动时SqlSessionFactoryBuilder构建工厂解析 XML 进XMLConfigBuilder然后将 Mapper 接口注册到MapperRegistry。真正执行查询时框架会通过SqlSession拿到一个ExecutorExecutor内部再依赖StatementHandler处理预编译语句最后由ResultSetHandler完成结果集映射。在这条路径上最值得花时间的是MapperRegistry和MapperProxy。理解了MapperProxy你就明白了为什么 Mapper 接口不需要写实现类MyBatis 在运行时给接口生成一个动态实现调用接口方法时会被转发到MapperProxy的 invoke 方法再由它去找到对应的MappedStatement并执行 SQL。读源码时我推荐用 debug 方式跟一遍流程写一个最简单的查询从 Service 调用开始一直断点到SimpleExecutor的doQuery方法里观察变量值的变化。跟完一遍之后很多“接口方法为什么不能重载”“为什么一个 mapper id 要全局唯一”的问题都会豁然开朗。4.2 高频面试题这些问题反复出现MyBatis 面试题看似花样很多核心其实集中在几个固定问题上。我这里整理了一张表每个问题都附上回答要点面试题回答要点#{}和${}的区别#{}是预编译占位符走 PreparedStatement能防注入${}是直接字符串拼接有注入风险优先用#{}Mapper 接口为什么没有实现类也能调用MyBatis 启动时会扫描接口并注册调用时通过动态机制生成接口实现逻辑一级缓存什么时候失效不同 SqlSession、中间有增删改、手动 clearCache、查询参数变化二级缓存为什么可能导致脏读缓存挂在 namespace 上多表更新时其他表的变更不会触发当前 namespace 缓存失效分页插件原理拦截 Executor改写 SQL在 SQL 上拼接 limit 或分页方言语句扫描不到 Mapper 的排查方向检查 MapperScan 路径、XML 的 namespace、mapper-locations 配置回答这些问题时我建议先说结论再说原因。比如面试官问一级缓存先答“它默认开启作用域是 SqlSession 级别的”再展开讲失效场景。这样会让对方觉得你对这个框架是有体系化理解的而不是背了几个面经。4.3 怎么沉淀成“自己的”面试材料我在仓库里做面试题整理时有一个比较严格的要求每个面试题答案都必须在代码里找到对应证据。比如“一级缓存什么时候失效”我不能只写理论一定会在演示工程里写一个测试方法真实执行一遍并打印日志然后把这个日志作为笔记的附件证据。这种做法在准备面试时帮助很大。当你把每个知识点都用代码验证过之后回答问题是带着画面感的而不是在背诵文稿。而且面试官如果追问“你有实际遇到过吗”你可以讲出具体的排查过程这种深度是短期刷题很难达到的。我推荐每个人都按照“问题 - 验证场景 - 结论 - 源码定位”这个结构去建立自己的笔记库。这种积累越早开始越值钱它会在你换项目、带新人、做技术评审时反复发挥作用。5. 排错与复盘把踩过的坑变成解决同类问题的指南5.1 高频问题速查表最后这部分我把仓库里记录的几个高频问题整理成一个速查表方便你遇到问题时先对号入座问题现象可能原因解决方案if teststatus 1不生效OGNL 把1当成了 Character 类型数值比较用status 1字符串比较用1.equals(status)批量插入报 SQL 太长foreach 拼出超大 SQL超过 max_allowed_packet控制单批大小或改用 ExecutorType.BATCH查询结果字段全是 null没开启驼峰映射或 resultMap 不匹配开启map-underscore-to-camel-case核查 resultMap关闭二级缓存后查询变慢每次请求都走数据库业务层没有缓存在 Service 层加进程内缓存或 Redis 缓存拦截器不生效签名不匹配或未注册检查 Signature 里的 method 名、args确认已注册打印 SQL 日志不输出logging.level 配错包名配置 Mapper 接口所在包路径Mapper 接口报 Invalid bound statementXML 没扫到或 namespace 不一致检查 mapper-locations 和 namespace 命名这个速查表不是一次写完的而是我每踩一次坑就补一条积少成多才变成今天的规模。你也可以用类似的方式维护自己项目里的 FAQ把它放在团队文档里能省掉很多重复答疑的时间。5.2 分享几个我实际排过的错排错能力是 MyBatis 使用水平的重要分水岭。分享三个我印象最深的案例。第一个是月份筛选功能。业务上要按月份字符串01、02查询我在 XML 里用了if testmonth 01结果所有月份条件全都失效。当时排查了很久用 debug 一看才发现 OGNL 把01解析成了 Character和字符串01根本不相等。最后改成if test01.equals(month)才解决。从那之后我所有字符比较全部统一用 equals 写法。第二个是批量导入三千条数据。刚开始用 foreach 拼大 SQL接入生产环境后偶尔报“Packet too large”于是我把方案改成了ExecutorType.BATCH 分批提交。这里有一个细节在 Spring 环境里需要专门定义一个 batch 类型的 SqlSessionTemplate否则你设置了 executorType 也不会真正生效。改完之后导入耗时从十几秒降到了两秒多。第三个是二级缓存脏读。某个内部管理系统上线了二级缓存结果出现了一个诡异现象一个租户修改了数据另一个租户在查询时读到了旧值。排查后发现问题就出在二级缓存挂在 namespace 上而查询 SQL 关联了多张表任何一张表的更新都没法触发整个关联结果集的缓存失效。最终方案是撤掉二级缓存在业务层按租户维度加了 Redis 缓存并且把租户 ID 拼进缓存 key 里。这个教训让我后来写缓存相关方案时都会先搞清楚“失效时机是否可控”。我在整理这些案例时最有价值的收获并不是“解决问题”而是把“排查思路”沉淀了下来。比如遇到缓存问题先确认 SqlSession 是否同一个再看缓存作用域最后看数据变更是否会触发失效。这个排查顺序本身就能帮你少走很多弯路。这三个案例都在演示仓库里有对应的模拟工程你可以跑一遍复现过程再对照修复方案看差异。关于 MyBatis 的坑其实远不止这些。我越来越觉得框架本身并不难难的是那些“看起来没问题但实际行为完全不同”的细节。把这些细节记录成一篇篇带代码验证的笔记就是我建立这个仓库最大的动力。如果你也在后端工程里和 MyBatis 打交道建议你也用“每天一个小主题”的方式把遇到的问题、排查路径和最终结论都沉淀下来。几个月之后你会发现这份笔记的含金量比大多数收藏夹里的教程都高。