MyBatis 常见性能陷阱:N+1 查询、一级缓存踩坑解决方案
MyBatis 上手容易,但一旦系统并发上来,慢 SQL、CPU 飙高、数据莫名不对,往往都能追溯到两个老熟人:
N+1 查询问题
一级缓存误用问题
这两个坑,一个影响性能,一个影响正确性。本文将结合执行原理 → 典型场景 → 解决方案 → 实战建议,彻底拆干净。
一、N+1 查询问题:最隐蔽的性能杀手
1️⃣ 什么是 N+1 查询?
举个最常见的例子:查询订单列表,并关联查询每个订单的用户信息。
错误写法(典型 N+1)
scss
// 1. 查订单
List<Order> orders = orderMapper.selectAll();
// 2. 遍历订单,查用户
for (Order order : orders) {
User user = userMapper.selectById(order.getUserId());
order.setUser(user);
}
SQL 执行情况:
sql
1 条:select * from orders
N 条:select * from user where id = ?
👉 总共 N+1 条 SQL
当 orders = 1000 时,直接 1001 次 DB 访问。
2️⃣ 为什么 N+1 这么致命?
- 网络 IO × N
- 数据库连接占用
- SQL 解析成本 × N
- 接口 RT 随数据量线性增长
特征非常明显:
单次接口请求,SQL 数量异常多,但每条 SQL 都很快。
3️⃣ MyBatis 中 N+1 的高发场景
✅ 场景一:association / collection 的 select 属性
ini
<resultMap id="orderMap" type="Order">
<id property="id" column="id"/>
<association property="user"
column="user_id"
select="com.xxx.UserMapper.selectById"/>
</resultMap>
⚠️ 这就是官方文档里最容易踩的 N+1 陷阱
每映射一条 Order,就会触发一次 selectById。
✅ 场景二:懒加载(lazyLoadingEnabled)
ini
<setting name="lazyLoadingEnabled" value="true"/>
懒加载 ≠ 解决问题,它只是推迟 N+1 的发生时机。
4️⃣ N+1 的标准解决方案
✅ 方案一(最推荐):JOIN + 嵌套映射(一次 SQL)
ini
<select id="selectOrdersWithUser" resultMap="orderUserMap">
select o.id, o.order_no, u.id as user_id, u.name
from orders o
left join user u on o.user_id = u.id
</select>
<resultMap id="orderUserMap" type="Order">
<id property="id" column="id"/>
<association property="user" javaType="User">
<id property="id" column="user_id"/>
<result property="name" column="name"/>
</association>
</resultMap>
✅ 1 条 SQL
✅ 性能好
✅ 生产环境首选
✅ 方案二:批量查询(次优)
ini
List<Order> orders = orderMapper.selectAll();
Set<Long> userIds = orders.stream()
.map(Order::getUserId)
.collect(Collectors.toSet());
Map<Long, User> userMap =
userMapper.selectByIds(userIds)
.stream()
.collect(Collectors.toMap(User::getId, u -> u));
orders.forEach(o -> o.setUser(userMap.get(o.getUserId())));
✅ SQL 数量:2 条
✅ 适合复杂查询或跨数据源场景
✅ 方案三:开启二级缓存(辅助手段,不是根治)
⚠️ 二级缓存只能缓解,不能消除 N+1
5️⃣ N+1 排查小技巧
- MyBatis 日志打印 SQL
- p6spy / MyBatis-Plus SQL 分析
- SkyWalking / Arthas 查看 SQL 次数
- IDEA MyBatis Log Plugin
黄金法则:
一个接口,SQL 超过 5 条,就要警惕 N+1。
二、一级缓存:最容易"出 Bug"的地方
1️⃣ MyBatis 一级缓存是什么?
- 作用域:SqlSession
- 默认:开启
- 存储位置:本地内存
- Key:SQL + 参数 + RowBounds + StatementId
sql
同一个 SqlSession
同一个 SQL
同一个参数
→ 第二次直接走缓存
2️⃣ 一级缓存的典型踩坑场景
❌ 坑一:事务中读到"脏数据"
java
@Transactional
public void updateUser() {
User u1 = userMapper.selectById(1L);
userMapper.updateName(1L, "newName");
User u2 = userMapper.selectById(1L); // 还是旧值!
}
原因:
u2直接从一级缓存取出- 数据库已更新,缓存未失效(非同一 Statement)
✅ 这不是 Bug,是设计如此
❌ 坑二:Spring 环境下缓存"时灵时不灵"
很多人以为:
Spring + MyBatis 一级缓存一直生效
真相是:
| 场景 | 一级缓存 |
|---|---|
| 方法内多次同 SQL | ✅ |
跨 @Transactional 方法 |
❌ |
| Mapper 方法独立调用 | ❌ |
因为 Spring 每次执行完 Mapper 方法后,close SqlSession。
❌ 坑三:分布式环境下数据不一致
- A 服务更新数据
- B 服务一级缓存仍然命中旧值
⚠️ 一级缓存完全不适合分布式
3️⃣ 一级缓存的源码级真相(重点)
MyBatis 一级缓存失效条件:
sqlSession.clearCache()update / insert / deleteflushCache=true- SqlSession 关闭
只读事务 + 多次查询 = 一级缓存命中
4️⃣ 一级缓存的正确使用姿势
✅ 原则一:不要把一级缓存当"一致性保障"
一级缓存是性能优化手段,不是数据一致性手段
✅ 原则二:必要时手动清缓存
ini
sqlSession.clearCache();
或在 XML 中:
ini
<select id="selectById" flushCache="true">
✅ 原则三:Spring 项目慎用长事务
长事务 = 一级缓存长期存活 = 脏读风险
typescript
@Transactional
public void process() {
// 大量逻辑 + 多次查询
}
✅ 拆小事务
✅ 读写分离
✅ 查询走从库
5️⃣ 一级缓存 vs 二级缓存
| 对比项 | 一级缓存 | 二级缓存 |
|---|---|---|
| 作用域 | SqlSession | Mapper (namespace) |
| 默认 | 开启 | 关闭 |
| 分布式 | ❌ | ❌ |
| 一致性 | 弱 | 更弱 |
| 推荐 | 了解即可 | 慎用 |
👉 生产环境建议:
一级缓存:了解原理,避免误判
二级缓存:能不用就不用
三、综合实战建议(强烈推荐)
✅ MyBatis 性能与安全最佳实践
- 禁止 association / collection 使用
select - 复杂对象用 JOIN + resultMap
- 一个接口 SQL ≤ 5 条
- 事务尽量短小
- 不依赖一级缓存做业务判断
- 分布式场景禁用 MyBatis 缓存
- 缓存统一交给 Redis
四、一张图总结
sql
N+1 查询
├── 表现:SQL 数量爆炸
├── 根因:逐条关联查询
└── 解法:JOIN / 批量查询
一级缓存
├── 表现:数据"明明改了却没变"
├── 根因:SqlSession 内缓存命中
└── 解法:理解作用域 + 避免强依赖
五、一句话总结
N+1 是性能问题,一级缓存是正确性问题。
N+1 靠 JOIN 根治,一级缓存靠"别信它"规避。