371 lines
14 KiB
Markdown
371 lines
14 KiB
Markdown
|
|
---
|
|||
|
|
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 索引原理]]
|