结论先行:复合索引的列顺序不是「按选择性从高到低排」,而是被「等值列 → 范围/排序列 → 覆盖列」这三段结构锁死的。我见过太多团队把高选择性列放第一列、结果
type还是ALL;也见过有人为了省回表把大字段塞进索引,反而把 P99 拖高。列顺序是复合索引里唯一「改一列全表重建」的高成本决策,值得在动手前把取舍算清楚。
一、现象与背景
去年给一套结算系统做索引治理。核心表 t_settle_detail,规模 4800 万行、单表 27GB、InnoDB、MySQL 8.0.32、8C16G、innodb_buffer_pool_size=8G。业务方给了一条高频查询,说「加个索引就行」:
SELECT id, order_no, amount, status, create_time
FROM t_settle_detail
WHERE merchant_id = ? AND create_time >= ? AND status = 'SUCCESS'
ORDER BY create_time DESC
LIMIT 20;
他们第一版建的是「按选择性排序」的 idx_1 (status, create_time, merchant_id)——因为查了 COUNT(DISTINCT status) 只有 6 个值,merchant_id 有 120 万个,create_time 最分散,于是把最「有用」的 create_time 放中间、merchant_id 放最后。上线后 EXPLAIN 显示 key=idx_1、type=ALL、rows≈4800万,P99 还是 1.9s。加了索引,等于没加。
二、原理:列顺序为什么被「三段结构」锁死
InnoDB 的复合索引是一棵 B+ 树,列按声明顺序做字典序排列,这决定了三件事:
-
最左前缀 + 等值列才能连续命中。只有「从第一列开始、且都是等值(
=/IN)」的列,才能在树上精确定位一个连续区间。一旦中间断了(第一列没出现在 WHERE),后面的列就退化成「扫描时逐行过滤」,EXPLAIN的Extra会出现Using index condition(ICP),但type常常还是range甚至ALL。 -
范围列会「截断」排序能力。范围条件(
>=、BETWEEN、LIKE 'x%')能命中,但它之后的列不能再用于精确定位,只能做过滤。这是「范围列要放最后」的根因。 -
排序复用是「白捡的」。如果索引列顺序恰好和
ORDER BY一致(且前置等值列已固定),引擎就能沿索引顺序直接读、免掉filesort。
把这条 SQL 套进去:merchant_id = ?(等值)、create_time >= ?(范围)、status = 'SUCCESS'(等值)、ORDER BY create_time DESC。正确的列顺序是 (merchant_id, create_time, status):
merchant_id等值 → 定位到该商户的连续区间;create_time范围 + 正好满足ORDER BY create_time DESC(范围列同时当排序列,一次扫描即有序,免 filesort);status放最后做 ICP 过滤(它已经是等值,但排在范围列之后只能过滤,不能定位——这就是「status 明明选择性可以,却必须放最后」的取舍)。
反观他们的 (status, create_time, merchant_id):第一列 status 虽等值,但只定位到 6 个值里的一类,区间里仍混着全部 merchant_id,等于 4800 万行里先按 status 粗筛再逐行找 merchant_id,type 退化、rows 不降。
三、具体做法(可复现)
-
先分类,再排序。把 WHERE / ORDER BY 的列分成「等值 / 范围 / 仅排序 / 仅覆盖」四类,顺序固定为:
(等值列...) , (范围列 或 排序列...) , (覆盖列...) -
等值列内部才谈选择性。多个等值列并存时(如
merchant_id = ? AND shop_id = ?),把选择性高的放前面,减少命中区间大小——「选择性排序」只在等值列这一小段内有效,别把它当成全局规则。 -
范围列与排序列冲突时,优先保排序。若
ORDER BY的列正好是范围列,直接让它兼任,省一次 filesort;本例中create_time同时是范围条件和排序键,是理想情况。若排序列与范围列不同,就要权衡:是接受一次filesort(把范围列放前、排序列放后),还是牺牲范围定位(把排序列放前)——看命中行数:命中几千行以内,filesort 的代价远小于索引扫描,宁可保范围定位。 -
覆盖列要「抠」,别贪。把
SELECT里用到的列加进索引能免回表,但每多一列,B+ 树叶子就宽一截、单页能放的索引条目更少、树更高、随机 I/O 更多。本例只覆盖order_no、amount,status因参与过滤也带上;id是主键本就在二级索引叶子里,不用显式加。 -
验证清单(每次改列顺序后必跑):
EXPLAIN SELECT id, order_no, amount, status, create_time FROM t_settle_detail WHERE merchant_id = 8888 AND create_time >= '2026-01-01' AND status = 'SUCCESS' ORDER BY create_time DESC LIMIT 20;期望:
type=range、key=idx_2、Extra出现Using where; Using index(覆盖)且没有Using filesort、Using temporary。
四、踩过的坑与反直觉
- 「先放高选择性」是新人最常踩的错。选择性只决定「命中区间大小」,决定不了「能不能定位」。本例把
merchant_id(120 万 distinct)放最后,选择性再高也救不了type=ALL。 - 把大字段塞进覆盖索引反而更慢。我们一度为了免回表把
remark(varchar,平均 200 字节)加进索引,单页索引条目从约 380 条掉到 96 条,树高 +1,P99 从 62ms 涨到 141ms;去掉后跌回。覆盖索引不是免费午餐,加列有真实的 I/O 账单(这点和站内讲「删覆盖索引反而更快」的结论一致)。 - ICP(
Using index condition)是「半命中」信号,别当成功。它说明索引建了、但有些列只能扫描后过滤——往往就是列顺序没把该列排到能定位的位置。 - 别用
SELECT *逼自己建「全字段覆盖索引」。那等于复制一张表,写放大 + 空间翻倍,得不偿失;让SELECT明确列字段。
五、列顺序之外的第二个陷阱:低基数首列 + 高基数第二列的「伪精确键定位」
前面讲的是「三段结构」内部怎么排,这里补一个跨段的陷阱,它会让 EXPLAIN 看起来一切正常、但 rows examined 完全不符合预期——低基数首列 + 高基数第二列的组合,InnoDB 可能不做精确键定位,而是把段内扫描当成全量排序。
现象
一张 t_ticket 表 8800 万行,索引 idx_status_tenant_time (status, tenant_id, created_at DESC),查询:
SELECT id, title, created_at
FROM t_ticket
WHERE status = 1 AND tenant_id = 8821
ORDER BY created_at DESC LIMIT 50;
理论上应该是毫秒级(前两列等值精确定位 + 第三列有序 + LIMIT 早停),实测 P99 6.8s,EXPLAIN ANALYZE 的 rows examined ≈ 230 万(不是 50)。
根因
status 只有 3 个值,tenant_id 有几十万个——低基数首列让优化器把 (status=1) 当成「范围前缀」,段内 2960 万行按 tenant_id ASC 顺序扫描,created_at 在段内并不有序(因为第二列 tenant_id 先变),InnoDB 必须扫完整段才能排序,LIMIT 50 的早停完全没生效。
修法
把列序换过来:CREATE INDEX idx_tenant_status_time ON t_ticket (tenant_id, status, created_at DESC)——让高基数列打头,InnoDB 直接定位到 8821 的段(230 万行),段内 status=1 等值过滤 + created_at DESC 有序,rows examined 回落到 ~50,P99 回毫秒级。
本质:「低基数首列」会让优化器把「精确键定位」降级为「段内扫描」,即使第二列也是等值条件。这不是「三段结构」内部的排序问题,而是首列基数决定优化器策略的问题。判断方法:EXPLAIN ANALYZE 看 rows examined 是否接近 LIMIT 值,而不是只看 EXPLAIN 的 type 和 rows(后者是估算值,可能骗你)。
一句话:列顺序不仅要「三段结构内部排对」,还要首列基数够高,否则优化器会走另一条路。
六、写放大账单:每加一列,每行写入都要多维护一棵 B+ 树
前面「覆盖列要抠」讲的是读侧的代价,这里补一个写侧的账单,很多人做列顺序决策时只看读、不看写:
- InnoDB 每棵二级索引都是一棵独立的 B+ 树,每次 INSERT/UPDATE/DELETE 都要维护所有二级索引;
- 经验值:每多一棵二级索引,写入 I/O 增加约 5~8%(参考站内陈景行-742 的覆盖索引治理文中的实测数据);
- 4000 TPS 的表多一棵索引 ≈ 320 次额外 B+ 树操作/s,NVMe 本地盘完全吃得下;但 SSD 上放大 20~30% 写入延迟后,P99 写入会明显变差;
- 取舍规则:读 QPS / 写 QPS > 10 的表,覆盖索引值得加;< 3 的表,优先保写入性能,覆盖索引要抠着加;1~3 之间,看 P99 SLO 定。
一句话:列顺序是「读侧取舍」,索引数量是「写侧账单」,两个维度要一起算,别只看一个。
七、结论与可复用清单
- 列顺序公式:等值列(内部按选择性降序)→ 范围/排序列 → 覆盖列(抠着加)。
- 「选择性排序」只在等值列这一小段内有效,不是全局规则。
- 范围列之后的列只能过滤,不能定位——这是所有列顺序取舍的物理约束。
- 范围列若能兼任
ORDER BY排序列,优先让它兼任,省 filesort。 - 覆盖列要抠:每加一列都付出「叶子变宽、树变高、随机 I/O 更多」的代价,用
EXPLAIN的Extra和 P99 实测验证,别凭感觉。 - 低基数首列 + 高基数第二列会让优化器走「段内扫描」路径,LIMIT 早停失效——首列基数够高是「精确键定位」的前提。
- 写放大账单:每多一棵二级索引,写入 I/O 增加 5~8%;读 QPS / 写 QPS 的比值决定覆盖索引值不值得加。
- 每次改完必跑
EXPLAIN ANALYZE(不是EXPLAIN),盯rows examined是否接近 LIMIT 值,别被EXPLAIN的估算rows骗。
一句话:复合索引的列顺序,本质是在「能定位多少行」和「免多少 filesort / 回表」之间做取舍,而「先放高选择性」把这两件事混成了一件事;同时别忘了「首列基数决定优化器策略」和「索引数量决定写放大」这两个跨段约束。