一次数据库查询优化实战:从N+1问题到毫秒级响应

作者 lucy · 2026-04-11

⚠️ 声明:本文相关内容仅供参考,实际效果因场景不同可能差异很大,请结合自身情况判断,谨慎参考。

作者 lucy · 2026-04-17

⚠️ 声明:本文相关内容仅供参考,实际效果因场景不同可能差异很大,请结合自身情况判断,谨慎参考。

一、问题发现

某天测试同学反馈:”列表接口加载 200 条数据要 8 秒,太慢了!”打开监控一看,一个简单的列表查询,背后竟然执行了 201 次数据库请求——1 次查列表 + 200 次查详情。这就是经典的 N+1 查询问题

二、根因分析

先说说什么是 N+1 问题。代码逻辑大概是这样的:

// 服务层代码(模拟)
public List<OrderVO> getOrderList() {
    List<Order> orders = orderMapper.selectList();  // SQL ①:查所有订单,1条
    return orders.stream().map(order -> {
        OrderVO vo = new OrderVO();
        vo.setId(order.getId());
        vo.setAmount(order.getAmount());
        // 每个订单单独查一次用户信息
        User user = userMapper.selectById(order.getUserId());  // SQL ②~N+1:循环N次
        vo.setUserName(user.getName());
        return vo;
    }).collect(Collectors.toList());
}

这段代码看起来逻辑清晰,_orders_ 表查一次,_user_ 表循环查 N 次。当列表有 200 条时,执行 201 次 SQL,数据库往返开销巨大。如果加上网络延迟,耗时会成倍放大。

三、验证问题

先确认是不是真的 N+1,用 SQL 日志打印一下执行次数(druid 连接池自带的 SQL 日志即可):

# 开启慢SQL日志(配置文件)
spring:
  datasource:
    druid:
      # 打印所有SQL
      filter: stat,slf4j
      log-slow-sql: true
      slow-sql-millis: 1000

# 或者在 Mapper XML 里打印
# MyBatis 配置:
<settings>
    <setting name="logImpl" value="SLF4J"/>
</settings>

开启后访问一次接口,控制台输出了 201 条 select 语句,问题确认无疑。

四、解决方案:批量查询优化

方案一:JOIN 连表查询(适合单次关联)

<select id="selectOrderWithUser" resultMap="OrderUserMap">
    SELECT
        o.id AS order_id,
        o.amount,
        u.id AS user_id,
        u.name AS user_name
    FROM orders o
    LEFT JOIN users u ON o.user_id = u.id
    WHERE o.status = 1
    ORDER BY o.create_time DESC
    LIMIT 200
</select>

<resultMap id="OrderUserMap" type="com.example.OrderVO">
    <id property="id" column="order_id"/>
    <result property="amount" column="amount"/>
    <result property="userName" column="user_name"/>
</resultMap>

方案二:IN 批量查询(适合多对多或动态关联)

// Mapper 接口
List<Order> selectOrderList();
List<User> selectUsersByIds(@Param("ids") List<Long> ids);

// Mapper XML
<select id="selectUsersByIds" resultType="com.example.User">
    SELECT id, name FROM users WHERE id IN
    <foreach collection="ids" item="id" open="(" separator="," close=")">
        #{id}
    </foreach>
</select>

// 服务层
public List<OrderVO> getOrderListOptimized() {
    // ① 查订单列表
    List<Order> orders = orderMapper.selectOrderList();

    // ② 收集所有用户ID
    List<Long> userIds = orders.stream()
        .map(Order::getUserId)
        .distinct()
        .collect(Collectors.toList());

    // ③ 一次批量查用户(只多1条SQL)
    List<User> users = userMapper.selectUsersByIds(userIds);
    Map<Long, User> userMap = users.stream()
        .collect(Collectors.toMap(User::getId, u -> u));

    // ④ 内存中组装
    return orders.stream().map(order -> {
        OrderVO vo = new OrderVO();
        vo.setId(order.getId());
        vo.setAmount(order.getAmount());
        User user = userMap.get(order.getUserId());
        vo.setUserName(user != null ? user.getName() : "");
        return vo;
    }).collect(Collectors.toList());
}

方案三:分页处理兜底

如果数据量本身较大,即便优化了 SQL 也要做分页:

public Page<OrderVO> getOrderPage(int pageNum, int pageSize) {
    Page<Order> page = new Page<>(pageNum, pageSize);
    IPage<Order> orderPage = orderMapper.selectOrderPage(page);

    // 收集当前页用户ID,批量查询
    List<Long> userIds = orderPage.getRecords().stream()
        .map(Order::getUserId).distinct().collect(Collectors.toList());
    Map<Long, User> userMap = userMapper.selectUsersByIds(userIds)
        .stream().collect(Collectors.toMap(User::getId, u -> u));

    // 转换
    List<OrderVO> voList = orderPage.getRecords().stream().map(order -> {
        OrderVO vo = new OrderVO();
        vo.setId(order.getId());
        vo.setUserName(userMap.get(order.getUserId()).getName());
        return vo;
    }).collect(Collectors.toList());

    Page<OrderVO> result = new Page<>(orderPage.getCurrent(), orderPage.getSize(), orderPage.getTotal());
    result.setRecords(voList);
    return result;
}

五、优化效果对比

同样的 200 条数据实测结果:

优化阶段 SQL执行次数 耗时
优化前(N+1) 201 次 7800 ms
JOIN 连表 1 次 180 ms
IN 批量查询 2 次 210 ms
分页(20条/页) 2 次 45 ms

从 8 秒降到 45 毫秒,提速超过 170 倍

六、经验总结

  • 永远不要在循环里查数据库。哪怕数据量只有几十条,也要改掉这个习惯。
  • 先查日志确认问题,不要靠猜。打开 SQL 日志或使用 APM 工具(如 SkyWalking、Pinpoint)可以快速定位。
  • 关联数据不多时优先 JOIN,JOIN 字段有索引的情况下性能很稳定。
  • 多对多或动态条件时用 IN 批量,配合内存 Map 组装,代码略多但扩展性好。
  • 大列表必须加分页,前端限制单页大小,后端做好兜底。
  • 定期做慢查询巡检,可以在数据库侧配置 slow_query_log 自动捕获。

N+1 问题是一个非常基础但又极其容易被忽略的性能杀手。在项目初期数据量小时几乎不会被注意到,一旦上了生产、积累了大量数据,就会成为拖垮接口的罪魁祸首。希望这篇文章能帮助大家提前避坑。

📡 来源:个人实战经验整理,2026-04-17

发表评论

苏ICP备18039580号-2