MySQL 索引失效的 12 种场景与慢查询优化实战

MySQL 索引失效的 12 种场景与慢查询优化实战
神经蛙“我明明建了索引,为什么还是全表扫描?”这是 MySQL 优化里最常遇到的问题。索引失效的原因很多,但归根结底就两大类:写法让索引没法用,或者优化器算完账觉得不用更快。本文把两类都拆开讲。
一、先搞懂 InnoDB 的索引结构
不理解 B+Tree,就记不住那些失效规则,只能死记硬背。
1.1 为什么是 B+Tree 而不是 BTree
| 对比项 | BTree | B+Tree |
|---|---|---|
| 数据存储位置 | 每个节点都存数据 | 只有叶子节点存数据 |
| 叶子节点连接 | 无 | 有双向链表相连 |
| 单点查询性能 | 可能更快(根附近就命中) | 稳定,都要走到叶子 |
| 范围查询 | 需要中序遍历 | 沿链表顺序扫描,极快 |
| 非叶子节点容量 | 小(要存数据) | 大(只存键值),树更矮 |
B+Tree 的优势集中在一句话上:非叶子节点不存数据,所以一个页能装下更多键值,树高更矮,磁盘 IO 次数更少。
粗略估算:InnoDB 默认页大小 16 KB,主键为 bigint(8 字节)+ 指针(6 字节)约 14 字节,一个非叶子页能放约 1170 个键值。
- 树高 2 层:约 1170 × 16 ≈ 1.8 万行
- 树高 3 层:约 1170 × 1170 × 16 ≈ 2190 万行
- 树高 4 层:约 256 亿行
两千万数据只需 3 次磁盘 IO,这就是索引快的根本原因。
1.2 聚簇索引与二级索引
InnoDB 的主键索引就是聚簇索引 —— 叶子节点存的是整行数据。而二级索引(普通索引、唯一索引)的叶子节点存的只是主键值。
这带来一个关键概念:回表。
1 | -- name 上有二级索引 |
执行过程分两步:
- 在
idx_name这棵 B+Tree 上找到name='张三'对应的主键 id - 拿着这个 id 回到聚簇索引再查一次,取出完整行数据
第二步就是回表,多一次 B+Tree 查找。
1.3 覆盖索引:避免回表
如果查询的字段全都在索引里,就不需要回表:
1 | -- 建立联合索引 (name, age) |
二、12 种索引失效场景
以下示例基于这张表:
1 | CREATE TABLE `user` ( |
场景 1:违反最左前缀法则
联合索引 (name, age, city) 的排序逻辑是先按 name 排,name 相同再按 age 排,age 也相同才按 city 排。
1 | -- ✅ 走索引,用到 name |
场景 2:索引列上使用函数
1 | -- ❌ 对索引列做函数运算,B+Tree 的有序性被破坏 |
其他常见写法:
1 | -- ❌ |
场景 3:索引列参与表达式计算
1 | -- ❌ age 参与了运算 |
场景 4:隐式类型转换
这是最隐蔽的一种,SQL 看着完全没问题,但索引就是不走。
1 | -- phone 是 varchar,这里却用数字比较 |
规则总结:
| 字段类型 | 传入类型 | 是否走索引 |
|---|---|---|
| varchar | 字符串 | ✅ 走 |
| varchar | 数字 | ❌ 不走,字段被隐式转换 |
| int | 数字 | ✅ 走 |
| int | 字符串 | ✅ 走,转换发生在常量侧,不影响索引 |
注意最后一行的不对称性:字符串字段传数字会失效,数字字段传字符串却没问题。因为 MySQL 的转换规则是”把字符串转成数字”,数字字段传字符串时,转换的是常量而不是索引列。
场景 5:LIKE 以 % 开头
1 | -- ✅ 前缀匹配,能用到索引的有序性 |
如果业务必须支持前后模糊搜索,就不要硬扛索引了,用全文索引:
1 | -- 建全文索引 |
或者上 Elasticsearch,这才是模糊搜索的正解。
场景 6:OR 连接了非索引列
1 | -- status 没有索引,优化器只能全表扫(否则要扫两遍再合并) |
场景 7:!= 、<>、NOT IN
1 | -- ❌ 否定条件的匹配范围太广,优化器倾向全表 |
并非绝对失效 —— 如果 status != 1 命中的行数极少,优化器仍可能走索引。判断依据始终是 explain 的实际输出。
场景 8:IS NULL / IS NOT NULL
是否能用索引,取决于该列的空值比例,这是个动态决策:
1 | -- 如果 name 列绝大部分是 NULL,这个查询命中极少,会走索引 |
优化方向是让字段 NOT NULL 并给默认值,从根本上消除 NULL 判断:
1 | ALTER TABLE user |
场景 9:范围查询右侧的列失效
联合索引中,某一列用了范围查询之后,它右边的所有列都无法再用于精确定位。
1 | -- 索引 (name, age, city) |
因为 age > 20 匹配到的是一批值,这批值里 city 是无序的,无法继续用索引定位。
优化方案:把范围列放到联合索引的最后一位。
1 | -- 调整索引顺序,让等值列在前 |
场景 10:JOIN 时字符集不一致
跨表关联时,如果两个关联字段的字符集或排序规则不同,MySQL 会对字段做转换,等同于加函数。
1 | -- t1.phone 是 utf8mb4,t2.phone 是 utf8 |
排查方法:
1 | SHOW FULL COLUMNS FROM t1 LIKE 'phone'; |
场景 11:优化器主动放弃索引
有时候索引完全可用,但优化器算完账觉得全表更快,于是弃用。
1 | -- 假设 status=1 占了全表 90% 的行 |
原因就是回表代价:二级索引取出主键后还要回聚簇索引取完整行,随机 IO。当命中比例超过约 20%~30%,随机 IO 的开销就超过顺序扫全表了。
优化方案一:用覆盖索引消除回表
1 | CREATE INDEX idx_status_name ON user(status, name); |
优化方案二:强制走索引(谨慎使用)
1 | SELECT * FROM user FORCE INDEX (idx_status) WHERE status = 1; |
FORCE INDEX 是把优化器的决策权抢过来,属于硬编码。数据分布一变,它可能从”优化”变成”劣化”。只在明确知道数据分布且长期稳定时使用,并且要加注释说明原因。
场景 12:索引选择性太差
在区分度极低的列上建索引,本身就没什么意义。
1 | -- 假设 gender 只有 男/女 两个值,10 万行里男女各 5 万 |
用下面的公式计算索引选择性,越接近 1 越好:
1 | SELECT |
| 字段 | 选择性 | 建议 |
|---|---|---|
| id(主键) | 1.0000 | 最优 |
| phone | 0.9980 | 适合建索引 |
| name | 0.8500 | 可以建 |
| city | 0.0200 | 单独建意义不大 |
| gender | 0.00002 | 不要建 |
低选择性的列不是不能出现在索引里,而是应该作为联合索引的后缀列,配合高选择性列一起用,比如 (city, name)。
三、explain 执行计划怎么看
3.1 常用列解读
1 | EXPLAIN SELECT * FROM user WHERE name = '张三' AND age = 28; |
| 列 | 含义 | 关注点 |
|---|---|---|
type |
访问类型 | 最关键,见下表 |
key |
实际使用的索引 | 为 NULL 表示未走索引 |
key_len |
实际用到的索引字节数 | 判断联合索引用了几列 |
rows |
预计扫描行数 | 越小越好 |
Extra |
附加信息 | 看是否有 Using filesort / Using temporary |
3.2 type 性能排序
从好到坏:
1 | system > const > eq_ref > ref > range > index > ALL |
| 类型 | 含义 | 出现场景 |
|---|---|---|
system |
表只有一行 | 极少见 |
const |
主键或唯一索引等值查询,最多一行 | WHERE id = 1 |
eq_ref |
唯一索引关联,每行只匹配一条 | JOIN 主键 |
ref |
普通索引等值查询 | WHERE name = '张三' |
range |
索引范围扫描 | WHERE age > 20 |
index |
全索引扫描 | 覆盖索引但无条件 |
ALL |
全表扫描 | 需要优化 |
优化目标是至少达到 range,最好是 ref 或 const。看到 ALL 就要警惕。
3.3 Extra 常见提示
| 提示 | 含义 | 严重性 |
|---|---|---|
Using index |
覆盖索引,未回表 | 🟢 优秀 |
Using where |
在存储引擎返回后再次过滤 | 🟡 正常 |
Using index condition |
索引下推(ICP),减少回表 | 🟢 良好 |
Using filesort |
无法用索引排序,需额外排序 | 🔴 需优化 |
Using temporary |
创建了临时表 | 🔴 需优化 |
Using join buffer |
JOIN 未走索引 | 🔴 需优化 |
3.4 用 key_len 判断联合索引用了几列
1 | EXPLAIN SELECT * FROM user WHERE name = '张三' AND age = 28; |
假设 name 是 varchar(50) utf8mb4,则 50 × 4 + 2 = 202 字节;age 是 int 为 4 字节。
key_len = 202→ 只用了 namekey_len = 206→ 用了 name + agekey_len = 288→ 用了 name + age + city
key_len 是判断”联合索引到底生效了几列”最直接的方法。
四、慢查询定位
4.1 开启慢查询日志
1 | -- 查看当前配置 |
永久生效要改配置文件:
1 | [mysqld] |
4.2 分析慢日志
1 | # MySQL 自带工具,按耗时排序取前 10 |
生产环境更推荐 Percona Toolkit:
1 | # 生成完整分析报告 |
4.3 实时抓问题 SQL
1 | -- 查看当前正在执行的慢查询 |
五、实战案例
案例一:千万级分页优化
问题 SQL:
1 | -- 耗时 12 秒 |
原因:MySQL 必须先扫描并丢弃前 100 万行,才能取到想要的 20 行。
优化方案:延迟关联,先用覆盖索引定位主键,再回表取数据。
1 | -- 耗时 0.3 秒 |
更彻底的方案:游标分页,彻底告别深翻页。
1 | -- 已知上一页最后一条的 create_time 和 id |
配合索引 (create_time, id),无论翻到第几页都是毫秒级。
案例二:隐式字符集转换导致全表扫描
现象:一个 JOIN 查询突然从 50ms 变成 30 秒。
1 | SELECT u.name, o.amount |
explain 显示 orders 表的 type = ALL。查字符集发现:
1 | -- user.phone: utf8mb4_general_ci |
修复:
1 | ALTER TABLE orders |
修复后 type 变为 ref,耗时回到 40ms。
案例三:filesort 消除
问题 SQL:
1 | -- Extra: Using where; Using filesort |
原因:idx_user_id 只按 user_id 排序,同一 user_id 下的 create_time 是无序的,只能临时排序。
修复:建立联合索引,让排序天然有序。
1 | -- 删除冗余的单列索引 |
修复后 Extra 变为 Using index condition,filesort 消失。
六、索引设计最佳实践
- 优先使用自增主键 —— 顺序写入避免页分裂,且主键长度越小,二级索引越省空间
- 联合索引优于多个单列索引 —— MySQL 通常只会选择其中一个索引,联合索引效率更高
- 区分度高的列放前面 —— 但等值查询列一律优先于范围查询列
- 控制索引数量 —— 每个索引都是一棵 B+Tree,写操作要同步维护,索引多了写入会变慢
- 善用覆盖索引 —— 把查询高频的字段纳入联合索引,消除回表
- 避免冗余索引 —— 有了
(a, b, c),(a)和(a, b)就是冗余的 - 字段尽量 NOT NULL —— 省一个字节的 NULL 标记位,同时避免 NULL 判断导致的失效
- 长字符串用前缀索引 ——
CREATE INDEX idx ON t(email(20)),但要权衡区分度 - 定期清理无用索引 —— 查询
sys.schema_unused_indexes找出从没被用过的索引 - 上线前必看 explain —— 数据量和线上不一致时,本地测试的执行计划没有参考价值











