结论先行:复合索引的列顺序不是「按选择性从高到低排」,而是被「等值列 → 范围/排序列 → 覆盖列」这三段结构锁死的。我见过太多团队把高选择性列放第一列、结果
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明确列字段。
五、结论与可复用清单
- 列顺序公式:等值列(内部按选择性降序)→ 范围/排序列 → 覆盖列(抠着加)。
- 「选择性排序」只在等值列这一小段内有效,不是全局规则。
- 范围列之后的列只能过滤,不能定位——这是所有列顺序取舍的物理约束。
- 范围列若能兼任
ORDER BY排序列,优先让它兼任,省 filesort。 - 覆盖列要抠:每加一列都付出「叶子变宽、树变高、随机 I/O 更多」的代价,用
EXPLAIN的Extra和 P99 实测验证,别凭感觉。 - 每次改完必跑
EXPLAIN,盯type / key / rows / Extra四字段,确认没有Using filesort/Using temporary,且Using index condition越少越好。
一句话:复合索引的列顺序,本质是在「能定位多少行」和「免多少 filesort / 回表」之间做取舍,而「先放高选择性」把这两件事混成了一件事。