MyBatis 常见性能陷阱:N+1 查询、一级缓存踩坑解决方案

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 / delete
  • flushCache=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 性能与安全最佳实践

  1. 禁止 association / collection 使用 select
  2. 复杂对象用 JOIN + resultMap
  3. 一个接口 SQL ≤ 5 条
  4. 事务尽量短小
  5. 不依赖一级缓存做业务判断
  6. 分布式场景禁用 MyBatis 缓存
  7. 缓存统一交给 Redis

四、一张图总结

sql 复制代码
N+1 查询
├── 表现:SQL 数量爆炸
├── 根因:逐条关联查询
└── 解法:JOIN / 批量查询

一级缓存
├── 表现:数据"明明改了却没变"
├── 根因:SqlSession 内缓存命中
└── 解法:理解作用域 + 避免强依赖

五、一句话总结

N+1 是性能问题,一级缓存是正确性问题。

N+1 靠 JOIN 根治,一级缓存靠"别信它"规避。

相关推荐
码事漫谈1 小时前
多人共用一个 key,缓存命中率会不会因此降低?
后端
考虑考虑2 小时前
docker compose环境变量替换
运维·后端·自动化运维
武雄(小星Ai)3 小时前
飞书API上传26MB文件偶发失败:错误码9499与空响应体排坑实录
后端·api·排坑实录
徐小夕3 小时前
JitWord 4.0 万字分享:从协同工具到AI Word操作系统,聊聊3年产品创业史
前端·vue.js·后端
geovindu4 小时前
CSharp: Observer Pattern
开发语言·后端·观察者模式·设计模式·c#·.netcore·行为模式
Flynt5 小时前
把公司项目迁到 Spring Boot 4.0:编译通过只是开始
java·spring boot·后端
IT_陈寒5 小时前
Redis误用keys命令把生产环境搞崩了,血的教训
前端·人工智能·后端
她的男孩6 小时前
多租户和数据权限怎么共存?扒完拦截器注册链路,我找到 4 个隐蔽的坑
java·后端·架构
用户8356290780516 小时前
使用 Python 设置 Excel 页眉和页脚
后端·python
步行cgn6 小时前
IoC 控制反转:从概念到 Spring 的实现
后端