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

相关推荐
vipxieliang17 小时前
ValidX 的 Date 和 DateTime vs JPA 的 Temporal 对比
java·后端
触底反弹17 小时前
🔥 从「为什么」到「怎么用」:TypeScript 类型约束与泛型完全指南
后端·面试·typescript
用户83562907805117 小时前
使用 Python 查找和替换 Word 文档中的文本
后端·python
Java编程爱好者17 小时前
Spring Boot 实现数据脱敏:自定义注解 + Jackson 序列化器
后端
神奇小汤圆17 小时前
从 ☕️Java Spring Boot → 🦀Rust Axum 体验的真实感受
后端
掘金者阿豪18 小时前
Codex 开放 1M 上下文后,我折腾了一圈才发现:新版根本不是网上教的那样配置
后端
爱勇宝18 小时前
《道德经》第 10 章:真正成熟的人,能成事但不控制一切
前端·后端·程序员
六边形66618 小时前
独立开发不知道做什么?使用 TRAE Work 抓取差评痛点,快速跑通产品立项流
前端·后端·面试
高频因子挖掘机18 小时前
量化交易系统的数据层和策略层如何解耦?从紧耦合泥潭到优雅分层架构
后端·github
临江仙45518 小时前
同一个 AI Agent 如何同时服务 Web、微信和 QQ:PureChat 的渠道架构实践
前端·人工智能·后端