数据库查询慢: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),导致慢查询。后来加上组合索引(查询条件字段 + 排序字段)才解决。

实战复盘:一次慢查询排查过程

现在我接到慢查询报警,一般会走这么几步:

  1. 先看 EXPLAIN 输出,检查 type 是不是 ALL(全表扫)或 index(遍历索引)。如果是,立刻找索引问题。
  2. 看 rows 列,估算扫描行数。如果一行查询扫了几十万行,肯定不合理。
  3. 看 Extra 有没有 Using filesort、Using temporary,这些往往是 SQL 结构问题。
  4. 打开慢查询日志(slow_query_log),把实际 SQL 拿到测试库跑一遍,用 optimizer_trace 看细节。
  5. 如果发现 N+1,我会用 general_log 快速统计查询次数。比如用 grep 'SELECT' | wc -l 看看发了多少条 SQL。
小技巧:在 ORM 中打开 SQL 打印(如 spring.jpa.show-sql=true),开发阶段就监控查询次数。我习惯在日志里加个阈值,单接口超过 50 次查询就自动告警。
注意:索引不是越多越好。我曾经在一个表上建了 8 个单列索引,结果插入速度变慢,而且优化器反而会选错索引。建议用联合索引代替多个单列索引,并定期用 pt-index-usage 扫掉无用索引。

一张图看懂 N+1 vs 批量加载

下面这张 SVG 图展示了 N+1 问题的执行流程,以及批量加载如何把多次查询压缩成两次。

N+1 问题 vs 批量加载 N+1 方式(慢) 查询订单表(1次) 循环 N 次查用户表 总查询:1 + N 次 数据库连接池打满 批量加载(快) 查询订单表(1次) 收集 userIds → IN 查询(1次) 总查询:2 次(恒定) 连接池轻松 如何选择 N+1 → 立即改用 JOIN FETCH 或批量 IN 查询 索引失效 → 用 EXPLAIN 检查 type、possible_keys,避免函数和隐式转换

核心总结:N+1 问题本质是“循环查询”,解决方案是“批量”;索引失效本质是“优化器没找到匹配路径”,解决方案是“让 SQL 的写法与索引设计对齐”。这两个知识点都是实战高频雷区,踩一次就够你记一辈子。