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

相关推荐
金銀銅鐵3 小时前
[Java] 用 GUI 展示 EnumSet 增减元素时的内部变化(上)
java·后端·ai编程
念何架构之路4 小时前
go-grpc设计思想
开发语言·后端·golang
system_error_x4 小时前
SpringBoot 集成 Thymeleaf 简洁入门
java·spring boot·后端
步行cgn6 小时前
Spring MVC 视图机制的核心接口与类
java·开发语言·后端·spring·mvc
码事漫谈7 小时前
AI 读你的提示词,其实很没耐心
后端
用户8356290780517 小时前
如何使用 Python 格式化 Excel 单元格中的长文本
后端·python
我命由我123457 小时前
Python 开发 - Python 的 pathlib
java·开发语言·后端·python·学习·java-ee·学习方法
IT_陈寒7 小时前
Java性能优化完全指南
前端·人工智能·后端
电子IT笔记7 小时前
对接群晖NAS,上传和删除踩坑记录
前端·后端
Mk598788 小时前
P4:把 Agent 的记忆搬进 Redis —— AgentScope-Java 工程化实战
后端