Skip to content

二级索引找到了订单,MySQL 为什么还要查近两千行? ​

订单列表只显示 20 条,SQL 也有 LIMIT 20,执行计划还写着用了索引。按直觉,数据库至多翻 20 条索引记录,再取 20 行数据就够了。可我在一张十万行的测试表上跑这条查询,EXPLAIN ANALYZE 显示:索引扫描实际交出了 1891 行,上层过滤后才剩 20 行。

sql
SELECT id, created_at, total
FROM orders
WHERE user_id = 2 AND status = 1
ORDER BY created_at DESC
LIMIT 20;

表上原本有 (user_id, created_at) 索引。它很适合先定位用户,再按下单时间从新到旧走。问题是,status 不在这个索引里。数据库每拿到一条候选订单,就得知道它是不是 status = 1;而要回答这个问题,它往往得沿索引里保存的主键,再去找真正的订单行。

先别急着加索引。我们把这 1891 条是怎么来的算清楚,再看不同改法到底省下了哪一步。

先把订单造出来 ​

实验在本机一套隔离的 MySQL 9.6.0 上做:临时数据目录,只开放 Unix socket,不连接生产数据库。InnoDB 页大小是 16 KiB。建表和生成数据的完整 SQL 放在本文后面,关键结构如下:

sql
CREATE TABLE orders (
  id BIGINT NOT NULL PRIMARY KEY,
  user_id INT NOT NULL,
  status TINYINT NOT NULL,
  created_at DATETIME NOT NULL,
  total INT NOT NULL,
  padding VARCHAR(100) NOT NULL,
  KEY idx_user_created (user_id, created_at)
) ENGINE=InnoDB;

padding 是为了让完整订单行与索引项的宽度有明显差别,不参与查询。数据不是随手随机生成的:订单编号从 1 到 100000,user_id = n % 10,只有 n % 97 = 0 时 status = 1,创建时间按秒递增。这样每次重建,用户 2 都有 10000 条订单,其中 103 条符合状态条件。没有随机种子,也不需要靠某次抽样碰巧复现慢查询。

先看索引已经帮我们做了什么。user_id = 2 可以定位到索引中相邻的一段记录;created_at DESC 可以让扫描从这一段的时间末端反向进行,取出的顺序正好是页面需要的顺序,因此不必为了这条查询再把全部候选结果排序。LIMIT 20 则告诉执行器:凑齐 20 条合格订单就停。

它没有说“只检查 20 条”。若最新的 20 条里只有一条 status = 1,扫描还得继续往前走。

1891 这个数怎么算出来的 ​

本实验每 97 个编号才有一个状态为 1 的订单,但用户 2 的订单每 10 个编号才有一条。两组条件同时满足时,用户 2 的合格订单编号间隔 970。因为创建时间随编号递增,最新的合格编号是 99523(生成数据时 id = n + 1),第 20 个是 81093。为了从用户 2 的最新订单一直走到第 20 个合格订单,索引扫描会覆盖这段里的 1891 个用户 2 的编号。

这不是从运行时间倒推出来的猜测:先按数据生成规则算,再看执行器输出,两个数对上了。原索引路径的树形计划可简化成:

如果不看这个刻意安排的编号规律,只用比例粗估,也能看出为什么 LIMIT 20 不等于检查 20 条。用户 2 的 10000 条订单里,目标状态有 103 条,占约 1.03%。假定状态在时间上分布均匀,要凑 20 条,平均会碰到大约 20 ÷ 1.03% ≈ 1942 条候选订单。实际是 1891,因为这里的状态按固定间隔出现,不是随机抽样。这个估算只用于判断数量级;真实业务的状态往往随时间变化,例如刚下的单多是“待支付”,老订单多是“已完成”,不能拿全表平均比例替代最近一段的分布。

text
Limit: 20 row(s)                         actual rows=20
  Filter: (orders.status = 1)            actual rows=20
    Index lookup on idx_user_created     actual rows=1891

actual rows=1891 是扫描迭代器交给上层的记录数,不是“读了 1891 个磁盘页”,更不是“发生了 1891 次物理 I/O”。这些索引页和聚簇索引页可能已经在缓冲池里;多条记录也可能落在同一页。EXPLAIN ANALYZE 能说明执行路径经过了多少行以及这次运行的时间,不能单凭这个数字算出磁盘寻道次数。

还有一个细节:普通 EXPLAIN 里的 rows 是优化器根据统计信息做的估算,EXPLAIN ANALYZE 中的 actual rows 才是这次执行时各迭代器返回的行数。后来给这张表建覆盖索引时,传统计划对 user_id = 2 AND status = 1 估计约 5221 行,实际全表数出来只有 103 行;这条带 LIMIT 20 的查询最终只取了其中 20 行。三种数字各说一件事,不能把 rows=5221 当成真实扫描量,也不能用最外层的 actual rows=20 掩盖下层的 1891。

估计偏差会影响选哪个索引,尤其是多列相关性强、数据分布很不均匀的时候。ANALYZE TABLE 能更新统计信息,但它没有把真实查询执行一遍,也不能保证估计恰好等于实际。遇到“明明有索引却选了另一条”的情况,先比较候选计划的估计与实际,别只盯着 key 一列。本文用 FORCE INDEX 做对照,是为了让两条访问路径都跑出来;生产 SQL 中长期强制索引,要承担数据分布变化后计划不再合适的风险。

但它足以解释一个常见误判:命中索引、只返回 20 行,并不等于只检查 20 行。如果数据分布换了,比如每两条就有一条目标状态,扫描到第 20 条结果的距离也会随之缩短。

从二级索引叶子到订单行 ​

一张 InnoDB 表按聚簇索引组织行。这里 id 是主键,聚簇索引的叶子记录含订单的完整行。idx_user_created 是另一棵树,其叶子记录里有 user_id、created_at,还带有对应行的主键 id。可以把同一条订单的两处记录想成:

text
二级索引 idx_user_created 的叶子:user_id | created_at | id
                                                ↓ 用 id 再查
聚簇索引 PRIMARY 的叶子:      id | user_id | status | created_at | total | padding

这里画的是信息关系,不是磁盘页的逐字节布局。查询沿二级索引找到某个候选项时,能直接读到 id 和 created_at,却读不到 status 和 total。它要判断状态、取金额,就需要用 id 进入聚簇索引。这第二次查找,通常叫“回表”。

注意回表解决了两个不同的问题:一是判断行是否满足条件,二是拿到要返回的列。把这两件事混成一个“能不能覆盖”,后面很容易把优化方向选错。原查询中的 1891 个候选项需要检查 status;其中只有 20 条最后被返回,total 也只对合格结果有用。到底每个候选项读取了哪些内部字段、何时取完整行,要结合具体执行计划和引擎实现看,不能把上层迭代器行数机械地等同于完整回表次数。

主键为什么也出现在二级索引?因为二级索引得有办法指回行。这个设计的另一个后果是:主键很宽时,不只聚簇索引变大,每棵二级索引也会跟着存更宽的主键。索引页大小一定时,每页能放的记录可能变少;缓存能容纳的记录也可能减少。这里说的是方向,不是“主键每多一个字节,查询必慢多少毫秒”的换算公式,页填充率、压缩和访问分布都会影响结果。

回表为什么经常被描述成“随机读”?因为按时间排列的二级索引项,它们的主键未必相邻。顺着二级索引走很整齐,拿到一串主键再去聚簇索引找行时,目标页可能散在别处。不过“可能散”不等于“每条都打磁盘”:小表、热数据、相邻主键和足够大的缓冲池,都可能使这些页早在内存里。本实验用单机十万行数据,最有把握的结论是查找路径和迭代器行数;它不能模拟一个缓存不够用、并发写入持续发生的生产库。

SELECT 少拿两列,路径就变了 ​

现在把查询改成只取 id, created_at,同时暂时去掉 status 条件:

sql
SELECT id, created_at
FROM orders FORCE INDEX (idx_user_created)
WHERE user_id = 2
ORDER BY created_at DESC
LIMIT 20;

这两个返回列都在二级索引记录里,按用户与时间取前 20 条也能在索引上完成。此次计划的扫描迭代器返回 20 行,传统 EXPLAIN 的 Extra 可见 Using index。这里的 “Using index” 指覆盖索引读取:查询需要的信息在索引里凑齐了,正常情况下不必为了补 total 再查聚簇索引。

这个对照故意同时去掉了 status,所以它不是对原 SQL 的“等价优化”。如果只删掉 total,仍保留 WHERE status = 1,原索引照样缺少判断状态所需的列。看覆盖索引时,不能只盯 SELECT 列表;WHERE、排序与连接使用的列也要算进去。

把 status 放在什么位置 ​

我又加了一条索引:

sql
ALTER TABLE orders
  ADD KEY idx_user_status_created (user_id, status, created_at);

原查询的 user_id 和 status 都是等值条件。数据库可以直接定位到这两个值组成的那一小段索引记录,再按 created_at 逆向取最近的 20 条。此次运行中,新索引的扫描迭代器只交出 20 行,原索引仍是 1891 行;未写 FORCE INDEX 时,优化器也选了新索引。

total 仍不在新索引里,所以结果中的金额仍需查聚簇索引。新索引解决的主要问题,是先把候选范围从“用户 2 的订单”收紧为“用户 2 且状态为 1 的订单”。不能把这个结果写成“彻底消除了回表”。

如果把列顺序改成 (user_id, created_at, status) 呢?这条索引也保存了 status,但在当前 ORDER BY created_at DESC LIMIT 20 查询中,created_at 后面的状态值没有把扫描范围收紧为仅状态 1。实验里它仍然走过 1891 条记录,传统计划显示 Using where; Backward index scan,没有显示 Using index condition。因此“索引里有某列”与“这列能把扫描范围变小”不是一回事。

列顺序不是背“等值、排序、范围”口诀就完事。要拿实际 SQL 看:前面的列能锁定哪一段,后面的列是在这一段里维持顺序、过滤候选,还是只能在读取完整行后再判断。

Using index condition 又省了什么 ​

这里很容易把两个英文提示看反:Using index 是覆盖读取;Using index condition 是索引条件下推(ICP)。后者仍可能要读取完整行,只是先在二级索引项上检查一部分条件,没过关的就不必再去找聚簇索引行。

为了把 ICP 单独看出来,我把查询换成一个时间范围,并继续从索引外取 total:

sql
SELECT id, total
FROM orders FORCE INDEX (idx_user_created_status)
WHERE user_id = 2
  AND created_at >= '2026-01-01 10:00:00'
  AND status = 1;

在 (user_id, created_at, status) 索引里,user_id 和时间范围先找到可扫描的一段。status 也在索引项里,虽然它排在时间范围之后,不能把这一段直接缩成仅状态 1,却可以在决定是否取完整行前先检查。此次数据中,这个时间范围覆盖用户 2 的 6400 条订单,其中 66 条状态合格。

开启 index_condition_pushdown 时,传统计划显示 Using index condition,树形计划的索引扫描向上交出 66 行。关闭该开关,同一查询的扫描迭代器交出 6400 行,再由上层过滤成 66 行。在保存的这次原始输出里,两次迭代器耗时分别约 0.73 毫秒与 5.47 毫秒;先前另一轮运行是约 1.54 毫秒与 7.14 毫秒。两个时间对照都只是在本机短时间内测得,不能推广成 ICP 在业务库里的固定加速倍数。更可靠的观察是过滤发生的位置变了,而这正是 ICP 要解决的事。

sql
SET optimizer_switch='index_condition_pushdown=on';
EXPLAIN ANALYZE
SELECT id, total FROM orders FORCE INDEX (idx_user_created_status)
WHERE user_id=2 AND created_at>='2026-01-01 10:00:00' AND status=1;

SET optimizer_switch='index_condition_pushdown=off';
EXPLAIN ANALYZE
SELECT id, total FROM orders FORCE INDEX (idx_user_created_status)
WHERE user_id=2 AND created_at>='2026-01-01 10:00:00' AND status=1;

这里用 FORCE INDEX 是为了让两个开关下都走同一条二级索引,单独观察 ICP 的变化;它不代表生产查询应该长期强制索引。原来的倒序取 20 条查询没有显示 Using index condition,因此也不能把“加了状态列”直接解释成“ICP 一定启动了”。

现在能下什么结论 ​

这组实验能确认三个层次。第一,索引已经把用户与时间顺序用起来,问题出在缺少状态列时,为了凑够 20 条合格结果,要继续扫许多候选项。第二,把状态放在能够参与等值定位的位置,扫描范围从 1891 条缩到 20 条,但因为 total 不在索引里,最终结果仍需要完整行。第三,把状态放在范围列后面,有机会通过 ICP 在回表前淘汰候选,但要以真实执行计划为准,不能只看建索引语句猜。

那索性把 total 也放进去呢?我在同一张十万行表上又建了 (user_id, status, created_at, total)。这次传统计划出现 Using index,树形计划写的是 Covering index lookup,扫描仍只交出 20 行。这个结果证明:在这次静态数据查询里,索引已包含所需字段,取金额不再要求常规的聚簇索引查找。

代价也能看到一部分。mysql.innodb_index_stats 中,三列索引 (user_id,status,created_at) 报告 145 个叶子页;四列索引报告 170 个叶子页。二者总页数这次都报告 225 页,可能受分配粒度和树形结构影响,所以不能只引用总页数就说“加一列没有空间成本”。这个统计也只是当前数据和构建状态的快照,不能拿 170/145 直接预言业务库的索引大小。金额如果经常变化,索引项也要随之更新;本次实验没有压测写入,无法给出吞吐损失的数字。

覆盖还有个容易被忽略的例外。InnoDB 的二级索引记录没有聚簇索引记录里的事务版本信息。并发更新后,如果二级索引记录带删除标记,或者索引页在读取事务开始后被更新,引擎可能仍需去聚簇索引检查可见版本。也就是说,执行计划中的 Using index 表示这条查询具备覆盖条件,不是“任何并发情况下都绝不会访问聚簇索引”的保证。实验表几乎不更新,不能用这次读取替并发写入场景背书。

因此不能据此宣称“应该给所有订单列表建 (user_id,status,created_at,total)”。这个取舍还要看查询频率、写入量、数据分布、缓存命中和索引页占用;本实验的十万行数据与一次耗时不够回答生产环境值不值得。

排查自己的 SQL 时,我会先看四件事:查的列与过滤列是否都在索引里;扫描迭代器实际经过多少行;哪些条件是在取完整行前检查的;如果再建一条索引,写入和空间成本由谁承担。把这几件事说清,Using index、Using index condition 和“回表”才不会变成三个看上去很懂、其实互相混淆的标签。

实验怎么重跑 ​

完整实验包可下载:建表和造数、对照查询、ICP 开关实验、覆盖索引实验、2026 年 11 月 5 日原始输出。在一套可随时丢弃的 MySQL 9.6.0 实例上,按这个顺序运行四份 SQL;后三份会各自新建索引,重复运行前先执行 setup.sql 重建表。不要对业务库执行这些建表语句。实例页大小为 16384 字节;文中的行数来自这套确定性数据,毫秒数只是单机现场读数。

参考资料 ​

以上链接当前指向 9.7 手册,实验使用 9.6.0;核心执行路径以本机 9.6.0 实验输出为准。

梧桐和风 · 让复杂的技术讲得更清楚