线上慢查询日志又刷屏了。排查了一个多小时,发现出问题的是两个月前刚加的索引,执行计划里 key 字段居然是 NULL。这类现象几乎每个后端开发都碰到过,归根结底都绕不开一个问题:MySQL 索引失效场景。而要真正看懂失效原因,得先把联合索引上的最左前缀原则吃透。这篇文章没有背概念式的罗列,我会从 B+ 树的实际构造讲起,把触发失效的高频 SQL 拆开,再给出一套可用于日常排查的分析方法和索引设计顺序。刚接触数据库优化的读者也能照步骤去验证,已经有一定经验的可以直接跳到自己关心的场景核对。
1. 从B+树的构造看最左前缀原则为什么成立
1.1 联合索引到底把数据排成了什么样
我碰到过不少同事把“最左前缀”背得很熟,但一问“为什么跳过去就失效”,就开始支支吾吾。这个不是概念难,而是大家很少真正去想一棵 B+ 树内部的数据是怎么组织的。
有张用户表 t_user,字段是 last_name、first_name、phone,我给这三个字段建了联合索引 idx_name_phone(last_name, first_name, phone)。你打开这棵索引的叶子节点看,数据并不会有三个独立的排序结果,而是只有一组排序规则:先按 last_name 排好,last_name 相同的,再按 first_name 排,前面两项都一样时,才轮到 phone。
这就很像一本先按姓氏拼音、再按名字拼音排序的电话簿。你要查“Zhao、Ming、118”这个组合,能直接翻到姓氏 Z 的分区,再在 Z 那一片里找 Ming,最后在 Ming 的小范围内找到 118。可如果你只知道 first_name=Ming,不知道姓什么,拿着这本“姓氏优先”的通讯录就只能从头翻到尾。
| 查询条件 | 能被索引利用的部分 | 说明 |
|---|---|---|
| last_name='Zhao' | last_name | 完整利用第一列 |
| last_name='Zhao' AND first_name='Ming' | 两列 | 等值条件下第二列可以精确定位 |
| last_name='Zhao' AND phone='118...' | 只有 last_name | first_name 缺失导致 phone 在整棵树上无序 |
| first_name='Ming' | 无 | 无法从第一列开始 |
| phone='118...' | 无 | 直接不知道去哪棵子树 |
你可能会想,last_name 等值之后,phone 为什么不能跟着过滤?因为在 last_name='Zhao' 的区间里,B+ 树的第二排序键是 first_name,电话字段并没有在全局或局部保持顺序。只有 last_name 和 first_name 都相同的极小范围内,phone 才是有序的。所以 phone 只能被“回表后再逐行过滤”,索引的有序性在这里完全帮不上忙。这就是最左前缀原则的底层逻辑:不是 MySQL 故意限制你,而是联合索引本身就没有按后面的列进行全局排序。
1.2 “不是必须全列,但必须从第一列连续开始”
先说结论:联合索引 idx(a, b, c) 上,查询条件只要覆盖了“从第一列开始的连续前缀”,比如 a,或者 a+b,或者 a+b+c,就能用上索引;a 和 c 的组合虽然能用 a,但 c 基本是废的;单独用 b、单独用 c、用 b+c,索引前缀直接就断了,优化器大多会放弃这棵索引。
我遇到过有人把“最左前缀”理解成“联合索引的所有列都必须在 WHERE 里”,这也是个常见误区。比如 idx(a, b, c),WHERE a=1 完全可以走索引,只不过只用到了第一列的 key_len。单独用 a 的查询性能通常不错,尤其在 a 的选择性较高时。反过来,如果 WHERE 条件里一上来就用 b,那才是真正的“断供”。
还有一个容易混淆的点:为什么经常强调“范围查询之后的列会失效”?这是因为一旦某列用了范围条件,比如 a > 100,那么在这一步之后,b 虽然有索引,但不再是全局有序的可用键,只能退化成一个过滤条件。这个问题我把完整案例放在第二章第 2.5 节展开,它是实际业务里坑人最多的场景之一。
2. 六类高频失效场景:现场复现与根因拆解
2.1 给索引列套函数或做运算:优化器没了查询目标
这是最典型的一种,也最好理解。很多同学喜欢在 SQL 里图省事:
SELECT * FROM pay_log WHERE DATE(create_time) = '2025-06-01';看上去 create_time 是索引列,但 DATE() 函数先把每一行的 create_time 取出来算了一遍,才和常量比较。B+ 树索引中存的是原始值,并且是按照原始值排好序的;函数处理后的结果是一串新序列,索引里根本没有,优化器想跳转都不知道往哪跳,只能老老实实全表扫。
这类问题修复的核心是:把函数从列上挪走,改成范围条件。
SELECT * FROM pay_log WHERE create_time >= '2025-06-01 00:00:00' AND create_time < '2025-06-02 00:00:00';对列做运算也一样,比如WHERE amount + 100 > 500。这会让 amount 的有序性失效。正确的写法是先把常量算好:WHERE amount > 400。MySQL 8.0 开始支持函数索引(functional index),如果真的无法改写业务 SQL,可以考虑建一个((DATE(create_time)))的生成列索引,但实战中能改 SQL 就不要绕远路,函数索引维护成本和心智负担都不小。
2.2 隐式类型转换:很多“等于”其实偷偷套了 CAST
说一个我在业务系统里真实排查过的例子。用户表 t_user 的 phone 字段是 VARCHAR(32),但接口层传来的参数是数字类型,程序拼出来的 SQL 变成了这样:
SELECT * FROM t_user WHERE phone = 13700001111;phone 明明是字符串列,你却拿一个整型常量去比。MySQL 的隐式转换规则会尝试把两边转成同一类型,但结果往往是在索引列上触发了 CAST(phone AS SIGNED),导致索引列被函数包了一层,B+ 树的有序性再次失效。你可以直接看到 EXPLAIN 里 type 变成 ALL,rows 变成全表行数。
反过来也一样:如果索引列是数字,你传了一个字符串常量,同样可能发生隐式转换。更隐蔽的是表连接场景:A 表的 user_id 是 varchar,B 表的 user_id 是 int,两个表 JOIN 时也会有一方被迫对列做转换,索引大概率失效。
我的建议是,从代码层就保证参数类型和字段类型完全一致,尤其是手机号、订单号这类“看着像数字、实际上是字符串”的字段。其次,统一所有表的字符集和排序规则,连接查询时 utf8 和 utf8mb4 混用也可能在字段上做隐式转换,这类问题排查起来更费劲。
2.3 LIKE 首个字符是通配符:顺序无从比对
-- 可以用索引 SELECT * FROM t_user WHERE last_name LIKE 'Zha%'; -- 用不上索引 SELECT * FROM t_user WHERE last_name LIKE '%hao';LIKE 能走索引的前提是有一个确定的前缀,因为 B+ 树的有序性就是从第一个字符开始的。'Zha%'相当于告诉优化器:我可以把索引扫描范围限制在 Zha 到 Zha 的最后一个可能值之间,这是标准的范围扫描。一旦通配符放到最前面,'%hao'没有一个可以定位的起点,只能全量扫描所有叶子节点。
这类需求的正确姿势是分情况处理。能改成左模糊的业务尽量改成右模糊;必须做前缀模糊的情况,在 MySQL 里可以用全文索引,或者把数据同步到专门的搜索组件,而不是硬着头皮在一个大表上写LIKE '%xxx%'。另外,后缀模糊查询也尽量不要对日期、超大字符串这类列做,代价非常高。
2.4 OR 条件中夹杂无索引列:一个短板拖垮全表
SELECT * FROM t_order WHERE order_status = 'active' OR shop_id = 9527;假如 order_status 有索引,shop_id 没有索引,你可能会想:其中一个条件走索引,另一个全表扫,再加起来不就行了?MySQL 不是这么玩的。OR 是“并集”语义,只要其中一条路径需要全表扫描,优化器往往选择直接全表扫描,因为把索引结果和全表结果做归并的成本并不比全表扫描便宜多少。
有一种例外是 MySQL 的 Index Merge 优化,如果多个 OR 分支都有自己的索引,优化器可能分别用索引捞数据再合并,EXPLAIN 里会看到type=index_merge。但这条路对索引设计、数据分布都很敏感,线上不建议把它当成可靠方案依赖。
解决思路通常是拆 SQL:
SELECT * FROM t_order WHERE order_status = 'active' UNION ALL SELECT * FROM t_order WHERE shop_id = 9527;如果业务允许,也可以把 shop_id 的无索引短板补上索引。总之,OR 条件最怕的是“部分有索引、部分没索引”这种半吊子状态,要么补齐,要么拆开。
2.5 联合索引中跳过了最左列:直接断供
这是最左前缀原则最直接的应用场景。有索引 idx(a, b, c),然后你写:
SELECT * FROM t WHERE b = 1 AND c = 2; SELECT * FROM t WHERE a = 1 AND c = 2;第一条完全没用上这个联合索引,因为查询没有提供最左列 a,B+ 树连起始查找位置都定不下来。第二条虽然用了 a,但 c 并不能参与精确定位,它的作用只是回表之后的普通过滤条件。
这里要提醒一个很多文章说得不够准确的地方:WHERE a > 1 AND b = 2在 idx(a, b) 上,b 并不是“完全没用”。MySQL 5.6 之后有索引条件下推(ICP)机制,b 可以在存储引擎层就对回表结果做过滤,EXPLAIN 里看到Using index condition就是它在起作用。但 b 确实无法像等值前缀那样把一个查询范围直接定位到很小的区间,所以设计索引时还是要把等值条件尽量放在范围条件前面。
2.6 优化器判断全表扫描更便宜:选择性和成本
索引不是万能的,MySQL 的优化器也不是傻子。它最终选择是否走索引,靠的是基于统计信息的成本估算。比如一个性别字段 gender,取值只有“男”“女”两种,你建了索引后执行WHERE gender = '男',优化器算一笔账:走索引要随机读一半以上的主键,回表成本高得离谱,还不如直接全表顺序扫描。于是 EXPLAIN 里 key 依然可以是 NULL。
这种情况严格来说不算“索引失效”,而是“优化器压根不想用”。解决方案一是提高查询的区分度,让条件里同时带上其他筛选字段;二是建覆盖索引,让查询直接在索引里拿结果不回头表,成本就下来了;三是执行ANALYZE TABLE刷新统计信息,有时候是统计信息太旧导致优化器判断失真。几十行的小表全表扫描也属于正常策略,不能一看到 key=NULL 就觉得数据库出问题了。
3. 用 EXPLAIN 输出判断索引到底用没用上
3.1 先看 key 和 key_len,再看 type 和 Extra
排查索引失效最快的方法是跑一条 EXPLAIN。下面这个简化输出就是典型的失效现场:
EXPLAIN SELECT * FROM t_user WHERE phone = 13700001111;id | select_type | table | type | possible_keys | key | key_len | rows | Extra 1 | SIMPLE | t_user | ALL | idx_phone | NULL | NULL | 1200000 | Using wherekey=NULL 是直接证据,说明优化器最终没有使用任何可用索引。type 字段能看到访问级别,常见的从好到差大致是:
| type 值 | 含义 | 典型场景 |
|---|---|---|
| const | 主键或唯一索引等值查询 | WHERE id=1 |
| ref | 普通索引等值查询 | WHERE status='active' |
| range | 索引范围扫描 | WHERE create_time >= '...' |
| index | 全索引扫描 | 覆盖索引上的遍历 |
| ALL | 全表扫描 | 绝大多数失效场景 |
但比 key 更值得关注的是 key_len。我见过不少 SQL,EXPLAIN 显示 key 有值,type 也还行,但 SQL 还是慢得要命。为什么?因为联合索引被使用的部分太少。比如联合索引 idx(shop_id, status, create_time),一个查询本来应该用掉三列,结果 key_len 只显示第一列的长度,说明另外两列白白挂在索引上却没有参与定位。
key_len 的计算其实有规律。INT 类型一般占 4 字节;VARCHAR(20) 使用 utf8mb4 字符集时,一个字符最多 4 字节,所以是 20×4=80 字节,还要加上记录变长字符串的 2 字节;如果列允许 NULL,再加 1 字节。假如 shop_id 是 INT NOT NULL,status 是 VARCHAR(20) NOT NULL,那么 idx(shop_id, status) 的满 key_len 大约是 4 + 82 = 86。实际输出如果只有 4,那就等于告诉你:status 没被用上。
3.2 Extra 里的隐藏信息
Extra 列经常比 type 还要有信息量。Using where表示存储引擎返回记录后,Server 层还要再做一次条件过滤,通常意味着索引只是“部分服务”。Using index表示查询需要的数据全部在索引里,不用回表,这就是覆盖索引,性能很好。Using index condition代表 ICP 生效了,部分条件被下推到存储引擎层过滤,虽然不完美,但比完全回表再过滤强。Using filesort更要警惕,它说明 ORDER BY 字段没有被索引覆盖,排序可能在内存或磁盘上重新执行,数据量大时慢查询基本就是这么来的。
看 EXPLAIN 有一个顺序:先看 key 是否为 NULL;再看 type 是否超过 range;然后看 key_len 是否覆盖了预期列数;最后看 Extra 里有没有 Using filesort。一层一层往下查,基本能定位 80% 的索引问题。
3.3 一条真实慢查询的排查示例
拿一张订单表 pay_order 举例,结构大概是 member_id VARCHAR(32)、status TINYINT、create_time DATETIME、amount DECIMAL(10,2)。索引 idx_member_status(member_id, status, create_time) 是存在的。业务反馈某天按会员查单很慢,拿到的 SQL 长这样:
SELECT id, order_no, amount FROM pay_order WHERE member_id = 95270001 AND status = 2 AND create_time >= '2025-05-01 00:00:00';EXPLAIN 出来的结果是:
type=ALL, possible_keys=idx_member_status, key=NULL, rows=1800000, Extra=Using where索引明明存在,possible_keys 也列出了它,但 key 是 NULL。根因就是 member_id 在表里是 VARCHAR,而传入的 95270001 是整型字面量,MySQL 在列上做了隐式类型转换,索引被废掉。修复方法非常简单:传参时显式用字符串:
WHERE member_id = '95270001'改完之后 key_len 从 NULL 变成 32+1 的字符串长度,rows 瞬间降到几千。这个问题在 Java、PHP、Python 的 ORM 拼参数时特别容易复现,尤其是从路径参数里直接塞数字的情况。
4. 修复思路与联合索引设计顺序
4.1 一个场景一个对策:对照表
下面这张表是我日常排查慢查询时随身会带的备忘,几乎覆盖了前面所有失效场景。
| 失效场景 | 典型根因 | 修复方向 |
|---|---|---|
| 对索引列用函数或运算 | 索引只保证原始值有序 | 改写为范围条件,必要时建生成列索引 |
| 隐式类型转换 | 类型或字符集不一致 | 参数与字段类型保持一致,统一字符集 |
| LIKE 左通配符 | 前缀不可知,无法定位范围 | 改右模糊,或用全文索引/搜索模块 |
| OR 分支无索引 | 优化器走全表更划算 | 用 UNION ALL 拆分,或补全索引 |
| 联合索引跳过最左列 | 不满足连续前缀 | 按高频 WHERE 重排联合索引 |
| 范围条件后的列 | range 之后的列无法精确定位 | 调整列顺序,或依赖 ICP 做引擎层过滤 |
| 低选择性字段 | 优化器认为全扫成本更低 | 结合其他等值条件,建覆盖索引 |
不要被“索引多了就能防失效”迷惑。索引本身也可能成为问题:业务上多个索引之间存在冗余。比如已经有了 idx(a, b),再建一个 idx(a),这完全就是重复建设,新写入的 INSERT 和 UPDATE 都要额外维护一棵 B+ 树。定期用 SHOW INDEX FROM 表名,把冗余索引清一清,长期来看比临时加索引收益更大。
4.2 联合索引的列顺序怎么排
设计联合索引顺序时,我习惯遵循一个原则:先等值,后范围,再排序,最后考虑覆盖列。所谓等值,就是 WHERE 里写col = ?的列,它们放在最左边可以让 B+ 树快速收敛;范围条件比如col > ?、col BETWEEN ? AND ?,放在等值列之后;如果还有 ORDER BY 字段,放在索引最后可以免掉 filesort。
举个例子。订单查询接口的常见筛选条件是 shop_id、order_status、create_time,而且 shop_id 和 order_status 都是等值。那么 idx(shop_id, order_status, create_time) 就是合理选择,create_time 放在最右承担范围筛选。如果反过来建 idx(create_time, shop_id, order_status),shop_id 的等值过滤完全没法参与索引定位,性能差距会被大表放大得非常明显。
对于全是等值条件的联合索引,列的顺序还可以参照区分度。比如客户编号比性别区分度高,就把客户编号放前面。不过当所有等值条件都命中时,索引对定位的贡献差别不那么大,优先考虑高频查询和覆盖需求才是主次分明的做法。
4.3 覆盖索引兜底与冗余索引清理
覆盖索引是一个经常被低估的优化手段。比如一个报表查询只需要 shop_id、status、create_time 三列,而联合索引正好包含这三列,那么 MySQL 可以直接扫描索引页拿到所有数据,连回表都省了。即便某些列因为函数、范围问题无法参与定位,Extra 里的Using index也能把性能拉回安全线。
所以遇到“索引没完全发挥”但业务上有复杂查询需求的时候,我的第一反应不是改 SQL,而是看看 SELECT 出的列能不能全部并入索引。如果代价可控,这常常是几个优化方案里最省事、最稳定的一个。当然也别把表里每一个字段都塞进索引,过大的索引页会导致 IO 增加、缓冲池命中率下降,任何优化都要在成本面前取得平衡。
5. 几个容易被误判的场景和一个值得留意的边界
5.1 全表扫描不一定等于索引失效
写这篇内容前,我特意把“全表扫描”和“索引失效”两个概念分开。很多瞬间被 index 的同事看到 EXPLAIN 里 type=ALL 就慌了,实际上小表数据量下的全表扫描可能比随机回表还快。判断标准不是 key 有没有值,而是 rows 估算、实际耗时以及回表次数。优化器是一个基于成本的决策系统,它选择全表扫描,很多时候说明估算出来走索引并不划算。
如果同一条 SQL 在数据量增长后反而开始走全表,而统计信息又很久没更新,可以执行ANALYZE TABLE刷新一下。MySQL 的统计信息不是实时变化的,大批量导入数据之后如果不刷新,优化器会对行数产生严重误判,缺失的索引路径也可能被错误放弃。
5.2 IS NULL、NOT IN 和负向查询不能一刀切
网上很多文章喜欢说“IS NULL 走索引,NOT IN 不走索引”,这是粗糙的结论。实际情况取决于版本、优化器统计信息和数据分布。MySQL 对 NULL 的处理在设计上就走了索引范围扫描的路线,WHERE last_name IS NULL在一定条件下确实能用到索引;而NOT IN、<>这类负向查询,如果结果集占据全表的比例很大,优化器同样会主动选择全表扫描,因为索引扫描加回表反而更贵。
真正想优化负向查询,思路是把负向条件转换成正向条件。比如“查所有非冻结状态”,尽量写成WHERE status IN ('active', 'pending')这类可枚举的正向条件,优化器可以更从容地选择索引路径。
5.3 ICP:被很多文章说成“失效”的列,还能帮一点忙
MySQL 5.6 引入的索引条件下推,是个经常被忽略的细节。回到idx(a, b)上的WHERE a > 1 AND b = 2,b 这个列虽然没法参与范围定位,但在 ICP 机制下,MySQL 会把 b=2 这个条件下推到存储引擎,在从索引页读取记录、回表之前就过滤掉大量不符合条件的行。EXPLAIN 里的Using index condition就是这个标志。因此,说“范围之后的列完全失效”在某些场景下不够精确:它不能直接定位范围,但可以帮助减少回表数量。业务上如果 a 的选择性足够好,这种查询未必会慢。
这个概念要特别在团队里传递清楚,不然很容易出现一个衍生问题:有人看到联合索引里有范围条件,就贸然重排索引,结果把等值前缀搞乱了,反而越改越差。任何优化动作前,都要用 EXPLAIN 验证一下改动是否真的让 key_len 和 Extra 变得更健康。
最后说一点个人习惯:排查索引问题时我从来不只是盯着 key 字段,而是先看 key_len,再看 type 和 rows。key 是 NULL 属于明显失效;更多时候 key 有值但 key_len 特别短,说明索引只被“用到了一部分”,这种半失效状态才是线上慢查询的大头。每次建联合索引前,我会把高频查询条件罗列出来,按“等值字段靠左、范围字段靠右、覆盖查询字段再往后”的顺序排一遍,再估算 key_len 会占用几列。养成这个习惯以后,被慢查询半夜叫醒的次数少了很多。你可以从手头最慢的一条 SQL 开始,用 EXPLAIN 按这套检查清单过一遍,大概率能找到真正的瓶颈。