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 根治,一级缓存靠"别信它"规避。

相关推荐
神经蛙199615 小时前
🌍 别再硬编码中文了!Python Web 项目国际化(i18n)完全指南
后端·python
二月龙15 小时前
Spring 事务失效的 8 种场景,很多老手依然频繁踩雷
后端
掘金酱15 小时前
「TRAE Work 实战帮」征文启动!你沉淀的经验,值得被看见!
前端·人工智能·后端
用户18615580086016 小时前
MinIO Java 对接试用:从连接、上传到下载的完整示例
后端
爱勇宝16 小时前
DeepSeek V4-Flash 更新:代码与 Agent 能力全面增强
前端·后端·deepseek
极客悟道16 小时前
SDKMAN vs jEnv vs JetTUI,JDK 版本管理到底选哪个
后端
长大198816 小时前
Java8 新特性到底要不要吃透?工作中高频使用的 5 个功能总结
后端
二月龙16 小时前
Spring Bean 生命周期 & 循环依赖:90% 开发者只知结论不懂原理
后端