适用读者:建过 INDEX(a, b) 却发现查 b 走全表的人;被 key_len 数字绕晕的人;想知道联合索引列顺序到底该按什么排的人
结论先行:INDEX(a, b) 不是「给 a 和 b 各建一棵树」,而是按「先 a 后 b」的字典序建了一棵 B+ 树。想让 b 起作用,必须先能用 a 把扫描范围收窄——这就是最左前缀。它不是优化器的限制,是 B+ 树的物理结构决定的。
阅读时间:约 18 分钟 | 难度:进阶 | 收获:从记录排序规则推出最左前缀、用 key_len 反推用了几列、六种 WHERE 写法的可用性矩阵、以及一套列顺序决策法
目录
- 前言:一条「明明建了索引」的慢查询
- 联合索引在磁盘上是一棵怎样的树
- 最左前缀的严格定义
- 六种 WHERE 写法逐个验证
- 范围查询之后为什么断了
key_len:用了几列的直接证据
- ORDER BY、GROUP BY、DISTINCT 能不能蹭索引
- 索引下推、覆盖索引与最左前缀的关系
- 列顺序怎么排:四步决策
- 前缀索引与函数索引
- Index Skip Scan:8.0 的例外
- 联合索引与多个单列索引怎么选
- 索引不是免费的
- 实战:从一条慢 SQL 到一条索引
- 五个常见误区
- 小结
前言:一条「明明建了索引」的慢查询
线上有一条查询:
SELECT id, uid, kind, ts
FROM events
WHERE kind = 2 AND ts >= '2026-01-01';
表上明明有
INDEX idx_uid_kind_ts (uid, kind, ts),但
EXPLAIN 显示
type = ALL、
key = NULL。开发者的第一反应通常是「索引没生效」或者「统计信息过期」。
真实原因更简单:这条 SQL 没有给
uid。而这棵索引的第一排序键就是
uid,没有
uid 就无法把扫描范围收窄到一个连续区间,优化器只能退化成全表扫描。
这就是最左前缀。下面从磁盘上的排序规则开始,把它推一遍。
联合索引在磁盘上是一棵怎样的树
建一张事件表:
CREATE TABLE events (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
uid BIGINT NOT NULL,
kind TINYINT NOT NULL,
ts DATETIME NOT NULL,
payload JSON,
KEY idx_uid_kind_ts (uid, kind, ts)
) ENGINE=InnoDB;
idx_uid_kind_ts 的叶子项不是「三个独立的键」,而是把三列的值按定义顺序拼成一个复合键,然后按这个复合键排序。排序规则是字典序:
先比 uid
uid 相同,再比 kind
kind 相同,再比 ts
假设表里有这些记录,叶子链表大致是这样的:
(uid=1, kind=1, ts=10:00) → (uid=1, kind=1, ts=10:05)
→ (uid=1, kind=2, ts=09:00) → (uid=1, kind=2, ts=09:30)
→ (uid=2, kind=1, ts=08:00) → (uid=2, kind=3, ts=11:00)
→ ...
注意中间那一行:
uid=1, kind=2, ts=09:00 排在
uid=1, kind=1, ts=10:05 后面,尽管它的
ts 更早。因为排序先看
kind,
kind=2 整体排在
kind=1 之后。
这张图就是最左前缀的全部来源:
- 给定
uid,这一段记录在叶子链表上是连续的,能二分定位;
- 只给
kind,kind=2 的记录散落在每个 uid 段里,不连续,没法定位;
- 给
uid 和 ts、跳过 kind,ts 只在每个 kind 段内有序,跨段无序,也没法定位。
最左前缀的严格定义
最左前缀不是「必须用第一列」,而是:
从索引的第一列开始,连续匹配若干列;一旦跳过某一列,后面的列都不能再用于「定位」。
这里要区分两个动作:
- 定位(seek):用 B+ 树的分支判断把扫描起点收窄到某个叶子区间;
- 过滤(filter):在已经读到的记录上逐条判断条件是否成立。
最左前缀约束的是定位,不是过滤。跳过的列仍然可以参与过滤,只是它不能帮你少读页。
对
INDEX(a, b, c) 来说:
六种 WHERE 写法逐个验证
还是用
idx_uid_kind_ts (uid, kind, ts)。
全等值
SELECT * FROM events
WHERE uid = 1 AND kind = 2 AND ts = '2026-01-01 10:00:00';
三列都能定位,
key_len 是完整长度,
type 通常是
ref 或
const(如果三列组合唯一)。
前缀等值
SELECT * FROM events WHERE uid = 1 AND kind = 2;
能定位到
(1,2) 段。
ts 没用到,但
key_len 会停在
kind 的字节数上。
只用第一列
SELECT * FROM events WHERE uid = 1;
能定位到
uid=1 段,
kind 和
ts 都不能用于定位。
跳过中间列
SELECT * FROM events
WHERE uid = 1 AND ts = '2026-01-01 10:00:00';
只能用
uid 定位。
ts 可能出现在
Extra 的
Using index condition 里,表示它被下推到索引层做过滤,减少了回表次数,但
依然要扫完 uid=1 的全部记录。
很多人在这里产生错觉:看到
Using index condition 就以为
ts 用上了索引。它确实被用上了,但只是用于过滤,不是用于定位。
范围条件
SELECT * FROM events WHERE uid = 1 AND kind > 1;
uid 等值 +
kind 范围,能定位到
uid=1 段里
kind>1 的起点,然后顺序扫到段尾。
type 是
range。
如果再加
ts:
SELECT * FROM events
WHERE uid = 1 AND kind > 1 AND ts = '2026-01-01 10:00:00';
ts 不能定位,因为
kind 是范围,扫描输出在
ts 维度上不再有序。它最多作为 ICP 过滤条件。
IN 与 OR
SELECT * FROM events WHERE uid IN (1, 2) AND kind = 2;
IN 在优化器里通常被展开成多个等值区间,仍然能用
uid, kind 定位,
type 可能是
range。
SELECT * FROM events WHERE uid = 1 OR uid = 2;
同一列上的
OR 可以合并成
IN 式的范围,仍然能用索引。
SELECT * FROM events WHERE uid = 1 OR kind = 2;
不同列上的
OR,单棵联合索引无法同时满足两个分支,优化器可能选
index_merge。但
index_merge 在实践里经常不如一条合适的联合索引稳定,不要把它当成常规方案。
范围查询之后为什么断了
这是最容易被忽略的一条规则。原因还是排序:
对
INDEX(a, b, c) 来说,只有在
a 的取值被「钉死」之后,
b 才在整个扫描区间内有序;只有在
b 也被钉死之后,
c 才有序。
一旦
b 变成范围(
b > 2、
b BETWEEN 1 AND 5、
b LIKE 'abc%'),扫描输出里
b 取了多个值,
c 在这些
b 值之间不再全局有序。B+ 树没法用
c 做二分定位,只能逐条比较。
这条规则直接决定列顺序:
等值列放前面,范围列放最后。
如果业务里
ts 的等值查询也很常见,可以考虑把索引改成
(uid, ts, kind)。此时
uid = 1 AND ts = ? 能精确定位,
kind 退化为过滤;而原来的
(uid, kind, ts) 在
uid = 1 AND kind = ? 下更精确。两条查询谁更常见,就优先照顾谁。
key_len:用了几列的直接证据
EXPLAIN 的
key_len 表示「本次实际用到的索引字节数」。它不能直接告诉你用了哪几列,但结合列类型可以反推。
常见类型的字节数:
对
idx_uid_kind_ts (uid BIGINT NOT NULL, kind TINYINT NOT NULL, ts DATETIME NOT NULL):
- 只用到
uid:key_len = 8;
- 用到
uid, kind:key_len = 9;
- 三列全用:
key_len = 14。
如果
key_len 比你预期的小,通常只有三种原因:后面的列没写进
WHERE、列顺序不满足最左前缀、或者中间出现了范围条件。
key_len 有一个坑:它统计的是「索引键的字节数」,不包含主键后缀。二级索引叶子虽然带主键,但
key_len 不会把主键算进去。所以不要用
key_len 去推断「二级索引项的总大小」。
ORDER BY、GROUP BY、DISTINCT 能不能蹭索引
索引本身就是有序的,所以排序、分组、去重都有机会免掉额外操作。
ORDER BY
-- 能用索引顺序,免 filesort
SELECT * FROM events WHERE uid = 1 ORDER BY kind, ts;
-- 不行:跳过了 kind
SELECT * FROM events WHERE uid = 1 ORDER BY ts;
-- 不行:uid 是范围,kind 不再全局有序
SELECT * FROM events WHERE uid > 1 ORDER BY kind;
规则和
WHERE 一样:
ORDER BY 的列必须是索引列的「最左前缀的延续」,并且前面的列必须是等值条件。如果
Extra 出现
Using filesort,说明排序没蹭上索引,只能先取出结果再排。
方向也有讲究:
ORDER BY kind ASC, ts ASC 可以正向扫;
ORDER BY kind DESC, ts DESC 可以反向扫,EXPLAIN 里可能显示 Backward index scan;
ORDER BY kind ASC, ts DESC 方向混用,普通索引不行,需要 MySQL 8.0 的降序索引:KEY idx (uid, kind ASC, ts DESC)。
GROUP BY
SELECT kind, COUNT(*) FROM events
WHERE uid = 1 GROUP BY kind;
如果
GROUP BY 的列顺序与索引一致,优化器可以用「松散索引扫描」(
Using index for group-by)或「紧凑索引扫描」,避免建临时表。否则
Extra 会出现
Using temporary。
DISTINCT
SELECT DISTINCT kind FROM events WHERE uid = 1;
DISTINCT 本质上也是分组,能蹭索引时不会出现
Using temporary。
索引下推、覆盖索引与最左前缀的关系
这三个概念经常被混在一起,实际是三件不同的事:
ICP 由
optimizer_switch 里的
index_condition_pushdown 控制,默认开启。
一个组合例子:
SELECT id, uid FROM events
WHERE uid = 1 AND kind = 2 AND ts >= '2026-01-01';
uid, kind 用于定位,ts 用于范围收窄;
Extra 可能是 Using index condition,因为 ts 条件下推;
- 如果
SELECT 只要 id, uid,并且 ts 也在索引里,那么整条查询是覆盖的,Extra 会同时出现 Using index。
注意:
覆盖索引不要求最左前缀。即使
WHERE kind = 2 用不上这棵索引,只要优化器愿意全扫这棵较小的索引,也可能出现
Using index。但那是「扫整棵索引」,不是「定位」,性能是另一回事。
列顺序怎么排:四步决策
不要按建表字段顺序排索引,按查询形态排。一个可执行的顺序:
- 先把等值列放最前:几乎每个查询都会带、且选择性高的列,比如
tenant_id、uid、status;
- 再把排序列放进来:如果某个
ORDER BY 很稳定,放在等值列之后、范围列之前;
- 范围列放最后:
ts、amount > ?、LIKE 'abc%';
- 最后考虑覆盖列:把高频
SELECT 里的少量列追加到末尾,换 Using index。
举两个具体的取舍:
-- 查询 A:WHERE uid = ? AND kind = ? ORDER BY ts
-- 查询 B:WHERE uid = ? AND ts >= ? AND ts < ?
如果 A 远多于 B,用
(uid, kind, ts);如果 B 更多,用
(uid, ts) 或
(uid, ts, kind)。不要试图用一条索引同时完美服务两种形态,通常会让两边都退化。
区分度(selectivity)不是第一优先级。区分度再高,如果列不在最左且不能被定位,也用不上。正确顺序是:先满足查询形态,再在同等条件下优先高区分度。
查看区分度可以用:
SELECT COUNT(DISTINCT uid) / COUNT(*) AS uid_sel,
COUNT(DISTINCT kind) / COUNT(*) AS kind_sel
FROM events;
SHOW INDEX FROM events 里的
Cardinality 是优化器的估算值,不是精确值。大批量导入或频繁增删后,先
ANALYZE TABLE events 再观察。
前缀索引与函数索引
前缀索引
ALTER TABLE users ADD KEY idx_email (email(20));
它只对
email 的前 20 个字符建索引,适合超长字符串。代价是:
- 只能用于前缀匹配(
LIKE 'abc%'、等值查询的定位仍然可以);
- 不能覆盖:索引里只有前 20 个字符,要返回完整
email 仍然要回表;
ORDER BY email 不一定能完全走索引,因为前缀有序不等于全值有序。
选择前缀长度的常用办法是比较「前缀区分度」与「全值区分度」:
SELECT COUNT(DISTINCT LEFT(email, 20)) / COUNT(*) AS prefix_sel,
COUNT(DISTINCT email) / COUNT(*) AS full_sel
FROM users;
前缀区分度接近全值区分度时,再短一点也可以。
函数索引
MySQL 8.0.13 起支持函数索引:
ALTER TABLE events ADD KEY idx_ts_date ((DATE(ts)));
这样
WHERE DATE(ts) = '2026-01-01' 就能走索引,而不必把函数写到
WHERE 里再失效。函数索引的表达式必须被查询逐字匹配,写法不一致时仍然用不上。
Index Skip Scan:8.0 的例外
MySQL 8.0.13 引入了 Index Skip Scan,用于「联合索引的第一列没出现在条件里」的场景。它的大致思路是:既然第一列的不同值不多,就把第一列每个值都当作一个虚拟前缀,分别去查第二列。
-- 索引 (uid, kind, ts)
SELECT * FROM events WHERE kind = 2;
在满足条件时,
EXPLAIN 可能显示
Using index for skip scan。
但它有几个限制:
- 第一列的基数必须较低(不同值不能太多),否则虚拟前缀的数量会爆炸;
- 第一列不能已经有等值或范围条件;
- 需要
optimizer_switch 里的 skip_scan 开启,默认是开的;
- 它只是「让原本不可用的索引变得勉强可用」,通常仍然不如直接建一条
(kind, ...) 的索引。
所以正确的态度是:知道它存在,但不要把它当成「最左前缀失效了」的兜底方案。
联合索引与多个单列索引怎么选
假设有两条查询:
WHERE uid = ? 和
WHERE kind = ?。可以建两个单列索引,也可以建一个
(uid, kind)。怎么选?
判断依据是查询形态的分布,不是「索引越少越好」。如果两条查询频率接近,两个单列索引可能更合适;如果
WHERE uid = ? AND kind = ? 是主流,一条联合索引更划算。
index_merge 的三种形态在
Extra 里表现为:
Using intersect(...):多个索引结果取交集;
Using union(...):取并集;
Using sort_union(...):并集但需要排序。
实践里
index_merge 经常被优化器误选,可以用
optimizer_switch 关闭它做对比,但关闭是全局行为,不建议在生产环境长期这么干。
索引不是免费的
每个二级索引都是一棵独立的 B+ 树,写入时要同步维护:
INSERT 要往每棵索引里插一条;
UPDATE 改了索引列要删旧插新;
DELETE 要在每棵树里标记删除;
- 页分裂、buffer pool、redo/undo 都会被放大。
索引越多,写入越慢,buffer pool 越紧张,DDL 越重。正确的做法不是「为每个查询建一条索引」,而是:
- 先用慢查询日志和
performance_schema 找出真正的慢 SQL;
- 用
EXPLAIN 确认它缺的是定位、覆盖还是排序;
- 只为高频、明确的查询建索引;
- 定期用
sys.schema_unused_indexes 找从未被使用的索引。
实战:从一条慢 SQL 到一条索引
回到开头那条:
SELECT id, uid, kind, ts
FROM events
WHERE kind = 2 AND ts >= '2026-01-01';
第一步:确认现状
EXPLAIN SELECT id, uid, kind, ts
FROM events
WHERE kind = 2 AND ts >= '2026-01-01';
如果
key = NULL、
type = ALL,说明没有可用索引。
第二步:判断查询形态
kind 是等值;
ts 是范围;
uid 没出现,所以原来的 (uid, kind, ts) 用不上。
第三步:设计候选索引
ALTER TABLE events ADD KEY idx_kind_ts (kind, ts);
等值列
kind 在前,范围列
ts 在后,符合规则。
第四步:验证
EXPLAIN SELECT id, uid, kind, ts
FROM events
WHERE kind = 2 AND ts >= '2026-01-01';
预期
type = range、
key = idx_kind_ts、
key_len 等于
kind 加
ts 的字节数。再对比
rows 与
filtered,确认扫描量级符合预期。
第五步:考虑覆盖
如果这条查询频率很高,并且
SELECT 只取
id, uid, kind, ts,可以把
uid 追加到末尾:
ALTER TABLE events ADD KEY idx_kind_ts_uid (kind, ts, uid);
这样
id(主键)和
uid 都在索引里,查询可以变成覆盖索引。代价是索引更大、写入更慢,需要用写入量换读取量。
五个常见误区
误区一:「
(a, b) 建了,查
b 也能用」。不能。要么建
(b, a),要么单独建
(b)。
误区二:「把区分度最高的列放最左一定最好」。区分度只影响筛掉多少行;如果不满足最左前缀,再高的区分度也用不上。
误区三:「在索引列上做运算也能用索引」。
WHERE DATE(ts) = '2026-01-01'、
WHERE uid + 1 = 2 都会让普通索引失效。改成范围查询,或者建函数索引。
误区四:「隐式转换只会影响性能,不会影响结果」。
WHERE varchar_col = 123 会把列转成数字,索引失效;某些边界值还可能产生意外的匹配结果。类型要显式对齐。
误区五:「
Using index condition 说明索引列全用上了」。它只说明有条件下推,不代表最左前缀完整。
小结
- 联合索引是一棵按列顺序排序的树,最左前缀由物理顺序决定;
- 范围列之后的列不能用于定位,只能用于过滤;
- 列顺序 = 等值列 → 排序列 → 范围列 → 覆盖列;
key_len 是「用了几列」的直接证据,但要记得 VARCHAR 的 2 字节长度和可空列的 1 字节;
- 用
EXPLAIN、EXPLAIN ANALYZE 和 performance_schema 验证,别靠感觉。
相关阅读