返回博客

IN 查询扫了 200 万行却只取 10 条——用「日期夹逼」零 DDL 提速 3750 倍

后台管理系统的单据明细页,点开任意一张单据,转圈超过 20 秒,偶尔直接超时。线上 MySQL RDS,表里 400 万行,逻辑上只查 10 条,怎么可能这么慢?

这是一类典型的「冷列 IN 查询」陷阱。EXPLAIN 一看,rows: 2001923——优化器老老实实扫了 200 万行,用时 26 秒。

现象

系统采用常见的两步查询模式:

  1. 第一步:分页聚合,按日期范围 + 单据类型筛选,返回 10 个单据编号;
  2. 第二步:用这 10 个编号做 IN 查询,拉取明细行。

第二步的 SQL 大致如下:

SELECT * FROM sales_records
WHERE del_flag = 0
  AND doc_no IN ('D001', 'D002', ..., 'D010');

EXPLAIN 显示:

type: ref    key: idx_del_docdate_doctype    rows: 2001923
Extra: Using index condition; Using where

rows 超过两百万,查了约半张表。

根因

这张表上只有一个复合索引 (del_flag, doc_date, doc_type),没有 doc_no 的单列索引。

MySQL 的 B-tree 复合索引有一条铁律:范围谓词之后的列无法继续走索引。这里 del_flag = 0 是等值匹配,本可以精确定位,但 del_flag=0 命中的行占全表 95%——相当于把索引的第一层几乎全部选中。之后的 doc_no IN (...) 完全没有索引支撑,只能在这 200 万行里逐行过滤。

坑:软删标志打头的复合索引,等于没有索引

WHERE del_flag=0 是低基数等值条件,del_flag=0 覆盖 95% 的行时,type=ref 实际上是扫描了整个软删分区。后续 AND doc_no IN (...) 列没有索引,MySQL 只能在这个巨大分区里逐行匹配,几百万行扫下去是必然的。

正解

关键洞察:第一步分页查询已经有 doc_date BETWEEN :start AND :end 约束,返回的 10 条单据的 doc_date 全部落在这个区间里。把这个区间的上下界也传给第二步,优化器就能把 del_flag=0 + doc_date BETWEEN 的组合走进索引的 range 扫描,把 200 万行窗口压缩到几百行。

实现步骤

第一步分页:顺手带出日期边界

SELECT doc_no, MAX(doc_date) AS doc_date
FROM sales_records
WHERE del_flag = 0
  AND doc_date BETWEEN :startDate AND :endDate
  AND doc_type IN ('NS', 'SR')
GROUP BY doc_no
ORDER BY MAX(doc_date) DESC
LIMIT 10 OFFSET 0;
-- 返回:[(D001, '2026-04-28'), (D002, '2026-04-27'), ...]

应用层计算边界

$minDate = min(array_column($page1Rows, 'doc_date')); // '2026-04-20'
$maxDate = max(array_column($page1Rows, 'doc_date')); // '2026-04-28'

第二步 IN 查询:加上日期夹逼

SELECT * FROM sales_records
WHERE del_flag = 0
  AND doc_date BETWEEN :minDate AND :maxDate   -- 夹逼关键行
  AND doc_no IN (:d1, :d2, ..., :d10);

修复后 EXPLAIN:

type: range    key: idx_del_docdate_doctype    rows: 912
Extra: Using index condition

扫描行数从 2,001,923 降到 912,实测耗时从 26.26 秒 降至 7 毫秒,提速约 3750 倍。DDL 成本:零。代码改动:6 行 PHP。

正解:把第一步已有的日期范围传给第二步

夹逼列必须满足三点:① 在复合索引里是连续前缀(等值键之后即可);② 随业务实体单调递增(用创建日期,避免 updated_at);③ 可以从第一步查询结果里直接 MIN/MAX 计算,无需额外 DB 往返。

排查清单

遇到「IN 查询只取少量行却很慢」时,依次确认:

  1. EXPLAINrows 是否远大于预期返回行数;
  2. key 是否命中了一个低基数列(del_flagstatustenant_id)打头的复合索引;
  3. SHOW INDEX FROM table 确认 IN 列是否无单列索引;
  4. 业务上是否有自然的时间锚点,且第一步分页已经按该时间过滤——若有,直接套本模式。

一句话外卖

两步查询里,第一步已经「知道」结果集的时间范围,第二步却假装不知道——把这份信息传递下去,就是零成本的索引替代。