MyBatis
MyBatis 把 Mapper 接口方法与 SQL 关联起来,完成参数绑定、数据库执行和结果映射。本文以 MyBatis 3 和 MyBatis-Spring 为范围,先沿一次查询解释执行链路。接着说明批处理与缓存,再介绍分页插件、动态 SQL 和延迟加载。
1. 面试核心
先记住这几条,再读正文- 查询链路。 Mapper 代理定位
MappedStatement,再经SqlSession和Executor执行查询,缓存命中时可跳过 JDBC 查询。卡 · MappedStatement 与 Mapper 代理 - 参数绑定。
#{}通过 PreparedStatement 绑定数据值,${}直接替换 SQL 文本,动态标识符应来自固定白名单。卡 · 参数占位符与字符串替换 - 缓存范围。 一级缓存属于
SqlSession,二级缓存按 namespace 配置,跨 namespace 或外部写入需单独处理缓存失效。卡 · 一级缓存与二级缓存 - 批处理边界。 BATCH 的
flushStatements()执行 JDBC 批处理并返回BatchResult,commit 才提交事务。卡 · Executor 与批处理 - 插件与 SQL 改写。 MyBatis 插件可拦截
Executor等四类组件的指定方法,PageHelper 通过Executor.query改写分页 SQL。 卡 · 插件扩展点卡 · PageHelper 分页 SQL 改写
2. MappedStatement 与 Mapper 代理
Mapper 方法要执行哪条 SQL,由启动时加载的映射配置决定。MyBatis 读取 XML 或注解,把 SQL 及其动态片段、参数类型、结果映射和缓存配置保存到 Configuration。它是 MyBatis 保存全局配置的对象。
每条映射语句对应一个 MappedStatement,其中记录执行所需的配置。语句用 namespace + id 唯一标识,namespace 是映射器的命名空间。下面的查询使用 findById 作为 id:
<select id="findById" resultMap="orderResultMap">
select id, user_id, status
from orders
where id = #{id}
</select>
在 Mapper 代理用法中,namespace 对应接口全名,语句 id 对应方法名。调用 orderMapper.findById(id) 时,动态代理据此找到映射语句,再把参数交给 SqlSession。SqlSession 是一次数据库工作的会话入口,所以接口方法无需手写查询实现。
3. 查询执行链路
找到映射语句后,还需要绑定参数、执行 SQL 并构造返回对象。下面按职责顺序展示使用 PreparedStatement、且未命中缓存的查询链路。箭头表示处理阶段,各组件之间的调用还包含委托关系。
Mapper proxy
→ SqlSession
→ Executor
→ StatementHandler
→ ParameterHandler
→ JDBC PreparedStatement
→ ResultSetHandler
→ Java result
这条链路中的四类处理组件分别承担以下工作:
- Executor:执行器组织查询或更新,管理本地缓存和事务相关操作。配置二级缓存时,CachingExecutor 会先处理 namespace 缓存。之后,它再进入实际执行器的本地缓存与查询路径。
- StatementHandler:创建、参数化并执行 JDBC Statement。
- ParameterHandler:借助 TypeHandler 把 Java 参数绑定到 JDBC。TypeHandler 负责 Java 类型与 JDBC 类型之间的转换。
- ResultSetHandler:读取 JDBC 返回的 ResultSet,并按 ResultMap 或结果类型构造对象。ResultMap 描述数据库列怎样对应 Java 字段或关联。
排查时,可以按SQL、参数和结果定位:
- SQL 生成错误时,检查动态 SQL 和 BoundSql。BoundSql 保存本次执行生成的 SQL、参数映射和附加参数。
- 参数类型错误时,检查 ParameterHandler 与 TypeHandler,确认 Java 值怎样绑定到 JDBC。
- 列映射错误时,检查 ResultMap 与 ResultSetHandler,确认返回列怎样写入对象。
4. Executor 与批处理
执行器决定 Statement 是否复用,以及更新何时交给驱动。MyBatis 提供三种执行方式:
SIMPLE:每次执行创建新的 Statement,是默认方式。REUSE:在当前执行器范围复用相同 SQL 的 PreparedStatement。BATCH:积累更新语句并批量提交给 JDBC 驱动。
使用 BATCH 时,Mapper 更新调用先把操作加入 JDBC 批次,返回的是占位值。调用 flushStatements() 后,驱动才执行积累的批次,并通过 BatchResult 返回结果。因此,实际影响行数要从 BatchResult 中的驱动结果读取。
flush 后事务仍需 commit,执行失败则按事务边界回滚。查询进入 BatchExecutor.doQuery 时,也会先刷新待执行批次,所以中途查询会改变执行时机。验证批处理时,还要检查驱动能力、生成键和错误定位,并测试事务大小对性能的影响。
5. 参数占位符与字符串替换
参数既可以是普通数据值,也可以决定使用哪个列名,二者要分开处理。#{value} 会生成 JDBC ? 占位符,再由 PreparedStatement 绑定数据值。绑定的内容作为数据处理,所以适合用户输入的普通参数。
${column} 会把文本原样替换进 SQL,替换内容会参与 SQL 语法解析。表名、列名和排序方向属于语法,不能用 #{} 这样的普通值占位符表达。需要动态选择时,应在代码中映射到固定白名单。
下面的 Java 17 示例假定 sort 是已校验的枚举,输出只能是两个固定列名之一:
String orderBy = switch (sort) {
case CREATED_AT -> "created_at";
case AMOUNT -> "amount";
};
请求中的任意字符串直接进入 ${} 会形成 SQL 注入入口。示例通过枚举到固定文本的映射限制输入。
6. 一级缓存与二级缓存
缓存决定一次查询是否需要再次访问数据库。一级缓存属于 SqlSession,二级缓存则让不同会话复用同一 namespace 下的查询结果。
每个 SqlSession 都有本地缓存,默认范围是 localCacheScope=SESSION。在 SQL 等缓存键内容也相同的前提下,相同 statement、参数和分页条件的查询可以复用结果。update、commit、rollback 和 close 等操作会清理本地缓存。
SESSION 范围的本地缓存保存并返回对象引用。若应用修改了查询结果,后续命中缓存时看到的就是同一个对象修改后的值。因此,应缩短 Session 生命周期,并避免随意修改缓存中的查询结果。
二级缓存按 Mapper namespace 配置,需要通过 <cache/> 或 @CacheNamespace 等方式显式声明。声明后,还要满足全局 cacheEnabled 和语句 useCache 的设置。默认情况下,查询会使用已配置的缓存,写语句则刷新所属 namespace 的缓存。查询返回时,结果还要等会话的事务完成阶段按规则发布,其他会话才能复用。
同一张表可以由多个 namespace 或其他服务更新。某个 namespace 的缓存不会自动获知全部写入。可按依赖共享缓存配置或避免使用这种结果缓存。localCacheScope=STATEMENT 缩短一级缓存共享范围;查询的 useCache=false 控制二级缓存,两级缓存分别控制。
7. 插件扩展点
插件通过代理包装组件,在指定方法前后加入处理逻辑。MyBatis 支持的四类扩展点是:
- Executor 提供查询、更新和事务操作等执行入口。
- StatementHandler 负责 JDBC Statement 的创建与执行。
- ParameterHandler 负责参数绑定。
- ResultSetHandler 负责读取并映射结果。
分页、审计或租户条件插件应选择能完成任务的最小扩展点。改写 BoundSql 时,还要同步参数映射和缓存键,否则会绑定错参数或复用错误结果。数据库方言决定 SQL 语法;嵌套查询会增加查询路径,也需要验证插件是否在预期位置生效。
插件顺序会改变行为。若两个插件都重写 SQL 或参数,嵌套顺序就会影响结果。应通过集成测试确认代理嵌套顺序和最终 SQL。
7.1 PageHelper 分页 SQL 改写
PageHelper 通过在执行前改写 SQL,让数据库只返回所需页面。以 PageHelper 6.1.1 为例,PageInterceptor 拦截 Executor.query 的四参数和六参数重载。PageHelper.startPage(2, 10) 将页码、页大小放入当前线程的分页上下文。紧随其后的 Mapper 查询进入拦截器,完成计数与分页查询:
- 拦截器取得
MappedStatement、参数、BoundSql和CacheKey。它据此判断是否启用分页。 - 启用 count 时,拦截器执行计数查询。随后,拦截器通过数据库方言生成分页 SQL。
- MySQL 方言为第二页、每页 10 条生成
LIMIT ?, ?,绑定 offset=10、pageSize=10。与此同时,分页处理会同步参数映射、动态 SQL 附加参数和缓存键。 - 拦截器用新的 BoundSql 调用 Executor,执行数据库分页。它返回分页结果,并在 finally 路径清理分页上下文。
例如 SELECT id FROM orders ORDER BY id 会在 JDBC 执行前变为带 LIMIT 的 SQL。RowBounds.DEFAULT 传给后续执行器,分页工作由改写后的 SQL 完成。startPage 后应立即执行目标查询;若分支跳过查询,或进入拦截器前发生异常,就需手动清理。此时应调用 PageHelper.clearPage() 清理线程上下文。这能避免线程复用影响下一次查询。
8. Spring 事务与 SqlSession
在 Spring 事务中,多次 Mapper 调用需要共享同一数据库工作范围。MyBatis-Spring 通过事务同步管理 SqlSession 的创建、复用和提交。SqlSessionFactory 是创建会话的工厂,它必须与事务管理器使用同一 DataSource。同一事务内,经同一 SqlSessionFactory 的 Mapper 调用使用绑定的 Session。直接手工创建另一个 Session 会脱离当前事务和一级缓存范围。
DefaultSqlSession 应按单次工作单元使用,避免多线程共享。SqlSessionTemplate 则是线程安全的入口,它根据当前事务找到实际 Session。因此,可以共享模板,由模板选择各事务自己的会话。
事务外的每次 Mapper 调用会各自完成会话生命周期,一级缓存也随各次 Session 结束。
Mapper 返回后,外层事务仍可继续执行或回滚。缓存可见性、批处理 flush 和连接释放都要结合外层事务边界判断。
9. 动态 SQL 与结果映射
查询条件随输入变化时,动态 SQL 会根据参数生成本次执行的 BoundSql。常见的动态 SQL 标签各有作用:
<if>根据条件决定是否加入一段 SQL。<where>在需要时加入 WHERE,并处理开头多余的 AND 或 OR。<foreach>展开集合,生成 IN 条件等需要的列表参数。
业务要明确空集合、空字符串和 null 分别表示什么。缺少必需筛选条件时,应拒绝执行,防止一次定向修改变成全表操作。
读取结果时,resultType 适合简单结果的自动映射。resultMap 则显式描述列映射和构造器,也能用 association 表达单个关联,用 collection 表达集合。一对多映射应明确对象标识,避免把同一主记录反复创建。多参数 Mapper 可用 @Param 给出稳定名称,避免依赖编译器参数名设置。
列表查询逐项触发嵌套 select 时,会形成 N+1 次访问。延迟加载将查询推迟到属性访问时,实际 SQL 数量仍要通过日志确认。
关联访问频繁时,可以用 join 或批量加载减少查询,选择方法见 N+1 查询。
9.1 关联属性的延迟加载
延迟加载需要记住尚未执行的关联查询,等访问属性时再执行。MyBatis 3.5.19 为带延迟关联的结果对象创建代理,默认 proxyFactory=JAVASSIST。association 或 collection 的嵌套 select 对应一个 ResultLoader。这个加载器保存在 ResultLoaderMap 中。触发属性读取时,代理执行关联查询并写回该属性。
<association property="user" column="user_id"
select="findUserById" fetchType="lazy"/>
此片段放在订单的 resultMap 中,findUserById 接收 user_id 查询用户。主查询返回订单后,order.getUser() 触发尚未完成的 user 加载。后续访问使用已填充的属性。
关联何时执行查询,由以下延迟加载配置决定:
lazyLoadingEnabled默认 false,设为 true 时启用延迟关联。- 单个关联的
fetchType="lazy"或"eager"覆盖全局设置。 aggressiveLazyLoading=false时按属性加载;设为 true 时,普通方法调用也会触发全部待加载属性。- 默认
lazyLoadTriggerMethods=equals,clone,hashCode,toString中的方法会触发全部待加载属性。
因此,打印对象或序列化调用 getter 时,也会提前触发 SQL。
若原 Executor 已关闭,或加载发生在其他线程,ResultLoader 会创建新 Executor。它使用已配置的 Environment/DataSource 执行查询。事务范围也随之改变。若关联数据需要事务内一致性,应在事务结束前完成加载。逐项访问列表关联的 N+1 查询应通过批量加载或 join 处理。
10. 常见问题
理解执行链路后,还需要区分缓存与数据库隔离的边界,以及插件能进入哪些调用位置。
10.1 一级缓存中的旧值属于脏读吗?
缓存返回旧值属于数据新鲜度问题,与读取未提交数据的脏读不同。一级缓存命中时,会跳过数据库查询,直接返回 SqlSession 中已有的对象。数据库脏读则专指读取另一事务尚未提交的修改。
排查时,先确认 Session 范围、更新操作和缓存命中情况,再核对数据库隔离级别。这样才能区分值来自本地缓存,还是来自数据库读取。
10.2 插件可以拦截任意 MyBatis 方法吗?
官方插件机制支持 Executor、StatementHandler、ParameterHandler、ResultSetHandler 的规定方法。需要修改其他行为时,应使用明确扩展点或封装层。这样能避免依赖内部类的偶然实现。
11 · 面试题卡片
11.1 MyBatis 的一次查询是怎样执行的
出现公司:作业帮
考察重点:说明 Mapper 代理怎样定位映射语句,以及执行器如何组织 JDBC 查询。能指出缓存、插件和 Spring 事务分别在哪一步生效。
相关内容:MappedStatement 与 Mapper 代理、查询执行链路、Spring 事务与 SqlSession。
参考回答
Mapper 调用经 SqlSession 和 Executor 执行映射语句,缓存未命中时进入 JDBC,最后把结果映射为 Java 对象。
启动时,XML 或注解解析为 MappedStatement。Mapper 代理按接口和方法定位语句,再由 Executor 检查缓存并组织执行。
JDBC 路径中,StatementHandler 创建 Statement,ParameterHandler 绑定参数,TypeHandler 转换 Java 与 JDBC 类型;SQL 执行后,ResultSetHandler 构造返回对象。MyBatis-Spring 会让 SqlSession 与外层事务同步,因此连接和缓存的生命周期也受事务边界影响。
11.2 MyBatis 的 #{} 和 ${} 有什么区别,动态排序字段应该怎样传入?
出现公司:金山云
考察重点:区分数据值绑定与 SQL 文本替换,并说明动态标识符为什么需要白名单、多参数方法怎样命名。
相关内容:参数占位符与字符串替换。
参考回答
#{} 通过占位符绑定数据值,${} 则把内容直接替换进 SQL 文本。
列名和排序方向属于 SQL 语法,需要从枚举或固定映射中选择后再用于 ${}。若把请求中的任意字符串直接拼入,就会形成 SQL 注入入口。多个方法参数可用 @Param 明确名称,避免依赖编译器是否保留参数名。
11.3 MyBatis 一级缓存和二级缓存的作用范围、失效条件有什么区别?
出现公司:金山云
考察重点:比较 SqlSession 与 namespace 的缓存范围,说明结果发布和失效时机。能解释跨 Mapper 或外部写入为什么需要单独处理一致性。
相关内容:一级缓存与二级缓存、Spring 事务与 SqlSession。
参考回答
一级缓存属于单个 SqlSession;二级缓存按 namespace 配置,可以跨会话复用结果。
- 一级缓存默认在 Session 内复用。
localCacheScope=SESSION时,更新、提交、回滚和关闭等操作会清理它。缓存保存对象引用,修改返回对象会影响后续命中结果。 - 二级缓存需要显式配置。
cacheEnabled和useCache控制使用,结果按事务完成规则发布,默认写语句刷新所属 namespace。跨 namespace 或外部写入需要额外的失效策略。
useCache=false 不关闭一级缓存,后者由 localCacheScope 控制。Spring 事务外,各次调用独立管理 Session,一级缓存随该次 Session 结束。
11.4 使用 BATCH 时,flushStatements、Mapper 返回和事务提交分别代表什么?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:区分操作加入批次、驱动执行与事务提交,说明怎样读取影响行数并处理失败回滚。
相关内容:Executor 与批处理。
参考回答
BATCH 下,Mapper 更新返回表示操作已加入批次,flushStatements() 执行批次,commit 提交事务。
Mapper 返回的是占位值,实际影响行数要读取 BatchResult 中的驱动结果。flush 会暴露执行结果或异常,此时事务仍可回滚。查询进入 BatchExecutor.doQuery 时也会先刷新待执行批次,所以要同时检查中途查询和提交时机。
批处理依赖驱动能力,执行失败应按事务边界回滚,并核对生成键和错误定位结果。
11.5 MyBatis 插件能拦截哪些组件,为什么只修改 SQL 文本可能出错?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:说明插件能包装哪些接口,以及代理顺序怎样影响 SQL。能解释参数映射和缓存键为何要随 SQL 同步变化。
相关内容:插件扩展点。
参考回答
插件能包装 Executor、StatementHandler、ParameterHandler 和 ResultSetHandler 的指定方法,改写 SQL 时还要同步参数与缓存信息。
新增占位符后若没有同步参数映射,驱动就会绑定错参数。若查询条件改变却仍使用旧缓存键,也会命中不对应的结果。多个插件还会按代理嵌套顺序执行,所以应通过集成测试检查最终 BoundSql、参数和缓存命中情况。
11.6 PageHelper 怎样让 Mapper 查询变成数据库分页?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:解释 Executor 拦截怎样把分页上下文转换为方言 SQL,并同步参数映射与缓存键。
相关内容:PageHelper 分页 SQL 改写。
参考回答
PageHelper 拦截 Executor.query,按数据库方言生成分页 SQL,并同步参数映射和缓存键。
startPage 把页码与页大小放入线程上下文。PageInterceptor 读取后,按配置执行 count,再生成新的 BoundSql 和分页 CacheKey。MySQL 的 startPage(2,10) 对应 offset=10、pageSize=10 的 LIMIT,后续使用 RowBounds.DEFAULT,由 SQL 完成分页。
拦截器在 finally 清理上下文;若分支跳过查询或进入拦截器前就发生异常,需要自行调用 clearPage()。
11.7 MyBatis 延迟加载怎样实现,哪些调用会触发关联 SQL?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:说明结果代理如何保存并执行 ResultLoader,以及哪些方法调用会提前触发关联查询。
相关内容:关联属性的延迟加载。
参考回答
MyBatis 用结果代理保存关联查询,访问未加载属性时触发 ResultLoader,再把结果写回属性。
- 按属性触发需要关闭激进加载。
aggressiveLazyLoading=false时按属性加载,但默认的equals、clone、hashCode、toString会触发全部待加载属性;设为 true 后,普通方法也会触发全部加载。 - 日志和序列化也可能发出 SQL。 它们可能调用这些方法;列表逐项访问关联仍会产生 N+1。
- 事务结束后加载可能进入新事务。 原 Executor 关闭或线程切换后,ResultLoader 会创建新 Executor。需要原事务内一致性时,应提前加载。
可通过 lazyLoadingEnabled=true 或关联的 fetchType="lazy" 启用这套机制。
12. 参考资料
- MyBatis:Mapper XML Files
- MyBatis:Java API
- MyBatis:Configuration
- MyBatis-Spring:SqlSessionTemplate
- MyBatis-Spring:Transactions
- MyBatis:Dynamic SQL
- PageHelper 6.1.1:PageInterceptor
- PageHelper 6.1.1:ExecutorUtil
- PageHelper 6.1.1:MySqlDialect
- PageHelper:使用方法
- MyBatis 3.5.19:JavassistProxyFactory
- MyBatis 3.5.19:ResultLoader