This commit is contained in:
2026-05-24 11:42:38 +08:00
commit 30d312ac35
521 changed files with 146481 additions and 0 deletions
@@ -0,0 +1,462 @@
---
tags: [MySQL, N+1, 查询优化, ORM, 性能调优]
create time: 2026-05-21 13:37
---
# N+1 查询问题
## 概述
一条查询取回 N 条记录,然后逐条再查关联数据——最终执行了 **1 + N** 次查询,这就是 N+1 问题。它不只出现在 ORM 中,SQL 标量子查询、应用层循环调用、微服务间逐条 RPC 都是它的"马甲"。本文按**从 SQL 到应用到服务**的层次,系统梳理所有可能触发 N+1 的场景,以及对应的改写思路。
## 正文
### 先建立直觉
> [!question] 为什么是 1 + N 而不是 N?
> 因为第一步通常是"拿到主表数据"这一条查询,之后才是为每条数据各补一次关联查询。如果不做任何优化,当 N = 10 万时,数据库要处理 10 万 + 1 次查询——每次都有网络往返、SQL 解析、执行计划生成的开销。
```mermaid
flowchart LR
A["1 次查询: 拿到 N 条订单"] --> B["循环 N 次"]
B --> C1["查客户 1"]
B --> C2["查客户 2"]
B --> C3["..."]
B --> CN["查客户 N"]
style A fill:#00B6BC,color:#fff
style C1 fill:#EE5A24,color:#fff
style C2 fill:#EE5A24,color:#fff
style CN fill:#EE5A24,color:#fff
```
> [!abstract] 代价到底有多大?粗算一下
> 假设单次查询耗时 **2ms**(含网络往返 + SQL 解析 + 执行),N = 10000:
>
> | 模式 | 查询次数 | 总耗时(串行) |
> |------|---------|---------------|
> | N+1 | 10,001 | ≈ **20 秒** |
> | 批量 IN | 1 | ≈ **2ms** |
> | JOIN | 1 | ≈ **2ms** |
>
> 差距是 **4 个数量级**。而且 N+1 不仅是慢——它还消耗连接池资源、放大数据库 QPS、拖垮监控面板。很多"数据库扛不住了"的报警,根因其实是应用层的 N+1。
---
### 速查总览
> [!tip] 按层级定位问题
> N+1 不只是 ORM 的锅——SQL 写法、应用代码、服务架构都可能埋下隐患。先看全景图,再逐条深入。
```mermaid
mindmap
root(("N+1 触发场景"))
("SQL 层")
("标量子查询")
("相关子查询")
("WHERE IN 中的依赖子查询")
("ORM 层")
("惰性加载 Lazy Load")
("序列化器访问关联字段")
("应用层")
("循环内逐条查询")
("模板/视图中访问关联对象")
("服务间调用")
("逐条 RPC / HTTP 调用")
```
---
### 一、SQL 层面的 N+1
#### 1.1 标量子查询
```sql
-- ❌ 外层每行执行一次子查询
SELECT u.username,
(SELECT SUM(amount) FROM orders WHERE user_id = u.id) AS total_spent
FROM users u;
-- users 有 10 万行 → 子查询执行 10 万次
```
**为什么是 N+1**:标量子查询(Scalar Subquery)在 `SELECT` 列表中,对外层结果集的**每一行**都要执行一次。MySQL 优化器不一定能将其优化为 JOIN,尤其当子查询逻辑较复杂时。
**改写**:用 `GROUP BY` + `LEFT JOIN` 合并为一条查询。
```sql
-- ✅ 一次聚合 + 一次 JOIN
SELECT u.username, COALESCE(SUB.total, 0) AS total_spent
FROM users u
LEFT JOIN (
SELECT user_id, SUM(amount) AS total
FROM orders GROUP BY user_id
) SUB ON u.id = SUB.user_id;
```
---
#### 1.2 相关子查询(EXISTS / NOT EXISTS)
```sql
-- EXISTS 本身不一定是 N+1,但某些写法会退化
SELECT * FROM customers c
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.customer_id = c.id
AND o.amount > 1000
);
```
> [!note] EXISTS 不一定导致 N+1
> 现代 MySQL(8.0+)优化器经常将 `EXISTS` 解除关联(decorrelation),转为半连接(Semi-Join)。但当子查询条件复杂、无法解除关联时,确实会逐行求值——此时仍然是 N+1 的变种。
**判断方法**:用 `EXPLAIN` 查看执行计划,关注 `type` 和 `Rows`。如果外层行数 × 子查询扫描行数接近 N × M,就说明关联未被优化。
---
#### 1.3 WHERE IN 中的标量依赖
```sql
-- ❌ 子查询依赖外层列,逐行求值
SELECT *
FROM products p
WHERE category_name = (
SELECT name FROM categories WHERE id = p.category_id
);
```
**原理**:子查询引用了外层的 `p.category_id`,每次迭代都要重新查 `categories` 表。改为 JOIN 即可消除:
```sql
-- ✅ JOIN 合并
SELECT p.*
FROM products p
JOIN categories c ON c.id = p.category_id
WHERE p.category_name = c.name;
```
---
### 二、ORM 层面的 N+1
ORM 是 N+1 的**重灾区**,因为框架默认的惰性加载机制天然就是"用到时再查"。
#### 2.1 惰性加载(Lazy Loading)
```python
# Django ORM
orders = Order.objects.all() # 第 1 次查询
for o in orders:
print(o.customer.name) # N 次:每次访问触发新查询
```
```java
// JPA / Hibernate
List<Order> orders = em.createQuery("SELECT o FROM Order o").getResultList();
for (Order o : orders) {
o.getCustomer().getName(); // N 次延迟加载代理调用
}
```
```php
// Laravel Eloquent
$orders = Order::all(); // 1 次
foreach ($orders as $o) {
echo $o->customer->name; // N 次
}
```
**解法**:使用**预加载(Eager Loading)**:
```python
# Django: select_related (一对一/外键) 或 prefetch_related (多对多)
orders = Order.objects.select_related('customer').all() # 1 次 JOIN 查询
```
```java
// JPA: JOIN FETCH
@Query("SELECT o FROM Order o JOIN FETCH o.customer")
List<Order> findAllWithCustomer();
```
```php
// Laravel: with()
$orders = Order::with('customer')->get(); // 2 次查询: orders + customers WHERE id IN (...)
```
```go
// Go GORM: ❌ 默认惰性加载
var orders []Order
db.Find(&orders) // 第 1 次查询
for i := range orders {
db.Model(&orders[i]).Association("Customer").Find(&orders[i].Customer) // N 次
}
// ✅ GORM Preload:拆成 2 条 SQL(orders + customers WHERE id IN ...)
var orders []Order
db.Preload("Customer").Find(&orders)
// ✅ GORM Joins:用 LEFT JOIN 合并为 1 条 SQL
db.Joins("Customer").Find(&orders)
```
> [!question] `Preload` 和 `Joins` 什么时候该选哪个?
> - **Preload**:拆成 2 条 SQL,适合一对多(避免 JOIN 后行数膨胀)
> - **Joins**:合并为 1 条 SQL,适合一对一 / 外键关系,减少查询次数
> - 如果关联数据量不大,两者性能差异很小;如果一方有大量子记录,Preload 通常更安全
---
#### 2.2 序列化器中访问关联字段
```python
# Django REST Framework
class OrderSerializer(serializers.ModelSerializer):
customer_name = serializers.SerializerMethodField()
def get_customer_name(self, obj):
return obj.customer.name # ❌ 每个对象序列化时触发一次查询
```
**解法**:在 ViewSet 的 `get_queryset()` 中预加载:
```python
def get_queryset(self):
return Order.objects.select_related('customer').all()
```
---
### 三、应用层面的 N+1
#### 3.1 循环内逐条查询
```go
// ❌ Go: 循环中逐条查
for _, uid := range userIDs {
var user User
db.Where("id = ?", uid).First(&user) // N 次查询
users = append(users, user)
}
// ✅ 批量查询
var users []User
db.Where("id IN ?", userIDs).Find(&users) // 1 次查询
```
```python
# ❌ Python: 循环逐条查
users = []
for uid in user_ids:
user = db.query("SELECT * FROM users WHERE id = %s", uid) # N 次
users.append(user)
# ✅ 批量 IN 查询
users = db.query("SELECT * FROM users WHERE id IN %s", (user_ids,)) # 1 次
```
> [!question] 为什么不直接 IN 查?有什么注意事项?
> `IN (...)` 在元素极多时(如 10 万+)可能超长或性能退化。实践中的做法是**分批**——每批 500~1000 个 ID:
> ```go
> for i := 0; i < len(ids); i += 500 {
> batch := ids[i:min(i+500, len(ids))]
> db.Where("id IN ?", batch).Find(&users)
> }
> ```
---
#### 3.2 模板 / 视图中访问关联对象
```erb
<!-- ❌ Rails ERB 模板 -->
<% @posts.each do |post| %>
<h2><%= post.title %></h2>
<span>作者: <%= post.author.name %></span> <%# N 次查询 %>
<% end %>
```
这类问题特别隐蔽——**取数据在 Controller,访问关联在 View**,开发者在写 Controller 时可能根本没想到模板里会多触发 N 次查询。
**解法**:在 Controller 层预加载:
```ruby
# Rails
@posts = Post.includes(:author).all
```
---
### 四、服务间的 N+1
当单体应用拆分为微服务后,N+1 从"数据库查询"变成了"网络调用",代价更大。
#### 4.1 逐条 RPC / HTTP 调用
```
❌ 订单服务拿到 50 个订单
→ 逐个调用 用户服务 GET /users/{id}
→ 50 次 HTTP 往返,每次 50ms ≈ 2.5 秒
```
```mermaid
flowchart LR
subgraph "订单服务"
A["拿到 50 个订单"]
end
subgraph "用户服务"
B1["GET /users/1"]
B2["GET /users/2"]
B3["..."]
B50["GET /users/50"]
end
A -->|"逐条调用"| B1
A -->|"逐条调用"| B2
A -->|"逐条调用"| B3
A -->|"逐条调用"| B50
style B1 fill:#EE5A24,color:#fff
style B2 fill:#EE5A24,color:#fff
style B50 fill:#EE5A24,color:#fff
```
**解法**:提供批量接口,一次传入所有 ID:
```protobuf
// gRPC 批量接口定义
rpc GetUsers(GetUsersRequest) returns (GetUsersResponse);
message GetUsersRequest {
repeated int64 user_ids = 1;
}
```
```
✅ 调用一次 GetUsers([1, 2, ..., 50]) → 1 次 RPC,50ms
```
> [!tip] GraphQL 的 DataLoader 模式
> GraphQL 的解析器天然容易产生 N+1。DataLoader 通过**同一批次合并请求**(batching) + **内存去重**(caching)解决:
> ```js
> // 每个 resolver 中调用 dataloader.load(id)
> // DataLoader 在同一事件循环 tick 内收集所有 id,合并为一次批量查询
> const userLoader = new DataLoader(async (ids) => {
> const users = await db.query('SELECT * FROM users WHERE id IN (?)', [ids]);
> return ids.map(id => users.find(u => u.id === id));
> });
> ```
---
### 五、各场景速查对照表
| 层级 | 场景 | 根因 | 解法 |
|------|------|------|------|
| **SQL** | 标量子查询 | SELECT 列表中嵌套查询逐行求值 | `GROUP BY` + `JOIN` |
| **SQL** | 相关子查询 | WHERE/EXISTS 子查询引用外层列 | 优化器解除关联 / 改写为 JOIN |
| **ORM** | 惰性加载 | 属性访问时才触发查询 | 预加载(`select_related` / `JOIN FETCH` / `with()`) |
| **ORM** | 序列化器访问关联 | 取数与序列化未对齐 | 在 queryset 中 `select_related` |
| **应用** | 循环内逐条查询 | 缺少批量思维 | `WHERE id IN (...)` + 分批 |
| **应用** | 模板中访问关联 | 取数在 Controller,用数据在 View | Controller 层预加载 |
| **服务间** | 逐条 RPC/HTTP | 缺少批量接口 | 提供 `batch` 接口 / DataLoader |
---
### 六、如何发现隐藏的 N+1
> [!warning] N+1 最可怕的地方不是性能差,而是**在开发和测试阶段完全看不出来**
> 本地数据库网络延迟 0ms、数据量小(几十条),N+1 和正常查询体验几乎无差异。到了线上几万行时才暴雷。
**排查手段**:
| 手段 | 适用场景 | 说明 |
|------|---------|------|
| **SQL 日志** | ORM 项目 | 开启慢查询日志,观察短时间内的重复 SQL 模式 |
| **EXPLAIN** | SQL 层 | 关注子查询的执行次数 |
| **APM 工具** | 全链路 | SkyWalking / Datadog 可可视化每个 span 的 DB 调用次数 |
| **ORM 的 strict/lazy 禁用** | 开发阶段 | Django: `n_plus_one_db` checker;Rails: `bullet` gem;Laravel: `db:listen` |
| **代码审查** | 通用 | 搜索 `for` + `query/find/get` 的组合 |
**实战:从慢查询日志中识别 N+1**
开启 MySQL 慢查询日志后,你会在短时间内看到这样的重复模式:
```
# Time: 2026-05-21T10:32:01.001Z
SELECT * FROM orders WHERE status = 'pending';
# Rows_examined: 1500
# Time: 2026-05-21T10:32:01.005Z
SELECT * FROM customers WHERE id = 101;
# Rows_examined: 1
# Time: 2026-05-21T10:32:01.008Z
SELECT * FROM customers WHERE id = 203;
# Rows_examined: 1
...(重复 1500 次,每次只有 id 不同)
```
> [!warning] 识别特征
> - 短时间内出现**大量结构相同、仅参数不同**的 SQL
> - 每条 SQL 的 `Rows_examined` 都很小(1~几行)
> - 总执行时间 = N × 单条延迟,看起来每条都不慢,但累加惊人
---
### 七、N+1 一定是 bug 吗?
> [!question] 什么时候 N+1 可以"放过"?
> 不是所有 N+1 都需要消灭。以下情况可以权衡:
>
> | 场景 | 理由 |
> |------|------|
> | **N 极小且固定**(如 N ≤ 5) | 5 次查询和 1 次查询的差距可忽略,代码可读性可能更重要 |
> | **关联数据命中率极低** | 如果 100 条订单只有 3 条有关联退款记录,JOIN 反而拉大了结果集 |
> | **缓存已兜底** | 关联数据在 Redis 中,逐条查的是缓存而非数据库 |
>
> **但要确保**:你不是在用"可以接受"来掩盖"懒得改"。如果 N 会随业务增长,今天放过的 N+1 就是明天的线上事故。
---
### 八、改写思路总结
```mermaid
flowchart TD
A["发现 N+1"] --> B{"发生在哪一层?"}
B -->|"SQL"| C["子查询改 JOIN / GROUP BY"]
B -->|"ORM"| D["开启预加载 Eager Loading"]
B -->|"应用代码"| E["循环查询改为 IN 批量"]
B -->|"微服务"| F["提供批量接口 / DataLoader"]
C --> G["EXPLAIN 验证执行计划"]
D --> G
E --> G
F --> H["APM 监控 RPC 次数"]
style A fill:#EE5A24,color:#fff
style G fill:#00D866,color:#fff
style H fill:#00D866,color:#fff
```
**核心原则**:凡是「循环 + 单条数据库/网络调用」的组合,都可能产生 N+1。消灭它的思路只有一个——**合并为批量操作**。
---
### 九、Code Review 快速检查清单
> [!tip] 遇到以下代码模式,立刻警觉
> - [ ] `for` 循环内部有数据库查询 / HTTP 调用
> - [ ] ORM 属性访问(`.author.name`)出现在序列化器或模板中
> - [ ] SQL 的 `SELECT` 列表中有标量子查询
> - [ ] 微服务接口只提供"单个 ID 查单条"的 API
>
> **一句话记忆**:看到"循环里的数据访问",先问自己——**能不能提到循环外面?**
## 关联笔记
- [[hhs/MySQL/03-索引与查询优化/17-查询改写技巧]] — 子查询转 JOIN 的具体改写模式
- [[hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南]] — 验证改写后执行计划是否改善
- [[hhs/MySQL/03-索引与查询优化/16-慢查询日志分析]] — 从慢查询日志中发现 N+1 的重复 SQL 模式
- [[hhs/MySQL/03-索引与查询优化/补充/索引失效情况]] — 理解索引为何没被使用
@@ -0,0 +1,370 @@
---
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 索引原理]]