2055 字
约 6 分钟
8
S08-J06 · MyBatis 进阶机制:关联映射、缓存、插件与性能

适用读者:遇过"列表查出来只有一条子记录""二级缓存改库后不对""插件顺序乱"的人。
前置: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. 自测

  1. 关联映射三方式各多少条 SQL?分页 + join 集合的陷阱?
  2. 一级/二级缓存的作用域与失效条件?
  3. 三类脏读分别怎么来的?
  4. 插件四类拦截点?为什么顺序要显式指定?
  5. 批量三写法的适用边界?BATCH 的"读不到自己写的"为什么?
答案
  1. 1 条 / 1+N 条 / 2 条;LIMIT 按行截断导致主记录不足。
  2. Session / namespace;写操作、commit、close、flushCache。
  3. 跨 namespace、多实例、事务未提交读。
  4. Executor/StatementHandler/ParameterHandler/ResultSetHandler;扫描顺序不稳定导致环境行为差异。
  5. 小批量循环、中批量 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。

S08-J06 · MyBatis 进阶机制:关联映射、缓存、插件与性能
https://www.clxhxhhr.top/posts/5292/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。