数据库查询慢:N+1问题与索引失效的实战复盘
说起数据库慢查询,我印象最深的一个案子是电商订单列表页。上线第二天,DBA 就发来报警,说接口响应时间从 80ms 飙到了 4.5s。我当时一看日志就笑了——N+1 问题,老熟人了。
那今天我们就从两个最常见的坑入手:N+1 和 索引失效。都是实战中反复遇到的东西,我用自己的复盘经验来说说怎么快速定位、怎么修。
N+1 问题 —— 到底有多坑?
先看一个经典场景。假设我们要查“所有订单以及每个订单的买家信息”。很多人会这么写:
// 伪代码示意
List<Order> orders = orderDao.findAll(); // 第1次查询
for (Order order : orders) { // 循环 N 次
User user = userDao.findById(order.getUserId()); // 每次额外查询
}
第 1 次查订单表,得到 N 条记录。然后循环里每查一次用户表,又是 N 次查询。总共 1 + N 次,N 是订单数。当订单量上千时,数据库连接池瞬间被打满。我在项目中遇到过这种情况,运维直接找我喝茶了。
为什么会这样?其实很多 ORM 框架(比如 Hibernate、JPA)默认的懒加载策略,就是触发一次主查询,再在遍历时逐条发起子查询。你如果不主动优化,它就是默认执行 N+1。
怎么修?—— 批量加载与 join fetch
我个人习惯在写查询时,直接指定关联数据的加载方式。主要有两种路数:
- 使用 JOIN FETCH(或 include):让 ORM 用一条 SQL 把关联表一起查出来。
- 使用批量查询(IN 子句):先查主表,收集 userIds,再用 IN 查用户表。
来看对比:
| 方式 | SQL 执行次数 | 数据量 | 适用场景 |
|---|---|---|---|
| N+1(懒加载) | 1 + N | 少 | 几乎不适用,应避免 |
| JOIN FETCH | 1 | 可能重复数据(笛卡尔积) | 关联表较少、数据量不大 |
| 批量 IN 查询 | 2 | 精确,无重复 | 关联表较多或数据量大 |
我建议优先用批量 IN 查询,因为它可控、不容易产生意料之外的笛卡尔积。特别是当关联的表超过 2 个时,JOIN 很容易爆炸。
// 批量 IN 方式
List<Order> orders = orderDao.findAll();
Set<Long> userIds = orders.stream().map(Order::getUserId).collect(Collectors.toSet());
List<User> users = userDao.findByIdIn(userIds); // 只执行1次 IN 查询
Map<Long, User> userMap = users.stream().collect(Collectors.toMap(User::getId, u -> u));
orders.forEach(o -> o.setUser(userMap.get(o.getUserId())));
这样做,无论 N 多大,数据库只会执行两次查询(一次订单,一次用户)。有的框架还支持批量预加载(@BatchSize),也能减少次数,但我嫌它不够显式,还是自己写 IN 更踏实。
索引失效 —— 你以为走了索引?
另一个坑,就是索引建了,但 SQL 根本没用上。这种问题很隐蔽,第一次遇到时我熬夜到凌晨三点——说来也丢人,就是少了一个 & 符号。
常见的索引失效场景我列了个清单:
- 对索引字段使用函数:
WHERE DATE(create_time) = '2024-01-01'→ 换成WHERE create_time >= '2024-01-01' AND create_time < '2024-01-02'。 - 隐式类型转换:字段是 varchar,但传入数字
WHERE user_id = 123(数据库会做隐式 to_char,索引失效)。 - 不符合最左前缀原则:联合索引
(a, b, c),查询条件只用到 b 和 c,不走索引。 - 使用 LIKE '%keyword':前导通配符会让 B+树无法定位。
- OR 条件中有非索引列:哪怕其中一列有索引,OR 的另一列没索引,整个查询可能全表扫描。
我曾经犯过一个错:在 ORDER BY 的字段上没加索引,结果排序时走了文件排序(filesort),导致慢查询。后来加上组合索引(查询条件字段 + 排序字段)才解决。
实战复盘:一次慢查询排查过程
现在我接到慢查询报警,一般会走这么几步:
- 先看
EXPLAIN输出,检查type是不是ALL(全表扫)或index(遍历索引)。如果是,立刻找索引问题。 - 看
rows列,估算扫描行数。如果一行查询扫了几十万行,肯定不合理。 - 看
Extra有没有Using filesort、Using temporary,这些往往是 SQL 结构问题。 - 打开慢查询日志(slow_query_log),把实际 SQL 拿到测试库跑一遍,用
optimizer_trace看细节。 - 如果发现 N+1,我会用
general_log快速统计查询次数。比如用grep 'SELECT' | wc -l看看发了多少条 SQL。
pt-index-usage 扫掉无用索引。
一张图看懂 N+1 vs 批量加载
下面这张 SVG 图展示了 N+1 问题的执行流程,以及批量加载如何把多次查询压缩成两次。
核心总结:N+1 问题本质是“循环查询”,解决方案是“批量”;索引失效本质是“优化器没找到匹配路径”,解决方案是“让 SQL 的写法与索引设计对齐”。这两个知识点都是实战高频雷区,踩一次就够你记一辈子。