orders = Order.objects.<span>filter</span>(status=<span>'paid'</span>)
<span>for</span> order <span>in</span> orders:
<span>print</span>(order.user.username) <span># 访问关联的 User</span>
<span>for</span> item <span>in</span> order.items.<span>all</span>(): <span># 访问关联的 OrderItem</span>
<span>print</span>(item.product.name) <span># 再访问 OrderItem 的 Product</span>
他说:“Django 的 ORM 不是自动处理关联查询吗?我直接用点号访问,多方便。”
是方便。但每次点号访问,只要关联对象还没被加载,Django 就会单独发一条 SQL 去数据库取。
一个订单列表页,假设有 200 个订单,每个订单 5 个商品,涉及 100 个用户。那么:
- 取订单:1 条 SQL
- 每个订单取用户:200 条 SQL
- 每个订单取商品项:200 条 SQL
- 每个商品项取商品:1000 条 SQL
加起来 1401 条 SQL。每条 SQL 哪怕只花 5 毫秒,也要 7 秒多。这就是典型的 N+1 查询问题。不是数据库慢,是查询次数太多。
解决办法就是 select_related 和 prefetch_related。但这两个名字长得像、功能描述也像——“都是用来减少查询次数的”。很多人随便挑一个用,结果要么没效果,要么报错,要么内存暴涨。
先搞清楚外键在数据库里长什么样
要理解两者的区别,得先回到数据库层面看关联关系是怎么存的。
以订单和用户为例:
class User(models.Model):
<span>username</span> = models.CharField(max_length=<span>100</span>)
class Order(models.Model):
<span>user</span> = models.ForeignKey(User, <span>on</span>_delete=models.CASCADE)
<span>status</span> = models.CharField(max_length=<span>20</span>)
<span>created_at</span> = models.DateTimeField(auto_now_add=<span>True</span>)
Order 表里有一个 user_id 字段,存的是用户表的主键。这是一个多对一关系:多个订单可以属于同一个用户,每个订单只有一个用户。
当 Django 执行 order.user 的时候,它看到 Order 实例的 user_id 是 5,但 user 对象还没加载,于是发一条 SQL:
<span>SELECT</span> <span>*</span> <span>FROM</span> <span>user</span> <span>WHERE</span> id <span>=</span> <span>5</span>;
如果循环里对每个订单都这么做,就是 N 条额外 SQL。
select_related 解决的就是这个问题。它告诉 Django:“在取订单的时候,顺便把关联的用户也取出来。” Django 会用 SQL 的 JOIN 把两张表连起来,一次查询拿到所有数据:
<span>SELECT</span> <span>order</span>.*, user.*
<span>FROM</span> <span>order</span>
INNER <span>JOIN</span> user <span>ON</span> <span>order</span>.user_id = user.id
<span>WHERE</span> <span>order</span>.status = <span>'paid';</span>
拿到结果后,Django 在内存里把每一行拆成 Order 和 User 两个对象,并建立好引用。之后 order.user 直接返回已经加载好的对象,不再发 SQL。
一次 JOIN 查询代替了 N+1 条查询。
多对多和反向关系,JOIN 搞不定
现在看订单和商品项的关系:
class OrderItem(models.Model):
<span>order</span> = models.ForeignKey(Order, <span>on</span>_delete=models.CASCADE)
<span>product</span> = models.ForeignKey(Product, <span>on</span>_delete=models.CASCADE)
<span>quantity</span> = models.IntegerField()
OrderItem 有外键指向 Order。反过来,一个订单可以有多个订单项。从 Order 到 OrderItem 是一个一对多的反向关系。
Django 默认给反向关系起名叫 orderitem_set,但如果 ForeignKey 里设置了 related_name='items',就可以用 order.items.all() 访问。
问题来了:select_related 能不能用在 order.items.all() 上?
不能。select_related 只能用于单值关系,也就是 ForeignKey 和 OneToOneField。因为 JOIN 之后,一行订单对应一行用户,一对一,不会产生重复行。
但 order.items.all() 是一对多,一个订单对应多个订单项。如果硬用 JOIN:
<span>SELECT</span> <span>order</span>.*, orderitem.*
<span>FROM</span> <span>order</span>
INNER <span>JOIN</span> orderitem <span>ON</span> orderitem.order_id = <span>order</span>.id;
一个订单有 5 个订单项,结果集里就会出现 5 行。Django 拿到这 5 行后,需要把它们合并成一个 Order 对象,再把 5 个 OrderItem 挂上去。这可行,但如果有多个一对多关系同时 JOIN,行数会乘法式膨胀,性能反而更差。
所以 Django 对多对多和反向一对多提供了另一个工具:prefetch_related。
prefetch_related 走的是另一条路
prefetch_related 不用 JOIN。它的做法是分两步查询,然后在 Python 层面做匹配。
还是用订单和订单项举例:
<span>orders</span> = Order.objects.prefetch_related(<span>'items'</span>).filter(status=<span>'paid'</span>)
Django 会执行两条 SQL:
第一条,取所有订单:
<span>SELECT</span> <span>*</span> <span>FROM</span> <span>order</span> <span>WHERE</span> status <span>=</span> <span>'paid'</span>;
假设拿到 200 个订单,ID 是 1 到 200。
第二条,取这些订单的所有订单项:
<span>SELECT</span> <span>*</span> <span>FROM</span> orderitem <span>WHERE</span> order_id <span>IN</span> (<span>1</span>, <span>2</span>, <span>3</span>, ..., <span>200</span>);
一条 SQL 把所有相关订单项都取回来。
然后在 Python 里,Django 遍历这 200 个订单项,根据每项的 order_id,把它挂到对应的 Order 对象的 _prefetched_objects_cache 里。之后再访问 order.items.all(),直接返回缓存好的列表,不再发 SQL。
总共 2 条 SQL,而不是 201 条。
prefetch_related 还能继续往下预取。比如订单项里还有 product 外键:
<span>orders</span> = Order.objects.prefetch_related(<span>'items__product'</span>)
它会发三条 SQL:取订单、取订单项、取商品。然后在 Python 里把 OrderItem 和 Product 关联起来。比用 select_related 做多层 JOIN 更可控,也更省内存。
核心区别:一条 SQL 还是两条 SQL
现在可以做个清晰的对比了:
| 维度 | select\_related | prefetch\_related |
|---|---|---|
| 查询方式 | SQL JOIN | 分步查询 + Python 匹配 |
| SQL 条数 | 1 条 | 2 条(或更多) |
| 适用关系 | ForeignKey、OneToOneField | ManyToManyField、反向 ForeignKey |
| 能否用于多对多 | 不能 | 能 |
| 能否用于反向一对多 | 不能 | 能 |
| 内存占用 | 较低(单行不膨胀) | 较高(要存所有关联对象) |
| 是否支持自定义查询 | 有限 | 支持 Prefetch 对象 |
| 数据库压力 | JOIN 可能较重 | 多条简单查询,通常更轻 |
一句话总结:select_related 用 JOIN 一次拿完,适合单值关系;prefetch_related 分两次拿,适合多值关系。
判断标准很简单:关联对象只有一个,用 select_related;关联对象有多个,用 prefetch_related。
order.user 是单个用户,用 select_related('user')。 order.items.all() 是多个订单项,用 prefetch_related('items')。 user.order_set.all() 是反向多个订单,用 prefetch_related('order_set')。
用错了会怎样
用 prefetch_related 去预取一个 ForeignKey 行不行?行,但没必要。它会多发一条 SQL,虽然也能工作,但比 select_related 多一次数据库往返。
用 select_related 去预取多对多呢?直接报错:
FieldError: Cannot resolve keyword <span>'items'</span> <span>into</span> field. Choices <span>are</span>: ...
Django 在解析 select_related 的参数时,只会查 ForeignKey 和 OneToOneField。遇到多对多或反向关系,它找不到对应的字段,直接抛异常。
还有一种常见错误:在 prefetch_related 里套 select_related 的语法:
Order.objects.prefetch_related(<span>'user__profile'</span>) <span># 可以,会分步预取</span>
Order.objects.select_related(<span>'items__product'</span>) <span># 报错,items 不是单值关系</span>
如果一条链路上既有单值又有多值,可以混用:
<span>Order</span><span>.objects</span><span>.select_related</span>('user')<span>.prefetch_related</span>('items__product')
Django 会先 JOIN 用户,再分步预取订单项和商品。这是处理复杂关联的标准写法。
Prefetch 对象:更精细的控制
prefetch_related 默认会把关联对象的全部字段和全部记录都取回来。如果关联数据量很大,或者你只需要其中一部分,可以用 Prefetch 对象做精细控制。
from django.db.models import Prefetch
<span>orders</span> = Order.objects.prefetch_related(
Prefetch(
'items',
<span>queryset</span>=OrderItem.objects.filter(quantity__gt=<span>1</span>).select_related(<span>'product'</span>),
<span>to_attr</span>=<span>'big_items'</span>
)
)
这里做了三件事:
- 只预取
quantity > 1的订单项,不是全部。 - 预取时顺便用
select_related把product也加载了,避免后续再查。 - 结果存到
order.big_items这个自定义属性里,而不是默认的order.items。
之后用 order.big_items 访问,得到的是一个列表,已经过滤好、关联好,且不再触发 SQL。
Prefetch 的价值在于:它让你在“分步查询”这个框架里,仍然能对第二步查询做定制。这是 select_related 做不到的——JOIN 的 SQL 由 Django 生成,你很难在中间插条件。
性能对比:不只是 SQL 条数
很多人以为 select_related 一定比 prefetch_related 快,因为 SQL 条数少。这不一定。
select_related 用 JOIN,如果关联表很大,或者关联链很长,JOIN 出来的结果集可能非常宽。比如 select_related('user', 'product', 'address', 'payment'),每行订单都会带上四张表的全部字段。字段越多,网络传输和内存解析的开销越大。
prefetch_related 分步查,每条 SQL 只取一张表的字段,结果集更干净。在关联数据量大、字段多的情况下,反而可能更快。
另一个因素是索引。JOIN 的 ON 条件如果没有索引,数据库要做全表扫描。而 prefetch_related 的 IN 查询通常能用到主键索引,效率很高。
所以选择依据不是“哪个更快”,而是“哪个正确”。单值关系用 select_related,多值关系用 prefetch_related。这是由关系类型决定的,不是性能调优的结果。
一个真实场景
我之前维护一个博客系统,首页要展示最新的 20 篇文章,每篇文章显示作者名、分类名、标签列表、评论数。
最初的代码:
posts = Post<span>.objects</span><span>.all</span>()<span>[:20]</span>
for post in posts:
<span>print</span>(post.author.username)
<span>print</span>(post.category.name)
for tag in post.tags.<span>all</span>():
<span>print</span>(tag.name)
<span>print</span>(post.comments.<span>count</span>())
author 和 category 是外键,可以用 select_related。tags 是多对多,comments 是反向一对多,需要用 prefetch_related。
优化后:
<span>posts</span> = Post.objects.select_related(<span>'author'</span>, <span>'category'</span>).prefetch_related(<span>'tags'</span>, <span>'comments'</span>)[:<span>20</span>]
SQL 从 1 + 20 + 20 + 20 + 20 = 81 条降到 1 + 20 + 20 + 20 = 61 条?不是。
select_related 把 author 和 category 合并进第一条 SQL,所以取文章是 1 条。 prefetch_related('tags') 是 1 条:SELECT * FROM tag WHERE post_id IN (...)。 prefetch_related('comments') 是 1 条:SELECT * FROM comment WHERE post_id IN (...)。
总共 3 条 SQL。页面加载从 4 秒降到 200 毫秒。
注意 comments.count() 的问题。prefetch_related('comments') 会把所有评论对象加载到内存,如果只是想显示数量,用 annotate 更合适:
from django.db.models import Count
<span>posts</span> = Post.objects.select_related(<span>'author'</span>, <span>'category'</span>).prefetch_related(<span>'tags'</span>).annotate(comment_count=Count(<span>'comments'</span>))[:<span>20</span>]
这样评论数由数据库直接算好,不用把评论对象全部取回来。prefetch_related 适合“需要访问关联对象本身”的场景,annotate 适合“只需要聚合值”的场景。
怎么发现 N+1
Django Debug Toolbar 是最直观的工具。装好之后,页面侧边栏会显示每个请求执行了多少条 SQL。如果看到几十上百条相似结构的查询,基本就是 N+1。
没有 toolbar 的话,可以用 connection.queries:
from django.db import connection
<span># 执行查询</span>
list(orders)
<span>print</span>(len(connection.queries))
<span>for</span> <span>q</span> in connection.queries:
<span>print</span>(<span>q['sql']</span>)
connection.queries 只在 DEBUG=True 时记录,生产环境不适用。生产环境可以用 django-silk 或者 APM 工具做 SQL 监控。
还有一个办法:在测试里断言查询次数。
<span>from</span> django.test <span>import</span> TestCase
<span>from</span> django.assertNumQueries <span>import</span> assertNumQueries
<span>class</span> <span>OrderTest</span>(<span>TestCase</span>):
<span>def</span> <span>test_order_list_queries</span>(<span>self</span>):
<span>with</span> self.assertNumQueries(<span>3</span>):
orders = Order.objects.select_related(<span>'user'</span>).prefetch_related(<span>'items'</span>)
<span>for</span> order <span>in</span> orders:
order.user.username
<span>list</span>(order.items.<span>all</span>())
如果实际查询次数和预期不符,测试直接失败。这个方法能把 N+1 挡在上线之前。
什么时候不用预取
预取不是越多越好。有几个场景要谨慎:
第一,关联数据量极大。比如一篇热门文章有十万条评论,prefetch_related('comments') 会一次性把十万条评论全部加载到内存,直接 OOM。这时候应该分页,或者用 annotate 只取数量。
第二,预取了但没用到。select_related 和 prefetch_related 都有开销。如果某个关联对象在后续逻辑里根本没被访问,预取就是浪费。只预取确定会用到的关系。
第三,查询集只取一条记录。Post.objects.get(id=1) 本身只发一条 SQL,即使有 N+1,N 也是 1。预取的意义不大。预取主要在列表页、循环访问的场景下发挥作用。
最后
select_related 和 prefetch_related 的区别,本质上是单值关系和多值关系的区别,是一条 JOIN 和分步查询的区别。
名字像,但适用场景不重叠:ForeignKey 和 OneToOneField 用 select_related,ManyToManyField 和反向 ForeignKey 用 prefetch_related。混用、用错,轻则没效果,重则报错或内存暴涨。
处理列表页的时候,习惯性地问自己:这个循环里访问了哪些关联对象?它们是单个还是多个?单个的进 select_related,多个的进 prefetch_related。再配上 assertNumQueries 写个测试,N+1 就再也坑不到你了。
把 ORM 预取的选择标准讲得很清楚:单值用 select_related,多值用 prefetch_related。适合正被 N+1 拖慢的 Django 列表页与接口优化场景,能快速定位并落地。