Django ORM 多表关联查询避免 N+1 问题的实践
问题是怎么冒出来的
一开始我也没把列表页当回事。文章列表嘛,每行显示标题、作者、分类、评论数,再带两条最新评论,看起来挺简单。本地数据就几十条,页面嗖嗖的,完全感觉不到问题。结果数据一多,线上页面开始肉眼可见地慢,打开 Django Debug Toolbar 一看,好家伙,一条列表页干出来几百条 SQL。
当时第一反应是:“我也没写循环查库啊?”后来把 SQL 展开才发现,问题全藏在模板里。比如下面这种写法:
articles = Article.objects.all()
模板里再这么用:
{% for article in articles %}
<div>{{ article.title }}</div>
<div>{{ article.author.name }}</div>
<div>{{ article.category.name }}</div>
<div>{{ article.comments.count }}</div>
{% endfor %}
表面上看只有一次 Article.objects.all(),实际上 Django ORM 默认是懒加载的。循环到每一篇文章时,访问 article.author 会查一次作者,访问 article.category 会查一次分类,访问 article.comments.count 又会查一次评论数。100 篇文章就是 1 次主查询加 300 次关联查询,这就是典型的 N+1。
N+1 到底长什么样
N+1 不是说 ORM 写得有多复杂,很多时候就是“访问关联对象”这个动作太自然了。比如 article.author.name,谁看都觉得只是取个字段,但 ORM 不知道你后面还要不要用这个作者,它只能在你真正访问的时候再去数据库拿。
我后来养成了一个习惯:只要看到列表页、循环里出现 obj.foreign_key.xxx、obj.many_to_many.all()、obj.reverse_set.all(),就先警惕一下。尤其是模板里,最容易把查询藏起来。
外键和一对一:优先想到 select_related
像作者、分类这种外键,或者一对一关系,我一般先用 select_related。它会让 Django 在查主表的时候直接 JOIN 关联表,一次把数据带回来。
articles = (
Article.objects
.select_related("author", "category")
.all()
)
这样模板里再访问 article.author.name 和 article.category.name,就不会额外查库了。select_related 适合单值关系,也就是外键和一对一。别一看有关联就往上堆,JOIN 太多表也会让 SQL 变复杂,尤其是关联表数据量大的时候,得看实际情况。
多对多和反向外键:用 prefetch_related
标签、评论这种多对多或者反向外键,select_related 就不合适了,得用 prefetch_related。它不是 JOIN,而是先查主表,再用主表的 ID 去查关联表,通常是两次查询,然后把结果拼起来。
articles = (
Article.objects
.select_related("author", "category")
.prefetch_related("tags", "comments")
)
循环里访问 article.tags.all() 或者 article.comments.all() 时,Django 会直接用预取好的缓存,不会每篇文章再打一次数据库。
但这里有个坑我踩过:prefetch_related("comments") 之后,如果你在循环里写 article.comments.filter(is_deleted=False),它不一定会用预取结果,反而可能重新查库。因为你在关联管理器上又加了 filter,ORM 觉得这是新的查询。这个时候就得用 Prefetch 把查询条件带进去。
用 Prefetch 控制预取内容
比如我只要未删除的评论,还要按时间倒序,并且评论里带用户信息,可以这么写:
from django.db.models import Prefetch
comments_qs = (
Comment.objects
.select_related("user")
.filter(is_deleted=False)
.order_by("-created_at")
)
articles = (
Article.objects
.select_related("author", "category")
.prefetch_related(
Prefetch("comments", queryset=comments_qs, to_attr="active_comments")
)
)
用了 to_attr 之后,模板里就访问 article.active_comments,它是个列表,不再是管理器。这样既控制了预取的数据范围,也避免了在模板里再套 filter。
不过要注意,to_attr 出来的是列表,不能再调用 .filter()、.count() 这些管理器方法。需要计数就提前用 annotate。
每行一个 count,尽量用 annotate
评论数这个事,我一开始就是 article.comments.count,简单直接,但列表页每行来一次,SQL 数量直接爆炸。后来改成聚合:
from django.db.models import Count, Q
articles = (
Article.objects
.select_related("author", "category")
.annotate(
comment_count=Count(
"comments",
filter=Q(comments__is_deleted=False),
distinct=True,
)
)
)
模板里直接用 article.comment_count。这样主查询里就把计数带出来了,不用每行再查一次。
这里提一句,如果同时 annotate 多个多对多聚合,可能会出现 JOIN 导致计数被放大,distinct=True 有时候能救,但复杂场景下我会考虑子查询或者拆开查。别硬堆一个大而全的 QuerySet,最后 SQL 长得自己都不想看。
分页、序列化器和模板里的隐藏查询
列表页一般都要分页。我的习惯是先把查询条件、select_related、prefetch_related、annotate 都写好,再切片分页:
page = articles[:20]
Django 的 prefetch_related 会在主查询结果确定后,针对当前这页的 ID 去拉关联数据,所以不用太担心把全表都预取回来。但如果你在分页前就做了很重的排序、聚合,那还是得看 SQL。
另外,DRF 序列化器里也容易出 N+1。比如 SerializerMethodField 里访问 obj.author.name,或者自己写 obj.comments.count(),一样会查。我的做法是列表接口尽量把需要的关联和计数在 QuerySet 阶段搞定,序列化器只做字段拼装。
怎么确认查询真的少了
我现在改完这种列表页,一般会看两个东西:Django Debug Toolbar 和 connection.queries。Debug Toolbar 直观,能看到每条 SQL 和重复次数。connection.queries 适合临时在 shell 里确认:
from django.db import connection
articles = list(
Article.objects
.select_related("author", "category")
.prefetch_related("tags")
)
print(len(connection.queries))
当然这只在 DEBUG=True 时好用。写测试的话可以用 assertNumQueries,把查询数量卡住,后面谁不小心加了循环查库,测试会先报警。
最后一点个人习惯
我现在不会一上来就到处 select_related、prefetch_related,而是先把功能写出来,然后盯着列表页和循环看一遍。只要有循环里访问关联对象,就检查是不是能预取;只要有每行 count,就看看能不能 annotate;模板里少调用方法,尤其是那种看起来像属性的方法。
还有,only 和 defer 我也用,但比较谨慎。你省了字段,后面访问没加载的字段,ORM 还是会偷偷查一次,N+1 又回来了。与其这样,不如先把关联查询理清楚,再考虑字段裁剪。
说到底,N+1 不是什么高深问题,就是 ORM 太顺手,顺手到让人忘了它背后还在查库。列表页这种地方,提前多想一步,后面能省不少事。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。