23-Django-ORM的N+1问题-select_related与prefetch_related详解

文章目录

  • [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_related vs prefetch_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_relatedprefetch_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.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.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 内部匹配到各自的文章
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 问题的三个核心认知:

  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 就是你的两把工具。下篇讲一个请求从浏览器到数据库的完整旅程。一键四连!

相关推荐
Tbisnic1 小时前
AI大模型学习第十四天:Coze项目实战中的分治智慧
人工智能·python·学习·大模型·工作流·智能体·coze
master3361 小时前
python 安装pip
开发语言·python·pip
江畔柳前堤1 小时前
github实战指南03-Pull Request 全流程实战
开发语言·人工智能·python·深度学习·github·word
掘金者阿豪1 小时前
当内容平台越来越多后,我决定把文章放回自己的地盘
后端
llz_1122 小时前
web-第六次课后作业
前端·spring boot·后端
何以解忧,唯有..2 小时前
Go语言类型转换详解:从基础到进阶实践
开发语言·后端·golang
爱勇宝2 小时前
CEO通知5100名员工:今年不涨薪了,钱要投给AI!
前端·后端·程序员
何以解忧,唯有..2 小时前
Go 语言指针类型详解:从基础到实战
开发语言·后端·golang