文章目录
- Django ORM 的 N+1 问题——你每天都在踩但可能不知道
- 导入语
- 1 ~> 什么是 N+1——用最简单代码复现
- 1.1 模型定义
- 1.2 触发 N+1 的代码
- 1.3 Django Debug Toolbar 实测
- 2 ~> 根本原因——Django ORM 的懒加载
- 2.1 什么是懒加载
- 3 ~> 解决方案一:`select_related`——SQL JOIN,一锤子买卖
- 3.1 语法
- 3.2 适用场景——只适合外键和一对一
- 3.3 真实效果
- 4 ~> 解决方案二:`prefetch_related`——额外查询 + Python 层面组装
- 4.1 什么场景用
- 4.2 实例
- 4.3 `select_related` vs `prefetch_related` 对比
- 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 模型定义
fromdjango.dbimportmodelsclassDepartment(models.Model):name=models.CharField(max_length=100)classEmployee(models.Model):name=models.CharField(max_length=100)department=models.ForeignKey(Department,on_delete=models.CASCADE)1.2 触发 N+1 的代码
# ❌ N+1 问题——100 个员工 = 101 次查询employees=Employee.objects.all()# ① 查一次:SELECT * FROM employeeforempinemployees:print(emp.department.name)# ② 查 100 次:每次访问 emp.department1.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 = 1002 ~> 根本原因——Django ORM 的懒加载
2.1 什么是懒加载
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_idDjango 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 语法
# ✅ 1 次 JOIN 查询——2 条变成 1 条employees=Employee.objects.select_related("department").all()forempinemployees:print(emp.department.name)# 不再触发额外查询生成的 SQL:
SELECTemployee.id,employee.name,employee.department_id,department.id,department.nameFROMemployeeINNERJOINdepartmentONemployee.department_id=department.id3.2 适用场景——只适合外键和一对一
select_related适用这个:只有 ForeignKey 和 OneToOneField 能用。它是通过 SQL JOIN 实现的——一次查询把主表和关联表的数据全拉回来。不支持 ManyToManyField 和反向关联。
3.3 真实效果
CRM 日报接口优化前后对比:
没有 select_related: 101 次查询,接口耗时 820ms 加了 select_related: 1 次查询,接口耗时 45ms4 ~> 解决方案二:prefetch_related——额外查询 + Python 层面组装
4.1 什么场景用
prefetch_related适用这些:ManyToManyField、反向 ForeignKey、以及"你不想用 JOIN 聚合外键"的场景。
它不是用 SQL JOIN,而是——额外发一条查询,然后在 Python 层面把主表和关联表的数据对应上。
4.2 实例
classArticle(models.Model):title=models.CharField(max_length=200)tags=models.ManyToManyField("Tag")classTag(models.Model):name=models.CharField(max_length=50)# ❌ N+1:100 篇文章、每篇 3 个标签 = 301 次查询articles=Article.objects.all()forarticleinarticles: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.3select_relatedvsprefetch_related对比
| select_related | prefetch_related | |
|---|---|---|
| 实现方式 | SQL JOIN | 额外查询 + Python 组装 |
| 支持关系类型 | ForeignKey、OneToOne | ManyToMany、反向外键、ForeignKey 也可以 |
| SQL 查询数 | 1 条 | 2 条(或更多层嵌套) |
| 什么时候用 | 外键聚合简单,主表行数不大 | 多对多、反向外键、或不想用 JOIN 聚合时 |
5 ~> 进阶——嵌套预加载和多层关联
# 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也支持嵌套:
# 预加载文章 → 标签 → 每个标签的分类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就是你的两把工具。下篇讲一个请求从浏览器到数据库的完整旅程。一键四连!