作者 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