23-Django-ORM的N+1问题-select_related与prefetch_related详解
2026/6/16 7:34:02 网站建设 项目流程

文章目录

  • 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_relatedprefetch_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.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 什么是懒加载

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 语法

# ✅ 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.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 实例

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_relatedprefetch_related
实现方式SQL JOIN额外查询 + Python 组装
支持关系类型ForeignKey、OneToOneManyToMany、反向外键、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 问题的三个核心认知:

  1. 懒加载是必要的优化,但在循环中逐个访问关联对象就会被放大为性能杀手。遇到for obj in queryset: obj.related_field直接加select_relatedprefetch_related
  2. select_related= SQL JOIN,prefetch_related= 额外查询 + Python 组装。外键用前者,多对多用后者,嵌套用双下划线。
  3. 装个 Django Debug Toolbar——它让你看到每次请求执行了多少条 SQL。N+1 问题从来不是靠肉眼排查的,而是靠数字暴光的。

结尾

N+1 问题到这里拆解完毕。感谢阅读!

源码骑士 — 源码级拆解,从底层看透技术

👀关注:跟博主一起从源码视角深耕底层原理

❤️点赞:让优质内容被更多人看见

收藏:核心知识点存好,随用随查

💬评论:分享你的经验或疑问,一起交流

🔄一键四连:别忘了给博主一键四连!

🗡️寄语:一条 SQL 变成 100 条——这就是 N+1 的可怕之处。一条select_related把它变回一条。

结语:N+1 是 Django 项目性能优化的头号切入点。select_relatedprefetch_related就是你的两把工具。下篇讲一个请求从浏览器到数据库的完整旅程。一键四连!

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询