文章目录
- [Django ORM 的 N+1 问题------你每天都在踩但可能不知道](#Django ORM 的 N+1 问题——你每天都在踩但可能不知道)
-
- 导入语
- [1 ~> 什么是 N+1------用最简单代码复现](#1 ~> 什么是 N+1——用最简单代码复现)
-
- [1.1 模型定义](#1.1 模型定义)
- [1.2 触发 N+1 的代码](#1.2 触发 N+1 的代码)
- [1.3 Django Debug Toolbar 实测](#1.3 Django Debug Toolbar 实测)
- [2 ~> 根本原因------Django ORM 的懒加载](#2 ~> 根本原因——Django ORM 的懒加载)
-
- [2.1 什么是懒加载](#2.1 什么是懒加载)
- [3 ~> 解决方案一:`select_related`------SQL JOIN,一锤子买卖](#3 ~> 解决方案一:
select_related——SQL JOIN,一锤子买卖) -
- [3.1 语法](#3.1 语法)
- [3.2 适用场景------只适合外键和一对一](#3.2 适用场景——只适合外键和一对一)
- [3.3 真实效果](#3.3 真实效果)
- [4 ~> 解决方案二:`prefetch_related`------额外查询 + Python 层面组装](#4 ~> 解决方案二:
prefetch_related——额外查询 + Python 层面组装) -
- [4.1 什么场景用](#4.1 什么场景用)
- [4.2 实例](#4.2 实例)
- [4.3 `select_related` vs `prefetch_related` 对比](#4.3
select_relatedvsprefetch_related对比)
- [5 ~> 进阶------嵌套预加载和多层关联](#5 ~> 进阶——嵌套预加载和多层关联)
- [思考 && 总结](#思考 && 总结)
- 结尾
Django ORM 的 N+1 问题------你每天都在踩但可能不知道
📖 文章简介: N+1 查询是 Django 项目中最隐蔽也最高频的性能杀手。表面看起来代码正常------获取 50 个用户然后显示每个用户的部门名称,但实际上数据库被查询了 51 次而不是 2 次。本文从头拆解 N+1 的成因------Django ORM 懒加载机制与关联对象访问的特性,然后逐一分析 select_related(JOIN 方式预加载外键)和 prefetch_related(额外查询预加载多对多)的差异和适用场景。配有 Django Debug Toolbar 实测和真实事故------一个报表接口因为 N+1 导致数据库连接池耗尽。

🎬 个人主页: 源码骑士
❄ 专栏传送门: 《Android开发基础》《python基础课程》
⭐️热衷从源码视角拆解技术底层原理,将复杂架构讲得通俗易懂
🎬 源码骑士的简介:
5年Android Framework系统开发经验,曾主导多项系统级性能优化专项
技术栈覆盖Android系统全链路(Binder/Handler/AMS/WMS/启动流程)及Java后端全家桶(Spring + MyBatis + Redis + Oracle)
累计产出原创技术文章100+篇,文章以源码拆解为特色,被读者评价为"看一篇胜过啃一周文档"
导入语
2021 年,公司 CRM 系统的一个日报接口开始间歇性超时。运维反馈"数据库连接池满了",DBA 说"没有慢查询"。我打开 Django Debug Toolbar 一看------一个获取 100 个用户的接口,执行了 101 条 SQL 查询。第一条获取用户列表,后面 100 条是逐个查每个用户的部门名称。
这就是 N+1 问题。它最阴险的地方在于------你的代码看起来毫无问题。for user in users: print(user.department.name) 就这一行,背后是 100 次独立的数据库查询。
这篇文章把 N+1 的成因和两种解法(select_related 和 prefetch_related)讲清楚------不是背语法,而是从 SQL 层面理解它们做了什么。
1 ~> 什么是 N+1------用最简单代码复现
1.1 模型定义
python
from django.db import models
class Department(models.Model):
name = models.CharField(max_length=100)
class Employee(models.Model):
name = models.CharField(max_length=100)
department = models.ForeignKey(Department, on_delete=models.CASCADE)
1.2 触发 N+1 的代码
python
# ❌ N+1 问题------100 个员工 = 101 次查询
employees = Employee.objects.all() # ① 查一次:SELECT * FROM employee
for emp in employees:
print(emp.department.name) # ② 查 100 次:每次访问 emp.department
1.3 Django Debug Toolbar 实测
安装 django-debug-toolbar 后访问这个页面:
查询次数: 101
第一次:SELECT * FROM employee (1 条查询)
剩余 100 次:SELECT * FROM department WHERE id = 1
SELECT * FROM department WHERE id = 2
...
SELECT * FROM department WHERE id = 100
2 ~> 根本原因------Django ORM 的懒加载
2.1 什么是懒加载
python
emp = Employee.objects.get(id=1)
# 到目前为止只有一条 SQL:SELECT * FROM employee WHERE id = 1
# emp.department 还没有被加载------它只是一个惰性占位符
print(emp.department.name)
# 此时才触发第二条 SQL:SELECT * FROM department WHERE id = emp.department_id
Django ORM 默认只加载"当前对象",关联的外键对象在被访问之前不会被查询。这本身不是 Bug------它是为了省去不必要的查询。但循环里逐个访问关联对象时,就变成了 N+1。
Java 的 Hibernate 同样有懒加载------@ManyToOne(fetch = FetchType.LAZY) 的行为和 Django 的 ForeignKey 懒加载原理一致。不同在于 Hibernate 的 N+1 问题常用 JOIN FETCH 或 @BatchSize 解决,而 Django 有自己的一套工具。
3 ~> 解决方案一:select_related------SQL JOIN,一锤子买卖
3.1 语法
python
# ✅ 1 次 JOIN 查询------2 条变成 1 条
employees = Employee.objects.select_related("department").all()
for emp in employees:
print(emp.department.name) # 不再触发额外查询
生成的 SQL:
sql
SELECT employee.id, employee.name, employee.department_id,
department.id, department.name
FROM employee
INNER JOIN department ON employee.department_id = department.id
3.2 适用场景------只适合外键和一对一
select_related 适用这个:只有 ForeignKey 和 OneToOneField 能用。它是通过 SQL JOIN 实现的------一次查询把主表和关联表的数据全拉回来。不支持 ManyToManyField 和反向关联。
3.3 真实效果
CRM 日报接口优化前后对比:
没有 select_related: 101 次查询,接口耗时 820ms
加了 select_related: 1 次查询,接口耗时 45ms
4 ~> 解决方案二:prefetch_related------额外查询 + Python 层面组装
4.1 什么场景用
prefetch_related 适用这些:ManyToManyField、反向 ForeignKey、以及"你不想用 JOIN 聚合外键"的场景。
它不是用 SQL JOIN,而是------额外发一条查询,然后在 Python 层面把主表和关联表的数据对应上。
4.2 实例
python
class Article(models.Model):
title = models.CharField(max_length=200)
tags = models.ManyToManyField("Tag")
class Tag(models.Model):
name = models.CharField(max_length=50)
# ❌ N+1:100 篇文章、每篇 3 个标签 = 301 次查询
articles = Article.objects.all()
for article in articles:
print(article.tags.all()) # 每篇文章都触发一次独立的 ManyToMany 查询
# ✅ prefetch_related:2 次查询搞定
articles = Article.objects.prefetch_related("tags").all()
# 第 1 次:SELECT * FROM article
# 第 2 次:SELECT * FROM article_tags WHERE article_id IN (1,2,3,...,100)
# → 所有 100 篇文章的标签一次性查出 → Django 内部匹配到各自的文章
4.3 select_related vs prefetch_related 对比
| select_related | prefetch_related | |
|---|---|---|
| 实现方式 | SQL JOIN | 额外查询 + Python 组装 |
| 支持关系类型 | ForeignKey、OneToOne | ManyToMany、反向外键、ForeignKey 也可以 |
| SQL 查询数 | 1 条 | 2 条(或更多层嵌套) |
| 什么时候用 | 外键聚合简单,主表行数不大 | 多对多、反向外键、或不想用 JOIN 聚合时 |
5 ~> 进阶------嵌套预加载和多层关联
python
# Employee → Department → Company(三层关联)
employees = Employee.objects.select_related(
"department__company" # 双下划线表示"跨一层关系再预加载下一层"
).all()
# 生成的 SQL:
# SELECT * FROM employee
# INNER JOIN department ON ...
# INNER JOIN company ON ...
prefetch_related 也支持嵌套:
python
# 预加载文章 → 标签 → 每个标签的分类
articles = Article.objects.prefetch_related(
"tags__category"
)
思考 && 总结
N+1 问题的三个核心认知:
- 懒加载是必要的优化,但在循环中逐个访问关联对象就会被放大为性能杀手。 遇到
for obj in queryset: obj.related_field直接加select_related或prefetch_related。 select_related= SQL JOIN,prefetch_related= 额外查询 + Python 组装。 外键用前者,多对多用后者,嵌套用双下划线。- 装个 Django Debug Toolbar------它让你看到每次请求执行了多少条 SQL。 N+1 问题从来不是靠肉眼排查的,而是靠数字暴光的。
结尾
N+1 问题到这里拆解完毕。感谢阅读!
源码骑士 --- 源码级拆解,从底层看透技术
👀 关注:跟博主一起从源码视角深耕底层原理
❤️ 点赞:让优质内容被更多人看见
⭐ 收藏:核心知识点存好,随用随查
💬 评论:分享你的经验或疑问,一起交流
🔄 一键四连:别忘了给博主一键四连!
🗡️ 寄语 :一条 SQL 变成 100 条------这就是 N+1 的可怕之处。一条 select_related 把它变回一条。
结语:N+1 是 Django 项目性能优化的头号切入点。select_related 和 prefetch_related 就是你的两把工具。下篇讲一个请求从浏览器到数据库的完整旅程。一键四连!