列表页越刷越慢:Django ORM 的 N+1 查询与三层修法

作者:程序员白大力 · 2026-09-29 00:55

封面

列表页有个很典型的经历:开发环境十来条数据,页面秒开,测试全绿;上线后数据涨到几百条,页面肉眼可见地变卡,数据库监控里却查不到任何一条慢查询——单条都很快,慢在数量。这类问题最常见的原因就是 N+1 查询,它是 ORM 带来的最隐蔽的性能坑:代码越「顺手」,查询越多。这篇文章把它的成因、两条标准修法和防回归手段一次讲清。

一、为什么 N+1 藏得这么深

根源在 ORM 的两个默认行为。第一个是查询集的惰性求值:QuerySet 创建时不会碰数据库,直到迭代它、取长度、转列表时才真正执行查询。这让代码写起来很自由,也让「哪一行触发了查询」变得不直观。

第二个是关联对象的按需加载:访问 article.category 时,Django 发现这个外键对象不在内存里,就当场发一条查询去取。每次访问都取一次,绝不提前。

单独看这两条都是合理设计,叠在一起、再套上一个循环,N+1 就出现了。而它藏得深的原因是:每条查询都是主键点查,数据库执行计划完美,任何单条都不慢。慢的是 N 次网络往返和 N 次查询解析的固定开销乘以行数,这类账在单条查询的监控里根本看不见。

二、N+1 长什么样

先看一段再正常不过的列表视图代码:

# 视图:查一页文章
articles = Article.objects.order_by("-pub_date")[:20]

模板里再把每篇文章的分类名显示出来:

{% for a in articles %}
  {{ a.title }} — {{ a.category.name }}
{% endfor %}

执行时是 1 条查询取出 20 篇文章,然后模板每渲染一行,就为 a.category 单独发一条查询——20 篇文章就是 21 条查询,100 篇就是 101 条。这就是「N+1」:1 条列表查询,加 N 条逐行补查。查询条数随数据行数线性增长,是它最可靠的判别特征。

三、两条标准修法:select_related 与 prefetch_related

Django 对这类问题给了两个预加载工具,职责分得很清楚:

维度 select_related prefetch_related
适用关系 外键、一对一(单值) 多对多、反向外键(多值)
策略 SQL JOIN,一次取回 每个关系一条额外查询,Python 里拼合
查询数 1 条 2 条(主查询 + 1 条预取)
为什么这样 JOIN 多值关系会让行数翻倍 牺牲一条查询换掉 N 条

一对一的关联用 select_related,它直接在 SQL 里做 JOIN:

# 外键关联一次 JOIN 取回,模板里访问 a.category 不再触发查询
articles = (
    Article.objects
    .select_related("category")
    .order_by("-pub_date")[:20]
)

多对多或反向关系用 prefetch_related,JOIN 会让父行按关联数成倍复制,所以它改发一条 WHERE id IN (...) 的批量查询,再在 Python 侧拼合:

# 标签是多对多:两条查询解决,不再每篇文章查一次标签
articles = Article.objects.prefetch_related("tags")[:20]

两条链可以按需组合,核心原则是显式写出本次页面会用到的关系——把「将要访问什么」从循环里挪到查询构建的那一刻说清楚。

四、一个官方文档点名警告的坑

prefetch_related 不是贴上就完事。预取缓存对应的是 .all(),如果你预取了 tags,循环里却写了 a.tags.filter(heat__gt=10),这条新的过滤查询用不上预取缓存,N+1 原样复发——而且白跑了一条预取查询,比不预取还多一次。

正确做法是用 Prefetch 对象把过滤条件定义在预取那一刻:

# 把过滤写进 Prefetch,循环里用 a.hot_tags 拿到的就是缓存结果
from django.db.models import Prefetch

articles = Article.objects.prefetch_related(
    Prefetch("tags", queryset=Tag.objects.filter(heat__gt=10),
             to_attr="hot_tags")
)

预取定义什么,循环里就用什么,两边对不上等于白做。

五、检测与防回归:把查询数锁进测试

N+1 功能测试查不出来(页面是对的),肉眼评审也容易漏,所以要靠工具和测试两道防线。

开发期看工具:django-debug-toolbar 的 SQL 面板会列出每个请求的全部查询,并把相似的、重复的查询高亮标出——一排除了参数完全相同的查询,就是 N+1 的现场。

测试期锁预算:Django 自带 assertNumQueries,断言一段代码正好执行 N 条查询,可以直接当防回归闸门用:

# 列表页的查询数预算:数据涨到 100 篇也必须是这 3 条
def test_list_page_query_budget(self):
    self.make_articles(30)
    with self.assertNumQueries(3):
        self.client.get("/articles/")

这个测试的价值在于:谁以后删掉了预加载、或模板里新加了一个逐行取关联的字段,CI 当场红,而不是等线上数据库告警。数字本身不用追求精确到条,锁住「不随行数增长」这个性质就赢了。

还有一类变体值得点名:循环里写 a.comments.count() 同样是 N+1,只是补查换成了计数。这种该用 annotate(Count("comments")) 在主查询里一次算完。

六、结尾

N+1 问题的解法不复杂——两条预加载、一个过滤坑、一道查询数闸门,半小时能讲完。难的是意识到它存在:ORM 把查询藏在了属性访问的语法糖后面,性能也就藏在了正确性的阴影里。列表页、详情页、后台管理,凡是「一条主查询 + 循环里取关联」的地方,都值得用查询数过一遍。

如果你也在维护一个数据慢慢变多的 Django 站点,不妨现在就翻出列表页数一数查询条数——欢迎把你的踩坑经历留言分享,也转发给那个正在被「莫名变慢」困扰的同事。

参考文章

  • QuerySet API 参考:select_related 与 prefetch_related(Django 官方文档)
  • When QuerySets are evaluated(Django 官方文档)
  • Testing tools:assertNumQueries(Django 官方文档)
  • Detecting N+1 Queries in the Django ORM(Database Execution Plans)
  • Django ORM Query Optimization: A Practical Guide(Micropyramid)
本文由 AI 辅助生成并经人工审核发布,内容仅供参考。