小Cの已经记不起来的博客

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 太顺手,顺手到让人忘了它背后还在查库。列表页这种地方,提前多想一步,后面能省不少事。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放