MyBatis N+1 查询治理:列表页一次请求打了 120 条 SQL

后台订单列表页翻一页要四五秒,但每条 SQL 单跑都很快。打开 MyBatis SQL 日志才发现一次查询默默打了一百多条 SQL——经典 N+1。几天治理:批量 IN 查询 + 内存组装、一对一改 JOIN 嵌套结果映射、一对多用 collection 归并、写拦截器统计单请求 SQL 数做监控。SQL 数从 120 条压到 3 条。

2024 年我们的后台订单列表页越来越慢,翻一页要等四五秒。一开始以为是 SQL 没走索引,但单条 SQL 跑起来都很快。打开 MyBatis 的 SQL 日志才发现真相:一次列表查询,后台默默打了一百多条 SQL —— 经典的 N+1 查询问题。投了几天做专项治理,把列表接口的 SQL 数量从一百多条压到三条以内,本文复盘 MyBatis N+1 的完整实战。

问题背景

业务:运营后台订单列表,Spring Boot + MyBatis + MySQL
页面:一页 20 条订单,每条要展示用户名、商品名、收货地址
事故现象:
- 列表页翻一页 4-5 秒,运营天天投诉
- 单独拎出任意一条 SQL 执行,都在 5ms 内,都走了索引
- 数据库 QPS 莫名其妙很高,但每条都不慢

现场排查:
# 打开 MyBatis SQL 日志:mybatis.configuration.log-impl
#   = org.apache.ibatis.logging.stdout.StdOutImpl
# 翻一页列表,日志里刷出来这些:
SELECT * FROM orders WHERE status = 1 LIMIT 20         <- 1 条
SELECT * FROM users WHERE id = 101                     <- 第 1 条订单的用户
SELECT * FROM users WHERE id = 102                     <- 第 2 条订单的用户
... (用户查 20 次)
SELECT * FROM products WHERE id = 5001                 <- 第 1 条的商品
SELECT * FROM products WHERE id = 5002
... (商品查 20 次)
SELECT * FROM address WHERE order_id = 90001           <- 地址查 20 次
...

# 一共:1 + 20 + 20 + 20 = 61 条 SQL!
# 列表里还有嵌套对象,实际打到 120+ 条

根因:
1. 查 20 条订单是 1 条 SQL,但每条订单的关联数据
   都在循环里"一条一条单独查" -> 1 + N 条
2. 用了 MyBatis 的关联查询(association/collection)
   但配成了"嵌套查询"(select),每行触发一次子查询
3. 单条 SQL 都很快,问题在"数量"——网络往返被放大 N 倍

修复 1:认清 N+1 长什么样

// === N+1 的典型代码:在循环里查关联数据 ===
public List listOrders() {
    List orders = orderMapper.selectByStatus(1);   // 1 条 SQL

    List result = new ArrayList<>();
    for (Order order : orders) {
        OrderVO vo = new OrderVO(order);
        // 下面三行,每条订单都各查一次 —— 这就是 N
        vo.setUser(userMapper.selectById(order.getUserId()));        // N 条
        vo.setProduct(productMapper.selectById(order.getProductId()));// N 条
        vo.setAddress(addressMapper.selectByOrderId(order.getId())); // N 条
        result.add(vo);
    }
    return result;
}
// 20 条订单 -> 1 + 20*3 = 61 条 SQL
// 每条 SQL 哪怕只要 3ms,光网络往返:61 * 3ms ≈ 180ms+,
// 加上连接获取、结果解析,轻松到秒级。

// === 为什么单测 SQL 快、整体却慢 ===
// 性能杀手不是单条 SQL 的执行时间,而是 SQL 的【数量】:
// 每条 SQL 都有固定开销:获取连接、网络往返、SQL 解析、结果封装。
// N+1 把这个固定开销放大了 N 倍,这才是真正的瓶颈。

// === MyBatis XML 里也藏着 N+1 ===
// 
//     <- 嵌套查询!
// 
// 这种 select 形式的 association,主查询每返回一行,
// 就触发一次子查询,效果和循环里手查一模一样。

修复 2:解法一 —— 批量查询 + 内存组装

// === 核心思路:把"N 次单查"合并成"1 次 IN 批量查" ===
public List listOrders() {
    // 1. 查订单(1 条 SQL)
    List orders = orderMapper.selectByStatus(1);
    if (orders.isEmpty()) return Collections.emptyList();

    // 2. 收集所有要关联的 ID(去重)
    Set userIds = orders.stream()
        .map(Order::getUserId).collect(Collectors.toSet());
    Set productIds = orders.stream()
        .map(Order::getProductId).collect(Collectors.toSet());
    List orderIds = orders.stream()
        .map(Order::getId).collect(Collectors.toList());

    // 3. 批量查关联数据(各 1 条 SQL,IN 查询)
    Map userMap = userMapper.selectByIds(userIds)
        .stream().collect(Collectors.toMap(User::getId, u -> u));
    Map productMap = productMapper.selectByIds(productIds)
        .stream().collect(Collectors.toMap(Product::getId, p -> p));
    // 地址是一对多,按 orderId 分组
    Map> addrMap = addressMapper.selectByOrderIds(orderIds)
        .stream().collect(Collectors.groupingBy(Address::getOrderId));

    // 4. 内存里组装,不再碰数据库
    return orders.stream().map(o -> {
        OrderVO vo = new OrderVO(o);
        vo.setUser(userMap.get(o.getUserId()));
        vo.setProduct(productMap.get(o.getProductId()));
        vo.setAddressList(addrMap.getOrDefault(o.getId(), List.of()));
        return vo;
    }).collect(Collectors.toList());
}
// SQL 数量:1 + 1 + 1 + 1 = 4 条,固定不变,与订单数量无关。
// 这是最通用、最可控的解法。
<!-- 配套的批量查询 Mapper:用 foreach 拼 IN -->
<select id="selectByIds" resultType="User">
    SELECT * FROM users WHERE id IN
    <foreach collection="ids" item="id" open="(" separator="," close=")">
        #{id}
    </foreach>
</select>
<!-- 注意:IN 列表不能无限大,几千个 id 要分批,
     每批 500-1000 个,否则 SQL 过长 / 优化器选择变差 -->

修复 3:解法二 —— JOIN 一次查回

<!-- === 关联是一对一时,直接 JOIN 一条 SQL 查回 === -->
<select id="listOrdersWithJoin" resultMap="orderFullMap">
    SELECT
        o.id AS o_id, o.status AS o_status, o.amount AS o_amount,
        u.id AS u_id, u.name AS u_name,
        p.id AS p_id, p.name AS p_name
    FROM orders o
    LEFT JOIN users u    ON u.id = o.user_id
    LEFT JOIN products p ON p.id = o.product_id
    WHERE o.status = 1
    LIMIT 20
</select>

<!-- 用 resultMap 的【嵌套结果映射】把扁平结果拼成对象 -->
<resultMap id="orderFullMap" type="OrderVO">
    <id property="id" column="o_id"/>
    <result property="status" column="o_status"/>
    <result property="amount" column="o_amount"/>
    <!-- association 用 resultMap/嵌套字段,不是 select! -->
    <association property="user" javaType="User">
        <id property="id" column="u_id"/>
        <result property="name" column="u_name"/>
    </association>
    <association property="product" javaType="Product">
        <id property="id" column="p_id"/>
        <result property="name" column="p_name"/>
    </association>
</resultMap>
<!-- 关键区别:
     association 配 select="..."  -> 嵌套查询,N+1
     association 配嵌套的字段映射 -> 嵌套结果,一条 JOIN 搞定 -->
=== JOIN 方案的适用边界 ===
适合:
- 一对一关联(订单->用户、订单->商品)
- 关联表数量少(2-3 张)

要警惕:
- 一对多 JOIN 会"行膨胀":1 个订单 3 个地址 -> 结果 3 行
  MyBatis 靠  标签去重归并,但传输的数据量真的变大了
- JOIN 表太多(5+ 张),SQL 复杂、优化器容易选错执行计划
- 大表 JOIN 大表,没有好的索引时会很慢

经验:一对一用 JOIN,一对多优先用"批量查询 + 内存分组"。

修复 4:解法三 —— 合理使用 MyBatis 嵌套结果映射

<!-- 一对多也能用嵌套结果映射,collection 配字段而非 select -->
<select id="listOrdersWithAddress" resultMap="orderWithAddrMap">
    SELECT
        o.id AS o_id, o.status AS o_status,
        a.id AS a_id, a.detail AS a_detail, a.order_id AS a_oid
    FROM orders o
    LEFT JOIN address a ON a.order_id = o.id
    WHERE o.status = 1
</select>

<resultMap id="orderWithAddrMap" type="OrderVO">
    <id property="id" column="o_id"/>
    <result property="status" column="o_status"/>
    <!-- collection:一对多,MyBatis 按  归并多行到一个 list -->
    <collection property="addressList" ofType="Address">
        <id property="id" column="a_id"/>
        <result property="detail" column="a_detail"/>
    </collection>
</resultMap>
<!-- MyBatis 看  列(o_id):o_id 相同的多行,
     被归并成同一个 OrderVO,address 累积进它的 list。
     必须配 ,否则 MyBatis 无法判断"哪些行属于同一主对象"。 -->
// === 全局兜底:开启 MyBatis 的"延迟加载 + 按需触发" ===
// 如果改不动所有 SQL,可临时开延迟加载缓解(治标):
// mybatis:
//   configuration:
//     lazy-loading-enabled: true        # 关联对象延迟加载
//     aggressive-lazy-loading: false    # 不要碰一个属性就加载全部
// 效果:不访问关联对象就不查它。但只要你在循环里访问了,
// N+1 照样发生 —— 延迟加载不能根治,只是"不用就不查"。

// === 真正的治本:接口设计阶段就想清楚要不要关联数据 ===
// 列表页常常根本不需要那么多关联字段,
// 先问"这个字段列表页真的要展示吗",能砍就砍。

修复 5:排查 N+1 的手段

# === 1. 打开 MyBatis SQL 日志,最直接 ===
mybatis:
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
# 翻一次页面,数日志里打了几条 SQL,一目了然

# === 2. 用 p6spy 拦截并统计 SQL ===
# p6spy 能打印每条真实 SQL + 耗时,还能统计一次请求的 SQL 总数
# spy.properties 里配 logMessageFormat,接入后单请求 SQL 数清清楚楚
// === 3. 自己写个 MyBatis 拦截器,统计单请求 SQL 数,超阈值告警 ===
@Intercepts(@Signature(type = Executor.class, method = "query",
    args = {MappedStatement.class, Object.class, RowBounds.class,
            ResultHandler.class}))
public class SqlCountInterceptor implements Interceptor {
    private static final ThreadLocal COUNTER =
        ThreadLocal.withInitial(AtomicInteger::new);

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        int count = COUNTER.get().incrementAndGet();
        if (count > 50) {
            // 单次请求 SQL 数超 50,极可能是 N+1,打印告警
            log.warn("疑似 N+1:本次请求已执行 {} 条 SQL", count);
        }
        return invocation.proceed();
    }
    // 配合一个 Filter:请求结束时 COUNTER.remove() 并上报指标
}
// 把"单请求 SQL 数"做成监控指标,N+1 一出现就能在监控上看到。

修复 6:监控告警

# N+1 的外在表现:接口慢 + DB QPS 虚高
groups:
- name: mybatis-nplus1
  rules:
  # 1. 单请求 SQL 数过多(拦截器上报的指标)
  - alert: RequestSqlCountHigh
    expr: max(request_sql_count) by (api) > 50
    for: 5m
    annotations:
      summary: "{{ $labels.api }} 单请求 SQL 数 > 50,疑似 N+1"

  # 2. 接口 RT 高但 DB 单条 SQL 不慢(N+1 的典型特征)
  - alert: SlowApiFastSql
    expr: |
      http_server_requests_seconds{quantile="0.99"} > 1
      and on() avg(mysql_slow_queries) < 1
    for: 5m
    annotations:
      summary: "{{ $labels.uri }} 接口慢但无慢 SQL,排查 N+1"

  # 3. DB QPS 异常偏高(N+1 把查询数量放大了)
  - alert: DbQpsSurge
    expr: rate(mysql_global_status_questions[5m]) > 8000
    for: 10m
    annotations:
      summary: "MySQL QPS 突增,排查是否有接口产生 N+1 查询"

优化效果

指标                      治理前              治理后
=============================================================
列表页响应时间            4-5 秒               180ms
单次请求 SQL 数           61-120 条            3-4 条
DB QPS(后台高峰)        约 9000              约 1100
关联查询方式              循环单查 / select    批量 IN / JOIN
单请求 SQL 数监控         无                   拦截器统计 + 告警
运营投诉                  每天数次             消失

压测(后台列表 500 QPS):
- 治理前:DB 被 N+1 打爆,大量请求排队等连接
- 治理后:DB QPS 平稳,列表 P99 200ms 内

排查与改造:
- 打开 SQL 日志定位 N+1:0.5 天
- 列表接口改批量查询 + 内存组装(8 个接口):2 天
- 一对一关联改 JOIN 嵌套结果映射:1 天
- SQL 数拦截器 + 监控接入:0.5 天
- 压测验证:1 天

避坑清单

  1. N+1 是查 1 次列表再为每行查 1 次关联,总 SQL 数 = 1 + N
  2. 性能杀手不是单条 SQL 慢,而是 SQL 数量多、固定开销被放大 N 倍
  3. 循环里查关联数据是最常见的 N+1,代码评审要重点盯循环里的 Mapper 调用
  4. MyBatis 的 association/collection 配 select 是嵌套查询,等于 N+1
  5. 最通用的解法:收集 ID 批量 IN 查询,再在内存里组装,SQL 数固定
  6. IN 列表不能无限大,几千个 ID 要分批,每批 500-1000 个
  7. 一对一关联用 JOIN 一条查回,association 配嵌套字段映射而非 select
  8. 一对多用 collection 嵌套结果映射,必须配 id 标签让 MyBatis 归并行
  9. 一对多 JOIN 会行膨胀,数据量大时优先用批量查询 + 内存分组
  10. 用拦截器统计单请求 SQL 数,做成监控指标,N+1 一出现就能看到

总结

这次后台列表页变慢的排查,过程其实很有迷惑性,也让我对性能问题有了新的认识。最迷惑的地方在于:我们一开始所有的注意力都放在"哪条 SQL 慢"上,把每条 SQL 单独拎出来跑,发现它们都在几毫秒内、都规规矩矩走了索引,于是一度以为数据库没问题。直到打开 MyBatis 的 SQL 日志,翻一页列表,看着控制台哗啦啦刷出六十多条 SQL,才恍然大悟 —— 真正的性能杀手根本不是单条 SQL 的执行时间,而是 SQL 的数量。每一条 SQL 哪怕再快,它都有一笔躲不掉的固定成本:从连接池借连接、一次网络往返、SQL 解析、结果集封装,这些加起来可能比 SQL 本身的执行还久,而 N+1 问题的本质,就是把这笔固定成本无情地放大了 N 倍。理解了这一点,解法的方向就很明确了:想尽办法把 N 次查询压缩成常数次。最通用、最可控的办法是批量查询加内存组装 —— 先把列表查出来,收集所有要关联的 ID,用一条 IN 查询把关联数据一次性捞回来,再在内存里做映射拼装,这样无论列表有 20 条还是 200 条,SQL 数量永远是固定的那么几条。对于一对一的关联,直接用 JOIN 一条 SQL 查回来也很好,但要特别小心 MyBatis 的 association 和 collection 标签 —— 它们配 select 属性时是"嵌套查询",每返回一行就触发一次子查询,这和你在循环里手动单查没有任何区别,同样是 N+1,只有配成嵌套的结果字段映射,才是真正的一条 SQL 搞定。还要注意一对多关联用 JOIN 会带来行膨胀,数据量大时反而是批量查询加内存分组更划算。最后我觉得最有价值的一个工程实践,是写一个 MyBatis 拦截器把"单次请求执行了多少条 SQL"统计出来、做成监控指标,这样 N+1 问题在它刚冒头、还没严重到被运营投诉之前,就能在监控上被一眼看见。N+1 是个非常隐蔽的性能陷阱,因为它的每一条 SQL 看起来都"很健康",但聚在一起就是一场灾难,对付它最好的武器是让"一次请求打了多少 SQL"这件事变得可观测。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

Spring 循环依赖踩坑:加一个 @Async 注解就启动失败的复盘

2026-5-20 12:45:46

技术教程

日志打爆磁盘:一次 error.log 涨到 388G 的周末宕机复盘

2026-5-20 12:50:31

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索