Files
cs-note/hhs/MySQL/03-索引与查询优化/补充/索引失效情况.md
T
2026-05-24 11:42:38 +08:00

371 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [MySQL, 索引, EXPLAIN, 查询优化, B+Tree]
create time: 2026-05-21 13:08
---
# 索引失效情况
## 概述
明明建了索引,EXPLAIN 却显示 `type: ALL`——这种"索引建了等于没建"的情况在线上非常常见。本文系统梳理导致索引失效(或退化)的所有典型场景,并结合 B+ Tree 的底层原理给出每条规则的解释,帮你从"背规则"升级到"理解为什么"。
## 正文
### 速查总览
> [!tip] 先看全景图再逐条深入
> 下图把常见的索引失效场景按"谁导致的"做了分类,后文逐一展开。
```mermaid
mindmap
root(("索引失效"))
("SQL 写法问题")
("隐式类型转换")
("对索引列使用函数")
("索引列参与运算")
("LIKE 左模糊")
("违反最左前缀")
("范围查询截断后续列")
("SELECT * 阻止覆盖索引")
("OR 条件不当")
("NOT IN / NOT EXISTS")
("!= / <> / NOT LIKE")
("数据与优化器")
("优化器选择全表扫描")
("隐式字符集转换")
("IS NULL / IS NOT NULL")
```
---
### 1. 隐式类型转换
**现象**:`varchar` 列上建了索引,但用数值条件查询时索引失效。
```sql
-- phone 是 varchar 类型
SELECT * FROM users WHERE phone = 13800138000; -- ❌ 索引失效
SELECT * FROM users WHERE phone = '13800138000'; -- ✅ 走索引
```
**原理**:MySQL 的类型转换规则是**将字符串转为数字**——相当于对索引列执行了 `CAST(phone AS DECIMAL)`。对索引列施加函数,B+ Tree 无法直接定位,只能逐行扫描。
> [!question] 反过来呢?数值列用字符串查会失效吗?
> 不会。`WHERE id = '123'`(id 是 `int`)等价于 `WHERE id = 123`,因为 `'123'` 被转为数字后直接用于比较,不涉及对索引列的函数调用。
---
### 2. LIKE 左模糊 / 两侧模糊
```sql
SELECT * FROM products WHERE name LIKE '%手机'; -- ❌ 全表扫描
SELECT * FROM products WHERE name LIKE '手机%'; -- ✅ 走索引
SELECT * FROM products WHERE name LIKE '%手机%'; -- ❌ 全表扫描
```
**原理**:B+ Tree 按前缀有序排列。`'手机%'` 可以通过前缀定位到范围起点,而 `'%手机'` 左侧是未知的,无法利用有序性,只能全量遍历。
> [!tip] 模糊搜索的替代方案
> 如果业务确实需要前后模糊匹配,可以考虑:
> - **覆盖索引**:索引包含所有查询字段,避免回表开销(虽然仍是全扫索引,但比扫聚簇索引快得多)
> - **全文索引**(`FULLTEXT`)或外部搜索引擎(Elasticsearch)
---
### 3. 对索引列使用函数
```sql
-- ❌ 索引失效:对 create_time 调用了 YEAR() 函数
SELECT * FROM orders WHERE YEAR(create_time) = 2026;
-- ✅ 改写为范围查询,可以走索引
SELECT * FROM orders WHERE create_time >= '2026-01-01'
AND create_time < '2027-01-01';
```
**原理**:函数包裹索引列后,MySQL 拿到的不再是原始列值,而是函数的返回值。B+ Tree 上存的是原始值,无法对函数结果做二分查找。
常见的"隐形函数"还有:
| 写法 | 等价函数调用 |
|------|------------|
| `WHERE DATE(col) = '2026-05-21'` | `DATE(col)` |
| `WHERE col + 1 = 10` | 加法运算(见下节) |
| `WHERE CONCAT(col, 'x') = 'abx'` | `CONCAT(col, 'x')` |
> [!question] MySQL 8.0 的索引下推(ICP)能救吗?
> 不能。ICP(Index Condition Pushdown)优化的是**回表前的过滤**,前提仍是索引被命中。如果函数导致索引根本没用上,ICP 无从谈起。
---
### 4. 索引列参与运算
```sql
-- ❌ 对索引列做了运算
SELECT * FROM accounts WHERE balance - 100 > 0;
-- ✅ 把运算移到右边(常量侧)
SELECT * FROM accounts WHERE balance > 100;
```
**原理**:与函数同理——`balance - 100` 对每一行的 `balance` 做运算后才能比较,B+ Tree 上存的是 `balance` 原始值,无法直接定位。
**准则**:**索引列保持"干净",运算和函数尽量放到等号右侧。**
---
### 5. 违反联合索引的最左前缀原则
假设联合索引为 `(a, b, c)`:
| WHERE 条件 | 能否走索引 | 走到哪一列 |
|-----------|-----------|-----------|
| `a = 1` | ✅ | a |
| `a = 1 AND b = 2` | ✅ | a, b |
| `a = 1 AND b = 2 AND c = 3` | ✅ | a, b, c |
| `b = 2` | ❌ | — |
| `b = 2 AND c = 3` | ❌ | — |
| `a = 1 AND c = 3` | ⚠️ 部分 | a(c 无法跳过 b 使用)|
**原理**:联合索引在 B+ Tree 中按 `a → b → c` 的顺序排序。没有 `a` 就无法确定在树中的起始位置,就像查字典时不知道首字母一样。
> [!tip] MySQL 8.0+ 的索引跳跃扫描(Index Skip Scan)
> 当联合索引首列基数(cardinality)很低时(例如 `gender` 只有 M/F),优化器可能拆分成两次索引查询,绕过最左前缀限制。但这只是优化器的"兜底"策略,不应依赖。
---
### 6. 范围查询截断联合索引的后续列
联合索引 `(a, b, c)` 中,一旦某列使用了范围查询(`>`、`<`、`BETWEEN`、`LIKE 'x%'`),**该列之后的索引列将无法继续用于索引定位**。
```sql
-- 联合索引 (a, b, c)
WHERE a = 1 AND b > 10 AND c = 20;
-- a: ✅ 等值定位
-- b: ✅ 范围扫描(在 a=1 的范围内做 b > 10)
-- c: ❌ 无法走索引(b 是范围,c 被"截断")
```
**原理**:B+ Tree 先按 `a` 排序,`a` 相同时按 `b` 排序,`b` 相同时按 `c` 排序。当 `b > 10` 时,`b` 的值不再固定,那么同一个 `b` 值下可能对应不同的 `c`,`c` 在树中不再有序,无法二分查找。
> [!question] 那 `BETWEEN` 和 `LIKE 'x%'` 也会截断吗?
> 是的。`BETWEEN 1 AND 100` 本质也是范围,`LIKE '张%'` 同理——它们都让该列的值不再唯一确定,后续列的有序性被破坏。
> [!tip] 实战优化思路
> 在设计联合索引时,**等值查询的列放在前面,范围查询的列放在后面**。例如,如果查询经常是 `WHERE status = 'active' AND create_time > '2026-01-01'`,索引应设计为 `(status, create_time)` 而非 `(create_time, status)`。
---
### 7. SELECT * 导致无法走覆盖索引
```sql
-- 假设有联合索引 idx_name_age (name, age)
-- ✅ 覆盖索引:查询字段全在索引中,无需回表
SELECT name, age FROM users WHERE name = '张三';
-- ❌ SELECT * 强制回表:即使 WHERE 走了索引,仍需回表取其他列
SELECT * FROM users WHERE name = '张三';
```
**原理**:覆盖索引(Covering Index)是指查询所需的所有字段都包含在索引中,MySQL 可以直接从索引返回结果,省去"回表"(回到聚簇索引取完整行)的开销。`SELECT *` 取所有列,索引中不可能全部包含,因此必定回表。
> [!question] 回表代价有多大?
> 每次回表都是一次**随机 I/O**。如果查询匹配 10 万行,就要做 10 万次随机读。这就是为什么 `SELECT *` + 大量行 = 慢查询的经典组合。
>
> 通过 `EXPLAIN` 查看 `Extra` 列:出现 `Using index` 表示走覆盖索引,出现 `Using index condition` 表示走了索引但仍需回表。
> [!tip] 最佳实践
> - 生产代码中禁止 `SELECT *`,只查需要的列
> - 为高频查询设计"覆盖索引"——把 `SELECT` 中的字段也加入联合索引尾部
> - 注意:索引列过多会增大写入代价,需权衡
---
### 8. OR 条件不当(一侧无索引)
```sql
-- ❌ name 有索引,age 没索引 → 整体退化为全表扫描
SELECT * FROM users WHERE name = '张三' OR age = 25;
-- ✅ 用 UNION ALL 拆开,各自走各自的索引
SELECT * FROM users WHERE name = '张三'
UNION ALL
SELECT * FROM users WHERE age = 25 AND name != '张三';
```
**原理**:`OR` 要求两边条件取并集。如果其中一个条件无索引,MySQL 只能对全表扫描来保证结果完整。
> [!note] 如果 OR 两边的列都有索引呢?
> MySQL 会分别用两个索引扫描,再合并结果(`index_merge` 优化),通常是能走索引的。
---
### 9. NOT IN / NOT EXISTS / != / <>
```sql
-- 以下写法可能导致索引失效(取决于数据分布和优化器判断)
SELECT * FROM orders WHERE status != 'completed';
SELECT * FROM users WHERE id NOT IN (1, 2, 3);
SELECT * FROM users WHERE name NOT LIKE '张%';
```
**原理**:不等于 / 不在集合中 / 不匹配,本质上是**排除**操作——需要扫描大量行来确认"不等于",优化器通常评估后认为全表扫描更快。
> [!question] 那 NOT EXISTS 一定比 NOT IN 慢吗?
> 恰恰相反。`NOT EXISTS` 使用关联子查询,往往能在子查询表上走索引;而 `NOT IN` 需要将子查询结果物化后逐一比对,当结果集大时更慢。但在"索引是否失效"这个维度,两者的行为取决于执行计划,不能一概而论。
---
### 10. IS NULL / IS NOT NULL
```sql
SELECT * FROM users WHERE email IS NULL;
SELECT * FROM users WHERE email IS NOT NULL;
```
- **MySQL 5.6**:`IS NULL` 可以走索引,`IS NOT NULL` 通常不能。
- **MySQL 8.0+**:两者都**可以**走索引,优化器会根据**数据分布**自行判断——如果大部分行都是 `NULL`,`IS NOT NULL` 反而更高效地走索引。
> [!tip] 设计层面的建议
> 在设计表时,如果某列经常需要查询"非空"的记录,可以考虑将默认值设为一个特殊标记(如空字符串或 `0`),避免频繁 `IS NULL / IS NOT NULL` 判断。
---
### 11. 优化器主动放弃索引
即使索引"理论上"可用,优化器也可能主动选择全表扫描。
**核心判断逻辑**:优化器基于**成本估算**做决策——当回表代价高于直接全扫时,索引就会被放弃。
常见场景:
| 场景 | 原因 |
|------|------|
| 查询返回表中超过 20%~30% 的行 | 回表次数太多,不如顺序扫描 |
| 表数据量很小(几百行以内) | 顺序扫描比 B+ Tree 查找更快 |
| 统计信息过期 | 优化器误判行数,做出错误决策 |
```sql
-- 用 FORCE INDEX 强制走索引(仅用于验证,不建议生产使用)
SELECT * FROM orders FORCE INDEX(idx_create_time)
WHERE create_time > '2026-01-01';
```
> [!note] 保持统计信息准确
> `ANALYZE TABLE table_name` 可以刷新表的统计信息,帮助优化器做出更准确的判断。
---
### 12. 隐式字符集 / 排序规则转换
当 `JOIN` 两张表的关联字段字符集不一致时,MySQL 会对其中一列做隐式转换,导致该列的索引失效。
```sql
-- 表 A: name VARCHAR(50) CHARSET utf8mb4 COLLATE utf8mb4_general_ci
-- 表 B: name VARCHAR(50) CHARSET utf8 COLLATE utf8_general_ci
SELECT * FROM A JOIN B ON A.name = B.name;
-- B.name 会被隐式转换为 utf8mb4,B 表侧索引可能失效
```
**解决**:统一字符集和排序规则,或显式 `CONVERT()` 到低优先级字符集侧。
---
### 排查速查清单
当怀疑索引失效时,按以下流程排查:
```mermaid
flowchart TD
A["怀疑索引失效"] --> B["EXPLAIN 查看执行计划"]
B --> C{"type = ALL?"}
C -->|"是"| D["检查 WHERE 条件"]
C -->|"否"| E["索引已命中, 检查其他慢点"]
D --> F{"索引列被函数/运算包裹?"}
F -->|"是"| G["改写: 函数/运算移到常量侧"]
F -->|"否"| H{"存在隐式类型转换?"}
H -->|"是"| I["统一参数类型与列类型"]
H -->|"否"| J{"联合索引是否满足最左前缀?"}
J -->|"否"| K["调整索引或查询条件顺序"]
J -->|"是"| L{"范围查询是否截断后续列?"}
L -->|"是"| M["调整索引列顺序: 等值在前, 范围在后"]
L -->|"否"| N{"OR 条件中有无索引列?"}
N -->|"是"| O["UNION ALL 拆分或补充索引"]
N -->|"否"| P["考虑数据分布, ANALYZE TABLE"]
```
---
### 实战:从一个慢查询到修复的全过程
> [!example] 真实场景还原
> 线上告警:某订单查询接口 P99 耗时 3 秒。表 `orders` 约 500 万行,已有联合索引 `idx_status_time(status, create_time)`。
**第一步:拿到 SQL,跑 EXPLAIN**
```sql
EXPLAIN SELECT * FROM orders
WHERE status = 'pending'
AND create_time > '2026-05-01'
AND YEAR(update_time) = 2026;
```
```
+----+------+------+----------+----------+
| id | type | key | key_len | Extra |
+----+------+------+----------+----------+
| 1 | ALL | NULL | NULL | Using where |
+----+------+------+----------+----------+
```
`type = ALL`,`key = NULL`——索引完全没用上。
**第二步:逐条排查**
- `status = 'pending'`:等值查询,无问题。
- `create_time > '2026-05-01'`:范围查询,索引 `(status, create_time)` 可以覆盖前两列。
- `YEAR(update_time) = 2026`:**函数包裹了索引列**——如果 `update_time` 上有索引也会失效。更关键的是,这个条件让优化器评估后觉得"算了,全扫吧"。
**第三步:改写并验证**
```sql
-- 去掉函数,改写为范围
EXPLAIN SELECT * FROM orders
WHERE status = 'pending'
AND create_time > '2026-05-01'
AND update_time >= '2026-01-01'
AND update_time < '2027-01-01';
```
```
+----+-------+----------------+---------+-----------------------------+
| id | type | key | key_len | Extra |
+----+-------+----------------+---------+-----------------------------+
| 1 | range | idx_status_time| 68 | Using index condition |
+----+-------+----------------+---------+-----------------------------+
```
`type = range`,索引命中!但 `Extra` 显示 `Using index condition`(ICP),说明仍需回表。
**第四步:进一步优化——覆盖索引**
如果这个接口只需要 `order_id, status, amount`,可以建覆盖索引:
```sql
ALTER TABLE orders ADD INDEX idx_cover(status, create_time, order_id, amount);
```
改写查询为 `SELECT order_id, status, amount FROM orders WHERE ...`,`Extra` 将变为 `Using index`,彻底消除回表。
> [!tip] 排查口诀
> **先 EXPLAIN,看 type 和 key;再看 Extra,找 Using index;函数和类型,是最常见的坑。**
## 关联笔记
- [[hhs/MySQL/03-索引与查询优化/14-联合索引与最左前缀]]
- [[hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南]]
- [[hhs/MySQL/03-索引与查询优化/17-查询改写技巧]]
- [[hhs/MySQL/03-索引与查询优化/12-B+Tree 索引原理]]