一、MyBatis 知识框架图
MyBatis
核心定位
半自动 ORM
SQL 与 Java 对象映射
灵活控制 SQL
适合复杂查询和高性能场景
基础使用
全局配置 mybatis-config.xml
Mapper XML
Mapper 接口
SqlSession
SqlSessionFactory
参数映射
结果映射
核心组件
Configuration
Executor
SimpleExecutor
ReuseExecutor
BatchExecutor
StatementHandler
ParameterHandler
ResultSetHandler
TypeHandler
MappedStatement
BoundSql
SQL 映射
select
insert
update
delete
resultMap
sql 片段
include
selectKey
动态 SQL
if
choose_when_otherwise
trim_where_set
foreach
bind
script
高级映射
一对一 association
一对多 collection
嵌套查询
嵌套结果
延迟加载
鉴别器 discriminator
缓存机制
一级缓存
SqlSession 级别
默认开启
二级缓存
Mapper namespace 级别
需显式开启
第三方缓存
Redis
Ehcache
插件机制
拦截 Executor
拦截 StatementHandler
拦截 ParameterHandler
拦截 ResultSetHandler
分页插件
审计插件
SQL 改写
Spring 集成
SqlSessionTemplate
MapperScannerConfigurer
@MapperScan
事务托管
MyBatis-Spring-Boot-Starter
性能优化
SQL 优化
索引优化
批处理
分页优化
缓存优化
连接池优化
N+1 查询治理
日志与监控
源码与原理
Mapper 代理
XML 解析
SQL 执行流程
参数绑定
结果集映射
插件责任链
缓存装饰器
二、MyBatis 核心执行流程
应用调用 Mapper 接口方法
MapperProxy 动态代理拦截
根据接口方法定位 MappedStatement
SqlSession 调用 Executor
创建 StatementHandler
ParameterHandler 设置 SQL 参数
JDBC 执行 SQL
ResultSetHandler 处理结果集
TypeHandler 完成 JDBC 类型与 Java 类型转换
返回 Java 对象或集合
完整链路可以概括为:
- Mapper 接口方法被 JDK 动态代理拦截。
- MyBatis 根据
namespace + methodName找到对应的MappedStatement。 SqlSession委托Executor执行查询或更新。StatementHandler生成并预编译 SQL。ParameterHandler绑定参数。- JDBC 访问数据库。
ResultSetHandler将结果集映射为 Java 对象。- 一级缓存、二级缓存按规则参与读写。
三、MyBatis 重点知识体系
1. MyBatis 是什么
MyBatis 是一个半自动 ORM 框架。它不像 Hibernate 那样试图自动生成大多数 SQL,而是让开发者自己编写 SQL,并负责把 SQL 参数和查询结果映射到 Java 对象。
它的核心价值是:
- SQL 可控,适合复杂业务查询。
- 映射能力强,可以处理对象、集合、嵌套对象。
- 与 Spring 集成成熟。
- 比纯 JDBC 少大量模板代码。
- 比全自动 ORM 更容易进行 SQL 调优。
2. MyBatis 与 JDBC 的关系
MyBatis 底层仍然基于 JDBC。它封装了 JDBC 中重复且容易出错的部分,例如:
- 获取连接。
- 创建
PreparedStatement。 - 参数绑定。
- 执行 SQL。
- 遍历
ResultSet。 - 类型转换。
- 关闭资源。
但 MyBatis 不会消灭 SQL。它把 SQL 的控制权留给开发者,这是它区别于很多 ORM 框架的关键。
3. 重要对象
| 对象 | 作用 |
|---|---|
SqlSessionFactory |
创建 SqlSession 的工厂,通常全局单例 |
SqlSession |
执行 SQL 的会话对象,非线程安全 |
MapperProxy |
Mapper 接口的动态代理对象 |
Configuration |
MyBatis 全局配置中心 |
MappedStatement |
一条 SQL 映射语句的完整描述 |
BoundSql |
最终解析出来的 SQL 和参数信息 |
Executor |
SQL 执行器,负责缓存和数据库访问 |
StatementHandler |
负责创建和处理 JDBC Statement |
ParameterHandler |
负责参数绑定 |
ResultSetHandler |
负责结果集映射 |
TypeHandler |
负责 Java 类型与 JDBC 类型转换 |
4. #{} 与 ${} 的区别
#{} 使用 PreparedStatement 参数占位符,会进行预编译参数绑定,可以防止 SQL 注入。
${} 是字符串替换,会直接把参数拼接到 SQL 中,存在 SQL 注入风险。
推荐原则:
- 普通参数值使用
#{}。 - 表名、字段名、排序方向等无法使用预编译参数的位置,才考虑
${}。 - 使用
${}时必须做白名单校验。
示例:
xml
<!-- 推荐 -->
SELECT * FROM user WHERE id = #{id}
<!-- 有风险,必须做白名单控制 -->
SELECT * FROM user ORDER BY ${orderBy}
四、MyBatis 性能优化体系
1. SQL 层优化
SQL 优化是 MyBatis 性能优化的第一优先级,因为 MyBatis 最终还是执行 SQL。
常见策略:
- 只查询需要的字段,避免
SELECT *。 - 为高频查询条件建立合适索引。
- 避免在索引列上使用函数或隐式类型转换。
- 避免大表深分页。
- 避免返回过大的结果集。
- 使用
EXPLAIN分析执行计划。 - 避免在循环中频繁查询数据库。
示例:
sql
-- 不推荐
SELECT * FROM orders WHERE DATE(create_time) = '2026-05-05';
-- 推荐
SELECT id, user_id, amount, status
FROM orders
WHERE create_time >= '2026-05-05 00:00:00'
AND create_time < '2026-05-06 00:00:00';
原因是 DATE(create_time) 会让索引列参与函数计算,可能导致索引失效。
2. 避免 N+1 查询
N+1 查询指先查询 1 次主表,再对每条主表记录额外查询一次关联数据。
例如:
text
查询 100 个订单:1 次
每个订单再查用户信息:100 次
总计:101 次 SQL
优化方式:
- 使用 JOIN 一次查询。
- 先批量查询主表,再用
IN批量查询关联表。 - 使用嵌套结果映射代替嵌套查询。
- 谨慎使用延迟加载,防止遍历对象时触发大量 SQL。
推荐批量查询:
xml
<select id="selectUsersByIds" resultType="User">
SELECT id, name
FROM user
WHERE id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</select>
3. 批量写入优化
大量插入或更新时,不要一条一条提交。
可选方案:
- 使用 JDBC 批处理,也就是
ExecutorType.BATCH。 - 使用 MySQL 多值插入。
- 分批提交,例如每 500 或 1000 条提交一次。
- 控制单条 SQL 大小,避免超过数据库包大小限制。
示例:
java
try (SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH)) {
UserMapper mapper = sqlSession.getMapper(UserMapper.class);
for (int i = 0; i < users.size(); i++) {
mapper.insert(users.get(i));
if (i % 1000 == 0) {
sqlSession.flushStatements();
}
}
sqlSession.commit();
}
在 Spring 中一般不直接手动打开 SqlSession,而是结合事务和批处理能力设计专门的批量写入方法。
4. 分页优化
普通分页:
sql
SELECT id, title
FROM article
ORDER BY id DESC
LIMIT 100000, 20;
当偏移量很大时,数据库需要跳过大量记录,性能会下降。
优化方案:
- 使用游标分页,也叫 keyset pagination。
- 使用覆盖索引先查 ID,再回表。
- 限制可跳转页数。
- 对后台导出任务使用异步任务,不走普通分页接口。
游标分页示例:
sql
SELECT id, title
FROM article
WHERE id < #{lastId}
ORDER BY id DESC
LIMIT 20;
5. 缓存优化
MyBatis 有两级缓存。
一级缓存:
- 默认开启。
- 作用域是
SqlSession。 - 同一个
SqlSession中相同查询可以复用缓存。 - 执行增删改、提交、回滚、关闭会清理缓存。
二级缓存:
- 作用域是 Mapper namespace。
- 需要显式开启。
- 要求缓存对象可序列化。
- 数据一致性要求高的场景要谨慎使用。
二级缓存适合:
- 读多写少。
- 数据变化不频繁。
- 对短时间旧数据可容忍。
- 查询结果体积可控。
不适合:
- 强一致性账户余额。
- 库存扣减。
- 高频写入数据。
- 多表复杂关联且更新入口分散的场景。
6. 连接池优化
MyBatis 自身可以配置数据源,但生产中通常交给 Spring Boot 和 HikariCP 管理。
重点参数:
- 最大连接数。
- 最小空闲连接。
- 连接超时时间。
- 空闲连接存活时间。
- 慢 SQL 日志。
- 泄漏检测。
连接池不是越大越好。连接数过大可能导致数据库上下文切换变多,吞吐反而下降。一般要结合数据库最大连接数、应用实例数、请求并发量和 SQL 平均耗时综合评估。
7. ResultMap 优化
复杂 resultMap 会增加映射成本,尤其是嵌套对象和集合映射。
优化建议:
- 简单查询使用
resultType。 - 复杂对象使用
resultMap。 - 一对多映射注意去重成本。
- 大结果集避免一次性映射成复杂对象树。
- 报表或列表页可以使用轻量 DTO。
8. 日志与监控
优化不能只靠猜,应通过观测定位。
建议关注:
- SQL 执行次数。
- 单条 SQL 耗时。
- 慢 SQL 分布。
- 数据库连接池等待时间。
- 返回行数。
- 是否出现 N+1 查询。
- 缓存命中率。
- 事务持有连接的时间。
常用工具:
- 数据库慢查询日志。
EXPLAIN。- p6spy。
- Arthas。
- APM,例如 SkyWalking、Pinpoint、Prometheus + Grafana。
五、MyBatis 入门级面试题
1. MyBatis 是什么?它解决了什么问题?
答案:
MyBatis 是一个基于 Java 的持久层框架,属于半自动 ORM。它封装了 JDBC 的重复代码,例如连接管理、参数设置、结果集遍历和对象映射,同时保留开发者手写 SQL 的能力。
它主要解决:
- JDBC 代码冗余。
- 手动参数绑定繁琐。
- 手动封装结果对象麻烦。
- SQL 与 Java 代码混杂。
- 数据库访问层难维护。
相比全自动 ORM,MyBatis 的优势是 SQL 更可控,适合复杂查询和性能敏感场景。
2. MyBatis 和 Hibernate 有什么区别?
答案:
MyBatis 是半自动 ORM,需要开发者编写 SQL;Hibernate 是全自动 ORM,更多通过对象关系映射自动生成 SQL。
主要区别:
| 对比项 | MyBatis | Hibernate |
|---|---|---|
| SQL 控制 | 手写 SQL,控制强 | 自动生成 SQL 为主 |
| 学习成本 | 相对低 | 相对高 |
| 复杂查询 | 更灵活 | 复杂查询可能不直观 |
| 数据库迁移 | SQL 与数据库耦合较强 | 数据库无关性更强 |
| 性能调优 | 直接调 SQL | 需要理解 ORM 生成 SQL |
| 适用场景 | 复杂业务、报表、高性能查询 | 标准 CRUD、领域模型驱动 |
面试中可以这样总结:MyBatis 更像增强版 JDBC,Hibernate 更像完整 ORM。MyBatis 牺牲了一部分自动化,换来了 SQL 可控性。
3. SqlSessionFactory 和 SqlSession 有什么区别?
答案:
SqlSessionFactory 是创建 SqlSession 的工厂,通常在应用启动时创建一次,全局单例使用。它是线程安全的。
SqlSession 是执行 SQL 的会话对象,包含数据库连接、事务上下文和一级缓存。它不是线程安全的,不能作为单例共享。每次请求或事务通常使用独立的 SqlSession。
在 Spring 集成中,开发者通常不直接操作 SqlSession,而是通过 Mapper 接口调用,底层由 SqlSessionTemplate 管理线程安全和事务。
4. Mapper 接口为什么不用写实现类?
答案:
因为 MyBatis 使用 JDK 动态代理为 Mapper 接口生成代理对象。
调用 Mapper 接口方法时,实际会进入 MapperProxy。MapperProxy 根据接口全限定名和方法名拼出 statement id,例如:
text
com.example.UserMapper.selectById
然后去 Configuration 中查找对应的 MappedStatement,最终交给 SqlSession 执行 SQL。
所以 Mapper 接口方法必须能和 XML 中的 namespace + id 对应起来。
5. #{} 和 ${} 的区别是什么?
答案:
#{} 是预编译参数占位符,底层会被转换成 ?,然后由 PreparedStatement 设置参数。它可以防止 SQL 注入。
${} 是字符串替换,会把参数直接拼接进 SQL。它不能防止 SQL 注入。
示例:
xml
SELECT * FROM user WHERE name = #{name}
最终类似:
sql
SELECT * FROM user WHERE name = ?
而:
xml
SELECT * FROM user ORDER BY ${orderBy}
会直接拼接字段名。
结论:
- 查询条件值使用
#{}。 - 表名、字段名、排序字段这类无法预编译的位置才使用
${}。 - 使用
${}必须做白名单校验。
6. MyBatis 如何传递多个参数?
答案:
常见方式有四种:
- 使用
@Param注解。 - 使用 JavaBean。
- 使用
Map。 - 使用 DTO 或 Query 对象。
推荐使用 @Param 或专门的 DTO。
示例:
java
User selectByNameAndStatus(@Param("name") String name, @Param("status") Integer status);
XML:
xml
<select id="selectByNameAndStatus" resultType="User">
SELECT *
FROM user
WHERE name = #{name}
AND status = #{status}
</select>
如果不使用 @Param,MyBatis 会使用 param1、param2 或 arg0、arg1 作为默认参数名,可读性较差。
7. resultType 和 resultMap 有什么区别?
答案:
resultType 用于简单映射,适合数据库字段名和 Java 属性名一致,或者开启驼峰映射后能自动匹配的场景。
resultMap 用于复杂映射,例如:
- 字段名和属性名差异较大。
- 一对一映射。
- 一对多映射。
- 嵌套对象。
- 继承或鉴别器映射。
示例:
xml
<resultMap id="UserMap" type="User">
<id property="id" column="id"/>
<result property="userName" column="user_name"/>
</resultMap>
简单场景用 resultType,复杂场景用 resultMap。
8. MyBatis 支持哪些动态 SQL 标签?
答案:
常用动态 SQL 标签包括:
if:条件判断。choose、when、otherwise:类似 switch。where:自动处理 WHERE 和多余 AND/OR。set:用于动态更新,自动处理逗号。trim:自定义前缀、后缀、去除规则。foreach:遍历集合,常用于IN和批量插入。bind:绑定变量。
示例:
xml
<select id="selectByCondition" resultType="User">
SELECT *
FROM user
<where>
<if test="name != null and name != ''">
AND name = #{name}
</if>
<if test="status != null">
AND status = #{status}
</if>
</where>
</select>
六、MyBatis 进阶级面试题
1. MyBatis 的一级缓存是什么?
答案:
一级缓存是 SqlSession 级别的缓存,默认开启,不能关闭。
在同一个 SqlSession 中,如果执行相同的 SQL、相同参数、相同分页和相同环境,MyBatis 会优先从一级缓存中取结果。
一级缓存失效场景:
- 不同
SqlSession。 - 执行了 insert、update、delete。
- 调用了
commit或rollback。 - 手动调用
clearCache()。 - 查询语句配置了
flushCache=true。
一级缓存的主要作用是减少同一会话内重复查询,但在 Spring 中通常一个事务对应一个会话,所以它的作用范围不会跨请求。
2. MyBatis 的二级缓存是什么?
答案:
二级缓存是 Mapper namespace 级别的缓存,需要显式开启。
开启方式:
xml
<cache/>
二级缓存的特点:
- 多个
SqlSession可以共享。 - 作用域是同一个 Mapper namespace。
- 查询结果在
SqlSession提交或关闭后才会进入二级缓存。 - 执行增删改默认会刷新缓存。
- 缓存对象通常需要可序列化。
二级缓存适合读多写少的场景。对于写频繁、强一致性要求高、多表关联复杂的业务,不建议依赖 MyBatis 二级缓存。
3. 为什么 MyBatis 二级缓存可能导致脏数据?
答案:
因为二级缓存默认按 Mapper namespace 隔离。如果一个查询涉及多张表,但更新操作发生在另一个 Mapper 中,当前 Mapper 的缓存可能不会被清理,从而返回旧数据。
示例:
OrderMapper中有查询订单和用户信息的关联 SQL。UserMapper更新了用户名称。OrderMapper的二级缓存没有被刷新。- 再次查询订单详情时,可能拿到旧的用户名称。
解决思路:
- 对复杂多表查询谨慎使用二级缓存。
- 使用统一 Mapper 管理相关查询和更新。
- 使用 Redis 等外部缓存并显式设计失效策略。
- 对强一致性数据不使用二级缓存。
4. MyBatis 的延迟加载是什么?如何触发?
答案:
延迟加载是指查询主对象时,关联对象暂时不查询,等真正访问关联属性时再执行 SQL。
它常用于一对一、一对多关联查询。
配置示例:
xml
<settings>
<setting name="lazyLoadingEnabled" value="true"/>
<setting name="aggressiveLazyLoading" value="false"/>
</settings>
触发方式:
java
Order order = orderMapper.selectById(1L);
User user = order.getUser(); // 访问 user 属性时触发关联查询
优点是避免不必要查询。缺点是如果在循环中访问关联属性,可能导致 N+1 查询。
5. MyBatis 如何实现一对多映射?
答案:
可以使用 collection 标签。
示例:
xml
<resultMap id="OrderMap" type="Order">
<id property="id" column="order_id"/>
<result property="amount" column="amount"/>
<collection property="items" ofType="OrderItem">
<id property="id" column="item_id"/>
<result property="productName" column="product_name"/>
</collection>
</resultMap>
SQL:
sql
SELECT
o.id AS order_id,
o.amount,
i.id AS item_id,
i.product_name
FROM orders o
LEFT JOIN order_item i ON o.id = i.order_id
WHERE o.id = #{id}
MyBatis 会根据 <id> 标签识别主对象是否已经存在,从而把多行结果合并到同一个订单对象的 items 集合中。
6. association 和 collection 的区别是什么?
答案:
association 用于一对一或多对一映射,对应单个对象属性。
collection 用于一对多映射,对应集合属性。
示例:
xml
<!-- 一个订单对应一个用户 -->
<association property="user" javaType="User"/>
<!-- 一个订单对应多个明细 -->
<collection property="items" ofType="OrderItem"/>
一句话总结:对象用 association,集合用 collection。
7. MyBatis 插件机制能拦截哪些对象?
答案:
MyBatis 插件可以拦截四类核心对象:
Executor:拦截查询、更新、提交、回滚等。StatementHandler:拦截 SQL 预编译、参数设置等。ParameterHandler:拦截参数绑定。ResultSetHandler:拦截结果集处理。
常见用途:
- 分页插件。
- SQL 日志。
- 数据权限。
- 多租户。
- 字段加解密。
- 自动填充审计字段。
- SQL 性能监控。
插件底层使用责任链和动态代理实现。
8. MyBatis 分页插件的基本原理是什么?
答案:
分页插件通常拦截 StatementHandler 或 Executor,在 SQL 执行前改写 SQL。
例如原始 SQL:
sql
SELECT * FROM user ORDER BY id DESC
MySQL 分页改写后:
sql
SELECT * FROM user ORDER BY id DESC LIMIT ?, ?
同时可能额外执行一条 count SQL:
sql
SELECT COUNT(*) FROM user
核心步骤:
- 拦截 SQL 执行。
- 获取原始 SQL 和分页参数。
- 根据数据库方言改写 SQL。
- 重新设置
BoundSql。 - 执行分页查询。
- 可选执行 count 查询。
9. ExecutorType.SIMPLE、REUSE、BATCH 有什么区别?
答案:
| 类型 | 特点 | 适用场景 |
|---|---|---|
SIMPLE |
每次执行都创建新的 Statement | 默认通用场景 |
REUSE |
复用相同 SQL 的 Statement | 高频重复 SQL |
BATCH |
批量执行更新语句 | 批量插入、批量更新 |
SIMPLE 是默认执行器。
REUSE 可以减少 Statement 创建成本,但使用场景不如默认执行器普遍。
BATCH 适合大批量写入,但要注意内存占用、事务大小和错误定位成本。
10. MyBatis 如何防止 SQL 注入?
答案:
主要依赖 #{} 的预编译参数绑定。
防御原则:
- 查询条件值使用
#{}。 - 不直接使用用户输入拼接 SQL。
- 使用
${}时进行白名单校验。 - 排序字段、排序方向、表名等动态部分用枚举控制。
- 模糊查询不要直接拼接字符串。
模糊查询推荐:
xml
<bind name="pattern" value="'%' + keyword + '%'"/>
SELECT *
FROM user
WHERE name LIKE #{pattern}
不要这样写:
xml
WHERE name LIKE '%${keyword}%'
七、MyBatis 高级面试题
1. MyBatis Mapper 方法调用的底层原理是什么?
答案:
Mapper 接口本身没有实现类,MyBatis 通过 JDK 动态代理创建实现对象。
核心流程:
MapperRegistry保存 Mapper 接口与MapperProxyFactory的关系。- 获取 Mapper 时,
MapperProxyFactory创建代理对象。 - 调用接口方法时进入
MapperProxy.invoke()。 - 如果是
Object方法,比如toString,直接执行。 - 否则封装为
MapperMethod。 - 根据接口名和方法名找到
MappedStatement。 - 判断 SQL 类型:select、insert、update、delete。
- 调用
SqlSession对应方法。 - 最终由
Executor执行 SQL。
这也是为什么 Mapper XML 的 namespace 必须与 Mapper 接口全限定名一致,SQL 标签的 id 必须与方法名对应。
2. MyBatis 插件为什么只能拦截四大对象?
答案:
因为 MyBatis 在创建这四类对象时,会通过 InterceptorChain.pluginAll() 进行代理包装。
这四类对象是:
ExecutorStatementHandlerParameterHandlerResultSetHandler
只有经过插件链包装的对象,才能被插件拦截。普通内部类或任意对象没有经过该代理流程,所以不能直接拦截。
插件实现通常包括:
java
@Intercepts({
@Signature(
type = Executor.class,
method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}
)
})
public class MyPlugin implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
return invocation.proceed();
}
}
3. MyBatis 的缓存 key 是如何生成的?
答案:
MyBatis 会通过 CacheKey 标识一次查询。通常包含:
MappedStatement的 id。- 分页参数
offset。 - 分页参数
limit。 - 最终 SQL。
- SQL 参数值。
- 环境 id。
只有这些信息都一致,才会命中同一个缓存。
这也解释了为什么同一个 Mapper 方法,如果参数不同或动态 SQL 生成的最终 SQL 不同,就不会命中相同缓存。
4. MyBatis 一级缓存为什么会出现脏读?如何解决?
答案:
一级缓存是 SqlSession 级别的缓存。如果同一个 SqlSession 中先查询了某条数据,之后数据库被其他事务修改,而当前 SqlSession 再次查询相同 SQL,可能会直接返回一级缓存中的旧数据。
解决方法:
- 缩短
SqlSession生命周期。 - 在关键查询前调用
clearCache()。 - 更新后及时提交或回滚。
- 将
localCacheScope设置为STATEMENT,让一级缓存只在语句执行期间有效。
配置示例:
xml
<setting name="localCacheScope" value="STATEMENT"/>
但设置为 STATEMENT 后,一级缓存复用能力会下降。
5. localCacheScope=SESSION 和 STATEMENT 有什么区别?
答案:
SESSION 是默认值,表示一级缓存作用于整个 SqlSession。同一个会话内的相同查询可以复用缓存。
STATEMENT 表示一级缓存只作用于一次语句执行过程。语句执行完后缓存基本不再复用。
区别:
| 配置 | 作用范围 | 优点 | 缺点 |
|---|---|---|---|
SESSION |
整个 SqlSession | 减少重复查询 | 可能读取到旧数据 |
STATEMENT |
单条语句 | 数据更新感知更及时 | 缓存收益更小 |
强一致性要求更高时,可以考虑 STATEMENT。
6. MyBatis 是如何解析动态 SQL 的?
答案:
MyBatis 在解析 XML 时,会把动态 SQL 节点解析成一组 SqlNode。
常见节点包括:
StaticTextSqlNodeTextSqlNodeIfSqlNodeChooseSqlNodeForEachSqlNodeWhereSqlNodeSetSqlNodeTrimSqlNodeMixedSqlNode
执行时,MyBatis 会根据参数对象和 OGNL 表达式动态拼接 SQL,最终生成 BoundSql。
大致流程:
- XML 被解析为
SqlSource。 - 动态 SQL 使用
DynamicSqlSource。 - 执行时遍历
SqlNode。 - 根据参数计算条件。
- 拼接最终 SQL。
- 生成
BoundSql。 - 交给后续执行器处理。
7. MyBatis 中 TypeHandler 的作用是什么?
答案:
TypeHandler 负责 Java 类型和 JDBC 类型之间的转换。
例如:
- Java
String与 JDBCVARCHAR。 - Java
Integer与 JDBCINTEGER。 - Java
LocalDateTime与 JDBCTIMESTAMP。 - Java 枚举与数据库字段。
自定义枚举转换示例:
java
public class StatusTypeHandler extends BaseTypeHandler<Status> {
@Override
public void setNonNullParameter(
PreparedStatement ps,
int i,
Status parameter,
JdbcType jdbcType
) throws SQLException {
ps.setInt(i, parameter.getCode());
}
@Override
public Status getNullableResult(ResultSet rs, String columnName) throws SQLException {
return Status.fromCode(rs.getInt(columnName));
}
@Override
public Status getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
return Status.fromCode(rs.getInt(columnIndex));
}
@Override
public Status getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
return Status.fromCode(cs.getInt(columnIndex));
}
}
适用场景:
- 枚举映射。
- JSON 字段映射。
- 加密字段解密。
- 特殊时间类型转换。
- 数据库自定义类型。
8. MyBatis 如何处理驼峰命名?
答案:
可以通过 mapUnderscoreToCamelCase 开启下划线到驼峰的自动映射。
配置:
xml
<setting name="mapUnderscoreToCamelCase" value="true"/>
效果:
text
user_name -> userName
create_time -> createTime
注意:
- 它主要影响结果集字段到 Java 属性的映射。
- SQL 中仍然要写数据库真实字段名。
- 如果字段别名已经和属性名一致,也可以不依赖该配置。
9. MyBatis 中 RowBounds 是物理分页还是逻辑分页?
答案:
默认情况下,RowBounds 是逻辑分页。
逻辑分页指数据库先返回较多数据,然后 MyBatis 在内存中跳过 offset,再取 limit。大数据量场景性能较差。
生产环境通常使用分页插件,将分页参数改写到 SQL 中,实现物理分页。
例如 MySQL:
sql
SELECT *
FROM user
ORDER BY id DESC
LIMIT ?, ?
所以面试回答重点是:原生 RowBounds 默认不是数据库物理分页,除非插件拦截并改写 SQL。
10. MyBatis 的 selectOne 如果查到多条记录会怎样?
答案:
selectOne 期望返回 0 或 1 条记录。
如果查到 0 条,返回 null。
如果查到 1 条,返回该对象。
如果查到多条,会抛出 TooManyResultsException。
因此,使用 selectOne 的 SQL 必须保证业务唯一性,例如通过唯一索引、主键或明确的 LIMIT 1 控制。
八、MyBatis 专家级面试题
1. 请描述 MyBatis 从 XML 配置加载到执行 SQL 的完整源码主线。
答案:
完整主线可以分为启动解析阶段和运行执行阶段。
启动解析阶段:
SqlSessionFactoryBuilder读取配置文件。XMLConfigBuilder解析mybatis-config.xml。- 构建
Configuration对象。 - 解析 settings、typeAliases、plugins、environments、mappers 等配置。
XMLMapperBuilder解析 Mapper XML。- 每个 SQL 标签被解析为一个
MappedStatement。 - 动态 SQL 被解析为
SqlNode和SqlSource。 MappedStatement注册到Configuration.mappedStatements。
运行执行阶段:
- 应用调用 Mapper 接口方法。
MapperProxy拦截调用。MapperMethod根据方法签名判断执行类型和返回类型。- 通过
SqlSession调用 select、insert、update 或 delete。 Executor处理一级缓存、二级缓存和数据库访问。StatementHandler准备 SQL。ParameterHandler绑定参数。- JDBC 执行 SQL。
ResultSetHandler映射结果。- 返回 Java 对象。
这条链路中的核心对象是 Configuration -> MappedStatement -> SqlSource -> BoundSql -> Executor -> StatementHandler -> ParameterHandler -> ResultSetHandler。
2. MyBatis 为什么使用装饰器实现缓存?
答案:
MyBatis 缓存体系使用了装饰器模式,基础缓存通常是 PerpetualCache,外层可以包装多个增强能力。
常见装饰器包括:
LruCache:最近最少使用淘汰。FifoCache:先进先出淘汰。SoftCache:软引用缓存。WeakCache:弱引用缓存。ScheduledCache:定时清理。SerializedCache:序列化缓存。LoggingCache:日志统计。SynchronizedCache:同步控制。TransactionalCache:事务缓存。
使用装饰器的好处:
- 缓存能力可以灵活组合。
- 避免一个缓存类承担过多职责。
- 符合开闭原则。
- 便于扩展不同缓存策略。
3. TransactionalCache 的作用是什么?
答案:
TransactionalCache 用于让二级缓存和事务提交保持一致。
查询结果不会立刻写入二级缓存,而是先暂存在事务缓存中。只有当 SqlSession 提交时,缓存内容才会真正写入二级缓存。如果事务回滚,则丢弃暂存数据。
这样可以避免未提交事务的数据污染二级缓存。
核心行为:
- 查询结果先放入临时缓存。
commit时刷新到真正二级缓存。rollback时清理临时缓存。- 更新操作会标记需要清空二级缓存。
4. MyBatis 插件链的执行顺序是怎样的?
答案:
MyBatis 会按插件注册顺序依次包装目标对象,形成多层代理。
如果配置顺序为:
xml
<plugins>
<plugin interceptor="PluginA"/>
<plugin interceptor="PluginB"/>
</plugins>
目标对象可能被包装为:
text
PluginB代理(PluginA代理(原始对象))
调用时通常外层插件先进入,所以后注册的插件可能先执行拦截逻辑。
因此多个插件同时改写 SQL、分页、加租户条件、加数据权限时,要特别注意顺序,否则可能出现 SQL 被重复包裹、count SQL 错误、参数映射不一致等问题。
5. 如何设计一个 MyBatis 数据权限插件?
答案:
常见设计是拦截 StatementHandler.prepare 或 Executor.query,在 SQL 执行前追加数据权限条件。
设计要点:
- 从当前登录上下文获取用户、角色、部门、租户等信息。
- 判断当前 SQL 是否需要加权限条件。
- 解析 SQL,而不是简单字符串拼接。
- 对 SELECT 添加权限 WHERE 条件。
- 对 UPDATE、DELETE 也要考虑权限约束。
- 保持参数映射与新增参数一致。
- 支持白名单或注解跳过。
- 对复杂 SQL、子查询、UNION 做兼容测试。
推荐使用 SQL 解析库,例如 JSqlParser,而不是手写字符串拼接。
风险点:
- SQL 改写导致语法错误。
- count SQL 与分页 SQL 不一致。
- 多插件顺序冲突。
- 权限条件漏加导致越权。
- 参数绑定错误。
- SQL 过于复杂导致解析失败。
6. 如何定位 MyBatis 查询很慢的问题?
答案:
定位顺序:
- 确认慢的是接口、MyBatis 映射、数据库 SQL,还是网络和连接池等待。
- 打印最终 SQL 和参数。
- 在数据库中用真实参数执行 SQL。
- 使用
EXPLAIN查看执行计划。 - 检查索引是否命中。
- 检查返回行数是否过大。
- 检查是否存在 N+1 查询。
- 检查是否有复杂 resultMap 映射。
- 检查连接池是否耗尽。
- 检查事务是否过长。
常见原因:
- 缺少索引。
- 索引失效。
- 深分页。
- 返回大字段或大结果集。
- 嵌套查询触发 N+1。
- 锁等待。
- 数据库连接池等待。
- 应用层映射对象过重。
- 日志打印过多。
优秀回答要强调:不要只盯着 MyBatis,最终瓶颈往往在 SQL、索引、连接池和数据量。
7. MyBatis 中如何处理大数据量查询?
答案:
大数据量查询要避免一次性把所有数据加载到内存。
可选方案:
- 分页查询。
- 游标查询。
- 流式处理。
- 限制返回字段。
- 异步导出。
- 使用只读事务。
- 控制 fetch size。
MyBatis 支持 Cursor:
java
Cursor<User> scanUsers();
使用时要注意:
- Cursor 必须在
SqlSession打开期间消费。 - Spring 事务不能提前结束。
- 不要把 Cursor 结果再全部收集到 List,否则失去意义。
- 数据库驱动可能需要额外 fetch size 配置。
适合导出、批处理、离线任务等场景。
8. MyBatis 如何实现多租户?
答案:
常见方案有三种:
- 独立数据库:每个租户一个库。
- 独立 schema:每个租户一个 schema。
- 共享表:表中增加
tenant_id。
MyBatis 中常见实现:
- 通过插件自动给 SQL 添加
tenant_id条件。 - 通过动态数据源路由到不同数据库。
- 通过 Mapper XML 显式编写租户条件。
- 结合拦截器和注解控制是否忽略租户。
共享表插件方案要重点解决:
- SELECT 自动加
tenant_id。 - INSERT 自动填充
tenant_id。 - UPDATE 和 DELETE 必须带
tenant_id条件。 - 防止用户传入任意租户 ID。
- 管理员跨租户查询要走受控白名单。
9. 如何在 MyBatis 中实现乐观锁?
答案:
乐观锁通常通过版本号字段实现。
表结构:
sql
version INT NOT NULL DEFAULT 0
更新 SQL:
xml
<update id="updateWithVersion">
UPDATE product
SET stock = #{stock},
version = version + 1
WHERE id = #{id}
AND version = #{version}
</update>
如果返回更新行数为 1,说明更新成功。
如果返回 0,说明版本已变化,发生并发冲突,需要重试或提示失败。
乐观锁适合读多写少、冲突概率不高的场景。如果冲突非常频繁,重试成本可能很高,需要考虑悲观锁、队列化或库存专用扣减方案。
10. MyBatis 项目中 Mapper XML 很多,如何治理?
答案:
治理重点是规范、复用、测试和可观测。
建议:
- 按业务边界拆分 Mapper。
- XML namespace 与接口全限定名一致。
- SQL id 使用清晰命名。
- 公共字段用
<sql>片段复用。 - 避免超长动态 SQL。
- 复杂查询沉淀为专门 DTO。
- 对高风险 SQL 添加集成测试。
- 引入 SQL lint 或代码评审规则。
- 建立慢 SQL 监控。
- 对大 XML 文件按领域拆分。
不要为了复用把所有 SQL 塞进一个通用 Mapper。过度通用会让 SQL 难读、难调试、难做权限控制。
九、性能优化面试专题题
1. 一个接口调用了 200 次 SQL,你会怎么优化?
答案:
先判断这 200 次 SQL 是否属于 N+1 查询。
排查步骤:
- 打开 SQL 日志或链路追踪。
- 统计 SQL 类型、次数和参数差异。
- 如果是循环中查询关联数据,改为批量查询。
- 如果是懒加载触发,考虑关闭该场景懒加载或改成 JOIN。
- 如果是权限、字典、配置类数据重复查,考虑缓存。
- 如果业务确实需要多次查询,评估是否可以聚合接口或预加载。
优化方案:
- 一次 JOIN 查询。
- 两阶段批量查询。
- 本地缓存或 Redis 缓存。
- 调整 resultMap,避免嵌套查询。
- 将循环内查询移到循环外。
优秀答案要强调:先观测,再判断是否 N+1,最后按数据一致性和结果规模选择 JOIN 或批量查询。
2. MyBatis 批量插入很慢怎么办?
答案:
优化方向:
- 使用批处理执行器
ExecutorType.BATCH。 - 使用多值 insert。
- 每 500 到 1000 条分批提交。
- 关闭不必要的 SQL 日志。
- 减少索引数量或延后创建索引,适合离线导入场景。
- 检查数据库事务日志和锁等待。
- 控制单事务大小。
多值 insert 示例:
xml
<insert id="batchInsert">
INSERT INTO user(name, age)
VALUES
<foreach collection="list" item="item" separator=",">
(#{item.name}, #{item.age})
</foreach>
</insert>
注意:
- 单条 SQL 不要过大。
- 数据库有最大包大小限制。
- 批量失败时错误定位更复杂。
- 需要结合业务决定是否允许部分失败。
3. 深分页为什么慢?如何优化?
答案:
深分页慢是因为数据库需要扫描并跳过大量记录。
例如:
sql
LIMIT 1000000, 20
数据库可能要先找到前 1000020 条,再丢弃前 1000000 条。
优化方式:
- 使用游标分页。
- 使用覆盖索引查询 ID,再回表。
- 限制最大页码。
- 搜索类场景使用搜索引擎。
- 导出类场景走异步任务。
覆盖索引优化示例:
sql
SELECT a.*
FROM article a
JOIN (
SELECT id
FROM article
ORDER BY id DESC
LIMIT 1000000, 20
) t ON a.id = t.id;
更推荐游标分页:
sql
SELECT id, title
FROM article
WHERE id < #{lastId}
ORDER BY id DESC
LIMIT 20;
4. resultMap 很复杂会影响性能吗?
答案:
会。
复杂 resultMap,尤其是一对多嵌套结果,会带来对象创建、去重、集合合并和属性填充成本。如果结果集很大,映射成本会明显上升。
优化建议:
- 列表页使用轻量 DTO。
- 详情页再查询完整对象树。
- 控制返回行数。
- 给嵌套结果正确配置
<id>,帮助 MyBatis 去重。 - 避免无必要的一次性深层级映射。
- 对报表查询直接映射到扁平 DTO。
5. 如何判断 MyBatis 慢是 SQL 慢还是映射慢?
答案:
可以分层计时。
排查方法:
- 从应用日志获取接口耗时和 SQL 耗时。
- 在数据库直接执行最终 SQL,看数据库耗时。
- 对比数据库耗时和接口耗时。
- 如果 SQL 快但接口慢,检查结果集大小、对象映射、序列化和网络传输。
- 如果 SQL 慢,继续分析执行计划、索引和锁等待。
例如:
text
接口耗时:3000ms
数据库 SQL 耗时:200ms
这种情况就不应只优化 SQL,还要看:
- 是否返回了几万行数据。
- 是否复杂 resultMap 映射。
- 是否 JSON 序列化慢。
- 是否网络传输大对象。
- 是否日志打印了完整结果。
十、高频追问速记
| 问题 | 简答 |
|---|---|
| MyBatis 是全自动 ORM 吗 | 不是,是半自动 ORM |
SqlSession 线程安全吗 |
不线程安全 |
| Mapper 接口实现类谁生成 | JDK 动态代理 |
#{} 能防 SQL 注入吗 |
能,使用预编译参数 |
${} 能防 SQL 注入吗 |
不能,是字符串替换 |
| 一级缓存默认开启吗 | 是 |
| 一级缓存作用域 | SqlSession |
| 二级缓存默认开启吗 | 不完全是,需要显式配置使用 |
| 二级缓存作用域 | Mapper namespace |
selectOne 多条结果会怎样 |
抛 TooManyResultsException |
RowBounds 默认是物理分页吗 |
不是,默认逻辑分页 |
| 插件能拦截哪些对象 | Executor、StatementHandler、ParameterHandler、ResultSetHandler |
| 动态 SQL 最终生成什么 | BoundSql |
| 简单映射用什么 | resultType |
| 复杂映射用什么 | resultMap |
| 批处理用什么执行器 | ExecutorType.BATCH |
| 大数据查询推荐一次性 List 吗 | 不推荐 |
| 大数据查询可用什么 | 分页、Cursor、流式处理 |
| 慢查询先看什么 | SQL、执行计划、索引、返回行数 |
| 二级缓存适合什么 | 读多写少、允许短暂旧数据的场景 |
十一、学习路线:入门到专家
入门阶段
目标:会使用 MyBatis 完成基础 CRUD。
需要掌握:
- Mapper 接口。
- Mapper XML。
select、insert、update、delete。#{}参数绑定。resultType。- Spring Boot 集成。
初级进阶
目标:能写可维护的动态 SQL。
需要掌握:
- 动态 SQL 标签。
resultMap。- 驼峰映射。
- 多参数传递。
- 分页查询。
- 批量插入。
中级阶段
目标:能处理复杂业务映射和性能问题。
需要掌握:
- 一对一、一对多映射。
- 延迟加载。
- 一级缓存和二级缓存。
- N+1 查询治理。
- SQL 优化。
- 连接池优化。
- 插件基本原理。
高级阶段
目标:理解 MyBatis 核心原理并能扩展。
需要掌握:
- Mapper 动态代理。
MappedStatement。BoundSql。Executor。StatementHandler。TypeHandler。- 插件机制。
- 缓存装饰器。
专家阶段
目标:能设计复杂持久层架构和排查线上问题。
需要掌握:
- MyBatis 源码主线。
- 多租户插件。
- 数据权限插件。
- 分库分表集成。
- 复杂 SQL 治理。
- 大数据量查询方案。
- 慢 SQL 体系化治理。
- 缓存一致性设计。
- 事务边界设计。
- 线上故障定位。
十二、面试回答模板
遇到 MyBatis 面试题,可以按这个结构回答:
- 先给定义。
- 再说底层原理。
- 说明使用场景。
- 补充风险点。
- 给出优化或最佳实践。
例如问:二级缓存能不能随便用?
优秀回答:
二级缓存不能随便用。它是 Mapper namespace 级别的缓存,适合读多写少、数据变化不频繁、允许短时间旧数据的场景。它的问题是缓存失效粒度较粗,复杂多表查询时,如果更新发生在其他 Mapper,当前 namespace 的缓存可能不会被清理,导致旧数据。因此在强一致性、高频写、多表关联复杂的业务里,不建议依赖 MyBatis 二级缓存,更推荐使用 Redis 并设计明确的 key、过期时间和失效策略。