适用读者:遇过"列表查出来只有一条子记录""二级缓存改库后不对""插件顺序乱"的人。
前置:S08-J05、J02(插件链)、J03(缓存)、B03(动态 SQL)、S11-B03(索引)。
覆盖旧文(章号版):3059(第6章 Mapper 接口)、3061(第8章 结果映射)、3062(第9章 缓存机制)、3063(第10章 插件开发)、3064(第11章 关联映射)、3068(第15章 MyBatis Plus)、3069(第16章 性能优化)、3076(第23章 分页插件)、3077(第24章 Boot 集成)、3078(第25章 性能优化进阶)、3080(第27章 最佳实践)→ 11 章 / 约 9.1 万字符。
扩展点:E-S08-024(关联映射三方式与 N+1 消除)、E-S08-025(缓存失效条件与三类脏读)、E-S08-026(插件责任链顺序与四类拦截点)、E-S08-027(批量三写法与性能口径)。
上一篇:S08-J05 · MyBatis 集成全谱:Spring、Boot、注解与 SQL 构建器
下一篇:补编第 3 篇 · 分布式与微服务下的持久层(待写)。
1. 一句话结论
MyBatis 的性能问题 90% 不在框架,在三处:关联映射写法(N+1)、缓存的错误假设(跨会话/跨实例)、批量用了循环插入。这三处修好,剩下的才是调 SQL。
出处:
A-3064(关联映射)、A-3062(缓存)、A-3063(插件)、A-3069/A-3078(性能)。
2. 关联映射三方式与 N+1 消除(E-S08-024)
| 方式 | 写法 | SQL 条数 | 问题 |
|---|---|---|---|
| 嵌套结果(join) | <collection resultMap=…> + LEFT JOIN |
1 | 需要 id 去重;分页时子行影响总数 |
| 嵌套查询(select) | <collection select=… column=…> |
1 + N | N+1;可配 fetchType="lazy" 缓解 |
| 手工两段 | 先查主表,再 IN (ids) 查子表,代码组装 |
2 | 需自己分组,最可控 |
<!-- ① 嵌套结果:一条 SQL,用 id 列让 MyBatis 合并 -->
<resultMap id="orderMap" type="Order">
<id property="id" column="order_id"/>
<collection property="items" ofType="Item">
<id property="id" column="item_id"/>
<result property="qty" column="qty"/>
</collection>
</resultMap>
<select id="selectOrdersWithItems" resultMap="orderMap">
SELECT o.id order_id, i.id item_id, i.qty
FROM orders o LEFT JOIN order_item i ON i.order_id = o.id
WHERE o.user_id = #{uid}
</select>
判据:分页 + join 集合是陷阱——LIMIT 10 会按"行"截断,导致主记录数不足。正确做法:先分页查主表 id,再用 IN 查子表(手工两段),或在子查询里先分页。
-- 两段式分页(推荐)
SELECT id FROM orders WHERE user_id = ? ORDER BY id DESC LIMIT 10;
SELECT * FROM order_item WHERE order_id IN (?,?,…);
3. 缓存:一级/二级的失效条件与三类脏读(E-S08-025)
| 缓存 | 作用域 | 默认 | 何时清 |
|---|---|---|---|
| 一级(localCache) | 同一 SqlSession |
开启,无法关 | 执行 update/insert/delete、commit、close |
二级(<cache/>) |
同一 namespace(跨 Session) | 关闭 | 该 namespace 有写操作、flushCache="true" |
三类脏读(真实故障来源):
① 跨 namespace 脏读:A 表被 B 的 namespace 更新 → A 的二级缓存不知道
→ 只在「单表独占 namespace」时开二级缓存
② 多实例脏读:集群多节点,各自 JVM 缓存 → 节点间不一致
→ 分布式场景禁用二级缓存,改 Redis(见 S12)
③ 事务未提交读:二级缓存在事务提交后才写入,但并发读可能读到旧值 → 用可重复读/短 TTL 兜底
配置要点:
<cache eviction="LRU" flushInterval="60000" size="1024" readOnly="false"/>
<!-- readOnly=false 需要对象可序列化;true 则返回同一实例(有被修改风险) -->
判据:"开了二级缓存后数据不对"是预期行为,不是 bug——除非你能证明"该 namespace 只碰一张表且单实例部署",否则别开。
4. 插件:四类拦截点与责任链顺序(E-S08-026)
| 可拦截对象 | 方法 | 典型用途 |
|---|---|---|
Executor |
update/query |
改写 SQL、分页、多租户注入条件 |
StatementHandler |
prepare |
改写 SQL 文本、设置超时 |
ParameterHandler |
setParameters |
参数加密/脱敏 |
ResultSetHandler |
handleResultSets |
结果填充、字段解密 |
@Intercepts(@Signature(type = Executor.class, method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}))
public class TenantInterceptor implements Interceptor {
@Override public Object intercept(Invocation inv) throws Throwable { … }
}
顺序规则:
多个插件按注册顺序形成责任链 → 先注册的在外层(先拿到调用)
分页插件建议靠前(先改写 SQL 再加其他处理)
ordering 不一致 → 不同环境行为不同(同一份 XML,注册顺序由 Spring 扫描决定)
判据:插件里不要做业务——插件是"全局 AOP",出错影响所有 SQL;业务逻辑应留在 Service。明确指定顺序(@Bean 逐个注册,别靠扫描)。
5. 批量三写法与性能口径(E-S08-027)
| 写法 | SQL 条数 | 适用 | 坑 |
|---|---|---|---|
循环调用 mapper.insert(x) |
N | 小批量(<100) | 网络往返 ×N |
<foreach> 拼多值 insert |
1 | 中等批量(几百~几千) | SQL 过长(max_allowed_packet);非标准 SQL 无法复用 |
ExecutorType.BATCH |
1 批(按 batchSize 刷) |
大批量、可容忍延迟 | 需 flushStatements();批内不能读到自己刚写的 |
try (SqlSession s = sqlSessionFactory.openSession(ExecutorType.BATCH)) {
UserMapper m = s.getMapper(UserMapper.class);
for (int i = 0; i < list.size(); i++) {
m.insert(list.get(i));
if (i % 500 == 0) s.flushStatements();
}
s.flushStatements(); s.commit();
}
性能口径(必须写清,否则数字无意义):
测试条件:MySQL 8.0 / 单机 / 1 万条 / 每行 200B / autocommit=false
度量:总耗时、QPS、GC 次数;对照组:循环单条 / foreach(1000/批) / BATCH(500/批)
结论口径:只报"相对倍数 + 环境",不报绝对 QPS(硬件相关)
6. 性能优化清单(3069/3078/3080 的压缩)
① SQL 层:索引命中(EXPLAIN 看 type=ALL 即全表)、避免 SELECT *、深分页改"游标/延迟关联"
② 映射层:关掉不需要的关联(懒加载按需)、resultMap 精简(少列少对象)
③ 执行层:批量、复用 SqlSession(Spring 已做)、合理 `fetchSize`(大结果集流式)
④ 应用层:缓存放 Redis(不靠二级缓存)、连接池参数(HikariCP `maximumPoolSize` 与 DB `max_connections` 匹配)
⑤ 观测:慢 SQL 日志阈值、`p6spy` 耗时、连接池等待时间指标
深分页的正确写法:
-- ❌ 越翻越慢:OFFSET 需要扫描并丢弃
SELECT * FROM orders ORDER BY id LIMIT 100000, 20;
-- ✅ 游标分页(记住上一页最后一个 id)
SELECT * FROM orders WHERE id > ? ORDER BY id LIMIT 20;
-- ✅ 或延迟关联(先取 id 再 join)
SELECT o.* FROM orders o JOIN (SELECT id FROM orders ORDER BY id LIMIT 100000,20) t ON o.id=t.id;
7. 反例:七种"看着没问题"
| 写法 | 问题 |
|---|---|
<collection select=…> 查列表 |
N+1,100 条主记录 → 101 次查询 |
| 分页 + join 集合 | 主记录被截断,页面条数不足 |
| 全局开二级缓存 | 跨 namespace / 多实例脏读 |
插件靠 @Component 扫描注册 |
顺序不稳定 |
| 循环 insert 一万条 | 一万次往返 |
| BATCH 模式里"写完立刻读" | 批未刷,读不到 |
深分页 LIMIT 100000,20 |
扫描 10 万行 |
8. 自测
- 关联映射三方式各多少条 SQL?分页 + join 集合的陷阱?
- 一级/二级缓存的作用域与失效条件?
- 三类脏读分别怎么来的?
- 插件四类拦截点?为什么顺序要显式指定?
- 批量三写法的适用边界?BATCH 的"读不到自己写的"为什么?
答案
- 1 条 / 1+N 条 / 2 条;LIMIT 按行截断导致主记录不足。
- Session / namespace;写操作、commit、close、flushCache。
- 跨 namespace、多实例、事务未提交读。
- Executor/StatementHandler/ParameterHandler/ResultSetHandler;扫描顺序不稳定导致环境行为差异。
- 小批量循环、中批量 foreach、大批量 BATCH;批内的语句要 flush 才发送到 DB。
9. 下一篇
补编第 3 篇 · 分布式与微服务下的持久层 —— 分库分表后的事务与分页、多数据源与动态切换、微服务里的数据归属与跨服务查询,以及 ORM 层的边界收缩。
本篇属于系列《S08 · MyBatis 持久层》。旧文来源:A-3059、A-3061–3064、A-3069、A-3076–3078、A-3080(章号版 10 章);扩展点 E-S08-024/025/026/027。版本口径 MyBatis 3.5.x / mybatis-spring-boot-starter 3.x / MySQL 8.0。