3.2 查询性能与 N+1 治理


文档摘要

3.2 查询性能与 N+1 治理 本节摘要:N+1 查询是 Django 项目最常见的性能问题:列表页对每条记录再发一次关联查询,页面越热闹数据库越遭殃。本节用墨迹博客的真实案例复现它,讲清两种预取方案的原理与选择,并建立"先度量再优化"的度量习惯。这一节是上线前的必修课。 复现:一个 101 条 SQL 的首页 墨迹博客首页要展示十篇最新文章,每篇显示作者名和分类名。直觉写法: 模板里: 功能完全正确,开发环境毫无异样。打开 SQL 日志(配置数据库引擎的日志级别为 DEBUG),访问首页,日志是这样的: 十篇文章,二十次额外往返。访问外键属性时代码,Django 无法保证稍后还会用到这个作者,于是每次都单独查、查完就丢。这没有任何 bug,是懒加载机制的天然副产品。

3.2 查询性能与 N+1 治理

本节摘要:N+1 查询是 Django 项目最常见的性能问题:列表页对每条记录再发一次关联查询,页面越热闹数据库越遭殃。本节用墨迹博客的真实案例复现它,讲清两种预取方案的原理与选择,并建立"先度量再优化"的度量习惯。这一节是上线前的必修课。

复现:一个 101 条 SQL 的首页

墨迹博客首页要展示十篇最新文章,每篇显示作者名和分类名。直觉写法:

def article_list(request): articles = Article.published.all()[:10] return render(request, "blog/list.html", {"articles": articles})

模板里:

{% for a in articles %} <li>{{ a.title }} — {{ a.author.username }} / {{ a.category.name }}</li> {% endfor %}

功能完全正确,开发环境毫无异样。打开 SQL 日志(配置数据库引擎的日志级别为 DEBUG),访问首页,日志是这样的:

SELECT ... FROM blog_article WHERE status=1 ORDER BY created_at DESC LIMIT 10; -- 1 条 SELECT ... FROM auth_user WHERE id=3; -- 第 1 篇的作者 SELECT ... FROM blog_category WHERE id=1; -- 第 1 篇的分类 SELECT ... FROM auth_user WHERE id=5; -- 第 2 篇…… ... 共 21 条

十篇文章,二十次额外往返。访问外键属性时代码,Django 无法保证稍后还会用到这个作者,于是每次都单独查、查完就丢。这没有任何 bug,是懒加载机制的天然副产品。评论数再加进模板(每篇文章统计一次评论),SQL 就变成 1 + 10 + 10 + 10 = 31 条;文章涨到 50 条时 151 条。N+1 的问题在于它随数据量线性恶化,而测试环境十篇文章根本暴露不出来。

两种预取,原理不同

select_related 用 SQL 连表,一次查询把外键对象带回来,适合"多对一":

articles = Article.published.select_related( "author", "category" ).all()[:10] # 1 条 SQL:article LEFT JOIN auth_user LEFT JOIN blog_category

prefetch_related 用两次查询加内存拼接,适合反向关系与多对多:

from django.db.models import Count articles = (Article.published .select_related("author", "category") .annotate(comment_count=Count("comments")) .all()[:10]) # 1 条主查询 + 1 条评论聚合,共 2 条,模板里 a.comment_count 零额外查询

选择标准记一句话:正向外键 select_related,反向与多对多 prefetch_related。原理差异决定了各自的边界——连表会让单条 SQL 变宽变重,预取的第二次查询结果要能装进内存。给外键链路"穿两层"(作者的个人简介)时,select_related 参数写"author__profile"。

治理前后对比

治理前后对比

先度量,再优化

N+1 之外的慢查询,通用排查顺序是:开 SQL 日志数条数、看耗时;对最慢那条用数据库的执行计划分析(EXPLAIN)看是否走索引;再决定加索引还是改查询。墨迹博客曾有一条"按标签查文章"的查询,两万行数据时要 800 毫秒,EXPLAIN 显示全表扫描——因为第 2 章 Meta 里的索引贴着首页查询建,没覆盖标签路径。补一个迁移加索引后降到 9 毫秒。优化从来不是玄学,是"看执行计划、改、再看"的循环。

两个易被忽视的点:

  • 分页别用大偏移。 第 10000 页的查询意味着数据库要扫过前十万行再丢弃。数据深时可改用"游标式"分页——where 条件接上一页末条的主键或时间戳。
  • 只取需要的列。 values 或 only 能把宽表查询变窄,列表页只取标题、时间、作者名时尤其明显。代价是漏取的字段被访问时会再触发查询,用 defer 排除大字段更稳妥。

⚠️ 预取不是越多越好:给不需要的关联都挂上预取,主查询会变成一张巨型宽表,内存占用与翻译开销一起涨。原则仍是"贴着模板实际访问的关联预取"。

防复发:把 N+1 挡在评审阶段

治理完存量只是这场战役的一半,另一半是防止复发。N+1 的复发路径非常典型:新同事在模板里随手写了访问外键属性的循环,测试环境数据少感知不到,上了生产流量一放大就炸。墨迹博客的防线有两道:第一道是测试断言——写一条基础测试用类,在关键列表页请求期间捕获执行的查询数,超过阈值即失败,新增的多余查询会在发布前暴露;第二道是评审习惯——凡是看到模板或序列化代码里出现循环体,评审人必问一句循环内的每个属性访问是否已在视图层取齐。两道防线的成本都不高,却把这类问题从生产事故降级成了提交记录里的一条修正。性能问题的最好治理时机永远不是压测之后,而是代码进入主干之前。

本节要点回顾

  • N+1 源于懒加载的天然行为:访问外键属性即触发单独查询,随数据量线性恶化。
  • 正向外键用 select_related 连表,反向与多对多用 prefetch_related 两次查询拼内存。
  • 聚合统计进 annotate,别在模板里对每行调计数方法。
  • 度量先于优化:SQL 日志数条数,执行计划看索引,最后才是改代码。

查询这条腿站稳了,下一章进入视图层,把这些数据用 HTTP 的语言交付出去。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U