【计算基础|MySQL09】索引(下):失效与设计
上一篇把 B+树索引的结构、聚簇与非聚簇、回表讲透了。那篇留了两个坑:为什么建了索引查询还是全表扫?联合索引 (a,b,c) 到底哪几列能用上?
这一篇就干两件事:把索引失效的所有常见姿势踩一遍并用 EXPLAIN 验证,再讲联合索引该怎么设计。所有结论都在 MySQL 8.0 上实测,EXPLAIN 输出原样贴出,不背口诀。
环境:Docker 起 MySQL 8.0.46(InnoDB,默认隔离级别 REPEATABLE-READ),表
t_abc10 万行,建了联合索引idx_abc(a,b,c)以及单列索引idx_ct(create_time)、idx_name(name)、idx_phone(phone)。
一、最左前缀原则:联合索引的 B+树是怎么排序的
结论先给:联合索引 (a,b,c) 在 B+树里是先按 a 排序,a 相同再按 b 排,b 相同再按 c 排。所以查询条件必须"从最左列开始、连续不断"地用,中间断了,后面的列就用不上索引。

你看这棵树:整体先按 a 排(1,1,1,1,2,2,3),a 相同的内部才按 b 排,b 相同才按 c 排。这意味着------没有 a,你根本不知道 b 在哪一片区间。
我在 10 万行的 t_abc 上实测五种查询,重点看 key_len(索引用了多少字节,能反推用了几列):
| 查询 | 用到的列 | type | key_len | Extra |
|---|---|---|---|---|
WHERE a=1 AND b=2 AND c=3 |
a,b,c 全用 | ref | 15 | NULL |
WHERE a=1 AND b=2 |
a,b | ref | 10 | NULL |
WHERE a=1 |
a | ref | 5 | NULL |
WHERE b=2 AND c=3(缺 a) |
不用 | ALL | NULL | Using where |
WHERE a=1 AND c=3(跳 b) |
只用 a | ref | 5 | Using index condition |
key_len 为什么是 5、10、15?因为 a/b/c 都是可空 INT:4 字节数据 + 1 字节 NULL 标记 = 5 字节/列。三列全用就是 15。这条是面试手写题 ------给你 key_len 反推索引用了几列。
🔴 重点 :最后一行 WHERE a=1 AND c=3,b 断了,所以 c 用不上索引(key_len 只有 5 = 1 列),但优化器没全表扫------它在 a 的索引上把 a=1 的行都捞出来,再在引擎层用 c 过滤,这就是 Using index condition(索引下推,第三节讲)。
⚠️ 避坑 :WHERE b=2 AND c=3 在网上很多文章里说"优化器会自动补 a",实测 8.0.46 直接 type=ALL, key=NULL 全表扫描,别迷信。
二、索引失效大全:每个都配反例和 EXPLAIN
下面每一条我都建好了对应索引,先让它失效给你看,再给改法。
2.1 对索引列用函数或运算
索引存的是列本身的有序值。你在列外面包一层函数,B+树就没法按原值定位了------等于要求"对索引里每个键都先套一遍函数再比较",只能全表扫。
sql
-- 失效:对 create_time 套 YEAR()
EXPLAIN SELECT * FROM t_abc WHERE YEAR(create_time)=2024;
-- type=ALL key=NULL rows=99875
sql
-- 改法:把函数挪到右边,改成范围
EXPLAIN SELECT * FROM t_abc
WHERE create_time>='2024-01-01' AND create_time<'2024-01-02';
-- type=range key=idx_ct rows=1
同样的还有:
sql
WHERE id + 1 = 2 -- 失效,type=ALL(MySQL 不会帮你化简成 id=1)
WHERE id = 1 -- 改后 type=const,走主键
WHERE LEFT(name,3)='use' -- 失效,type=ALL
✅ 改法口诀:索引列"裸奔",运算/函数都挪到等号右边。
2.2 隐式类型转换:字符串列用数字查
这是生产环境最隐蔽的坑。phone 是 VARCHAR 且建了索引,你却传了个数字进去:
sql
-- 失效:phone 是 VARCHAR,却用数字 13800000000 查
EXPLAIN SELECT * FROM t_abc WHERE phone=13800000000;
-- type=ALL key=NULL rows=99875(possible_keys 里其实有 idx_phone,但 key=NULL)
-- 正确:传字符串
EXPLAIN SELECT * FROM t_abc WHERE phone='13800000000';
-- type=ref key=idx_phone key_len=83 rows=1
🔴 为什么 :MySQL 的类型转换规则是"把字符串转成数字"而不是"把数字转成字符串",等价于 WHERE CAST(phone AS UNSIGNED)=138...------又在索引列上套函数了,索引直接报废。key_len=83 也对得上:VARCHAR(20) utf8mb4 最多 20×4=80 字节 + 2 字节长度 + 1 字节 NULL 标记 = 83。
⚠️ 关联查询同理 :两张表关联字段字符集不一致(一个 utf8mb4 一个 utf8),会发生隐式字符集转换,被转的那一侧索引失效。建表时全库统一 utf8mb4 能省掉一堆事故。
2.3 LIKE 左模糊
sql
-- 左模糊:索引彻底用不了
EXPLAIN SELECT * FROM t_abc WHERE name LIKE '%user%';
-- type=ALL possible_keys=NULL key=NULL
-- 右模糊:前缀固定,索引能用(但还要看选择性)
EXPLAIN SELECT * FROM t_abc WHERE name='user_55555';
-- type=ref key=idx_phone rows=1
⚠️ 一个容易被忽略的真相 :右模糊 LIKE 'xxx%' 只是"有资格"走索引,走不走还得优化器算成本。我实测 name LIKE 'user_55%' 时 possible_keys 里列出了 idx_name,但 key=NULL------因为这批数据里 user_55% 命中了大约一半的行,优化器觉得"回表比全表扫还贵",干脆放弃索引。左模糊是"连候选都进不了"(possible_keys=NULL),右模糊是"候选里有但优化器嫌它贵",两者性质不同。
2.4 OR 连接了非索引列
sql
-- a 有索引、status 没索引,OR 一下整个废掉
EXPLAIN SELECT * FROM t_abc WHERE a=1 OR status=2;
-- type=ALL key=NULL rows=99875
OR 两边只要有一个列没索引,优化器就不敢用索引(因为另一个分支必须全表扫,索性一起扫)。改法是拆成 UNION:
sql
EXPLAIN SELECT * FROM t_abc WHERE a=1
UNION
SELECT * FROM t_abc WHERE status=2;
-- 第一行 type=ref 用 idx_abc,第二行 status 仍全表扫(它没索引)
2.5 NOT IN / != / <>
sql
EXPLAIN SELECT * FROM t_abc WHERE a != 1;
-- type=ALL key=NULL filtered=50.00
EXPLAIN SELECT * FROM t_abc WHERE a NOT IN (1,2,3);
-- type=ALL key=NULL filtered=50.00
不等于这类条件,索引通常帮不上忙------因为"不等于 1"意味着要扫除了 1 以外的几乎所有行,优化器一算还不如全表扫。IS NOT NULL 同理,走不走取决于选择性。这类写法本身就说明你在查一大半数据,业务上往往该换个设计。
2.6 失效场景速查表
| 失效姿势 | 反例 | 改法 |
|---|---|---|
| 违反最左前缀 | WHERE b=2(缺 a) |
调整索引列顺序或补条件 |
| 索引列套函数 | WHERE YEAR(t)=2024 |
改范围 t>=.. AND t<.. |
| 索引列运算 | WHERE id+1=2 |
改 id=1 |
| 隐式类型转换 | WHERE varchar_col=123 |
传字符串 '123' |
| 隐式字符集转换 | 关联表字符集不一致 | 全库统一 utf8mb4 |
| LIKE 左模糊 | WHERE name LIKE '%ab' |
右模糊 'ab%',或全文索引 |
| OR 非索引列 | a=1 OR b=2(b 无索引) |
改 UNION 或给 b 加索引 |
| NOT IN / != | WHERE a != 1 |
评估业务,必要时换思路 |
三、索引下推 ICP:把过滤尽量往存储引擎推
Using index condition 不是摆设,它背后是 Index Condition Pushdown(索引下推,MySQL 5.6 引入)。
没有 ICP 时:存储引擎按联合索引最左列把记录捞出来、回表拿到整行,再交给 Server 层逐行过滤剩余条件。有 ICP 时:能在索引里判断的条件,就在索引层先过滤掉,不满足的根本不回表------省掉的是昂贵的随机回表 I/O。

实测对比(联合索引 idx_abc(a,b,c),查 a=1 AND b>50):
sql
SET optimizer_switch='index_condition_pushdown=off';
EXPLAIN SELECT * FROM t_abc WHERE a=1 AND b>50;
-- Extra: Using where ← 关了ICP
SET optimizer_switch='index_condition_pushdown=on';
EXPLAIN SELECT * FROM t_abc WHERE a=1 AND b>50;
-- Extra: Using index condition ← 开了ICP
🔴 怎么认 :EXPLAIN 的 Extra 列出现 Using index condition 就是 ICP 生效了。这是个 5.6 之后默认开启的优化,你不用配,但看到它要知道它在帮你省回表。
四、联合索引到底怎么设计
失效讲完了,正面问题来了:一个表要建哪些联合索引?五条原则,按优先级排。
4.1 等值在前,范围在后
联合索引里,范围查询列(>、<、BETWEEN、LIKE)之后的列用不上索引。所以:
- 经常
WHERE a=? AND b=? AND c>?→ 索引建(a,b,c),等值的 a、b 在前,范围的 c 在最后。 - 别建
(a,c,b)然后指望 b 能用上------c 是范围,后面的 b 直接断档。
4.2 区分度高的列往前放
SHOW INDEX 里有个 Cardinality(基数),是索引里不同值的估算个数。Cardinality / 总行数 = 选择性,越接近 1 越好 。我实测 t_abc:
| 索引列 | Cardinality | 含义 |
|---|---|---|
| PRIMARY(id) | 99875 | 接近总行数,选择性极高 |
| idx_phone | 97557 | 手机号几乎唯一,好 |
| idx_name | 63531 | 用户名较高 |
| idx_abc(a) | 101 | a 只有 ~100 个值,选择性差 |
| idx_ct(create_time) | 355 | 一天一个值,低 |
性别、状态这种只有两三个取值的列,单独建索引基本没用(优化器嫌它选择性差,宁可靠全表扫),但放进联合索引当末位列是有价值的------前面的高选择性列已经把范围缩得很小了。
⚠️ Cardinality 是估算值 ,由采样统计得来,数据大改后可能不准。跑一句 ANALYZE TABLE t_abc; 让它重新采样。
4.3 高频查询列优先覆盖
把最常出现在 WHERE、ORDER BY、GROUP BY 里的列挑出来。别给很少查的列单独建索引。
4.4 尽量用覆盖索引
如果一个查询需要的列全都在某个索引里 ,就不用回表了,EXPLAIN 的 Extra 会显示 Using index:
sql
EXPLAIN SELECT phone FROM t_abc WHERE phone='13800000000';
-- type=ref key=idx_phone Extra=Using index ← 不回表
因为要查的 phone 本身就在 idx_phone 索引叶子节点上,直接从索引拿,连主键都不用回。设计联合索引时,把高频 SELECT 的列也"塞"进索引末尾,就是常见的覆盖索引技巧。
4.5 单表索引别贪多
每多一个索引,写操作(INSERT/UPDATE/DELETE)都要多维护一棵 B+树,占用磁盘和 buffer pool。经验值:单表索引不超过 5 个 。上线后用 sys.schema_unused_indexes 找从来没用过的索引定期删。
五、几个被传歪了的结论
5.1 "单表不能超过 2000 万行"
这是谣言的讹传。真实依据是 B+树的高度:InnoDB 一个数据页 16KB,若单行不大,3 层 B+树大约能支撑 2000 万行左右,4 层能到几十亿 。但"2000 万"不是红线,它取决于单行大小和查询模式------单行越窄,一个页能塞的键越多,树越矮。千万级单表加好索引在生产里非常常见,真正要考虑分表的是到了亿级、或者写入压力大的时候。分分两种:垂直拆分 (按列把宽表拆开)和水平拆分(按行哈希/范围打散到多表)。
5.2 索引与排序:filesort 不一定真在"排文件"
ORDER BY 的列如果是索引列、且顺序和索引一致,就能直接用索引的有序性,不额外排序:
sql
-- create_time 有 idx_ct
EXPLAIN SELECT * FROM t_abc ORDER BY create_time LIMIT 10;
-- type=index key=idx_ct Extra=NULL ← 没 filesort
-- status 没索引
EXPLAIN SELECT * FROM t_abc ORDER BY status LIMIT 10;
-- type=ALL Extra=Using filesort
⚠️ Using filesort 听着像"磁盘文件排序",其实多数情况是在内存里用快排/归并排的,数据量大才会落磁盘。它不是洪水猛兽,但能靠索引消除就消除。
🔴 MySQL 8.0 的坑要记 :ORDER BY a ASC, b DESC 这种升降混合,在 8.0 之前用不上联合索引(因为索引默认全是 ASC);8.0 开始支持降序索引 ,建索引时写 KEY idx(a ASC, b DESC) 就能匹配混合排序。
5.3 索引与 GROUP BY
GROUP BY 的列有索引时,能直接利用索引有序性分组,避免 Using temporary;没索引就会建临时表,数据量大时很贵。这条和排序同理。
5.4 强制/忽略索引
sql
SELECT * FROM t_abc FORCE INDEX(idx_phone) WHERE ...; -- 强行用某索引
SELECT * FROM t_abc IGNORE INDEX(idx_ct) WHERE ...; -- 避开某索引
⚠️ 不到万不得已别用。优化器基于成本的判断通常比你拍脑袋准,FORCE INDEX 是在跟未来的数据分布赌命------数据量一变,你强推的索引可能就变成最差选择了。
5.5 索引监控
sql
-- 从来没用过的索引(MySQL 8 的 sys 库)
SELECT * FROM sys.schema_unused_indexes;
-- 索引使用统计(读多还是写多、是否真被命中)
SELECT * FROM sys.schema_index_statistics;
上线后定期跑这两个,把"建了等于没建"的索引删掉,给写入减负。
六、面试怎么考
| 面试题 | 一句话答 |
|---|---|
| 最左前缀原则 | 联合索引按 a,b,c 排序,查询必须从最左列连续匹配,断了后面用不上 |
| key_len 怎么算 | 反推用了几列;INT 可空=5,VARCHAR(n) utf8mb4=4n+2+1 |
| 索引失效场景 | 函数/运算、隐式类型转换、左模糊、OR 非索引列、违反最左前缀、!=/NOT IN |
| 什么是 ICP | 5.6+ 把索引能判断的条件下推到引擎层,减少回表;Extra=Using index condition |
| 联合索引设计 | 等值在前范围在后、高选择性在前、覆盖索引、单表不超 5 个 |
| filesort 是文件排序吗 | 不是,多为内存排序;能靠索引消除最好 |
| 单表 2000 万行 | 讹传,取决于行宽和查询,千万级加好索引没问题 |
总结:你真正需要记住的 8 件事
- 联合索引
(a,b,c)先按 a 排、再按 b、再按 c,查询必须从最左列连续匹配。 key_len是手测"索引用了几列"的利器:可空 INT=5,三列全用=15。- 索引列套函数、做运算、被隐式类型转换,都会让索引报废------列要"裸奔",变换挪到右边。
- 字符串列别用数字查;关联表字符集要统一 utf8mb4。
- 左模糊
%xx用不了索引,右模糊xx%有资格但优化器看选择性决定。 Using index condition= ICP 在帮你省回表,Using index= 覆盖索引不回表。- 联合索引:等值在前范围在后、高选择性在前、尽量覆盖高频列、单表别超 5 个。
- 单表 2000 万行是讹传;filesort 多半是内存排序;FORCE INDEX 不到万不得已别用。
验证清单
- 建一张
(a,b,c)联合索引表,亲手跑出 key_len = 15/10/5 的三种组合 - 制造一次
WHERE b=2(缺 a)确认 type=ALL - 用
WHERE phone=138...(数字)和'138...'(字符串)对比 key 的有无 - 开关
optimizer_switch观察Using index condition的出现与消失 - 跑一次
SHOW INDEX,看懂 Cardinality 和选择性 - 给一个
ORDER BY无索引列观察Using filesort,加索引后消失 - 查一次
sys.schema_unused_indexes认识这张表
参考资源
- MySQL 8.0 Reference Manual --- Index Condition Pushdown: dev.mysql.com/doc/refman/...
- MySQL 8.0 Reference Manual --- Optimization and Indexes: dev.mysql.com/doc/refman/...
- MySQL 8.0 Reference Manual --- DESC Indexes: dev.mysql.com/doc/refman/...
下篇预告:《【工程实战】explain 与慢查询优化》------理论背得再熟,上了生产还是得靠 EXPLAIN 和慢查询日志抓现行。下一篇我会在 Docker 里灌 100 万行订单数据,开慢日志,把"全表扫描→加索引前后耗时对比""函数导致失效""深分页"这七个真实优化案例逐个跑给你看。