463 lines
14 KiB
Markdown
463 lines
14 KiB
Markdown
---
|
||
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-索引与查询优化/补充/索引失效情况]] — 理解索引为何没被使用
|