Django ORM查询优化与N+1问题解决
引言
去年双十一大促前夜,我接手了一个"看起来很正常"的 Django 服务:接口 P99 延迟 800ms,CPU 只用了 15%,数据库连接池却长期打满。翻遍慢查询日志,每条 SQL 单独执行都只要 2ms——问题不在"哪条 SQL 慢",而在"SQL 太多"。
一个商品列表接口,返回 50 条商品,每条商品要展示所属店铺名和最近 3 条评论。代码写得清清楚楚:
products = Product.objects.filter(status=1)[:50]
for p in products:
print(p.shop.name) # 每条一次查询
print([c.content for c in p.comments.all()[:3]]) # 每条再一次50 条商品 → 1 + 50 + 50 = 101 次数据库往返。这就是经典的 N+1 查询问题。它不体现在任何一条慢 SQL 里,却把网络 RTT 放大了一百倍。
这篇文章我们从 Django ORM 的源码出发,把 N+1 的成因、select_related/prefetch_related 的真实实现、Prefetch 对象的进阶用法、以及 only/defer 与 prefetch 的踩坑点全部拆开讲清楚,最后给出可落地的最佳实践。
核心概念:用"点菜"理解 N+1
想象你在一家餐厅点了一桌菜:
- N+1 的做法:你先点"一份套餐"(1 次查询拿到 50 个商品),然后服务员每上一道菜,你都要单独按一次铃问"这道菜的厨师是谁"(50 次查询问店铺),再按一次铃问"这道菜的配料表"(50 次查询问评论)。菜本身不慢,按铃的次数才是灾难。
- JOIN 的做法(select_related):点菜时直接说"套餐连同厨师的姓名工号一起端上来",后厨一次性把关联信息拼在盘子里。一次往返,但盘子可能变大(数据冗余)。
- 批量预取的做法(prefetch_related):服务员先把 50 道菜上齐,然后拿着一张"所有菜品 ID 清单"去后厨一次性把 50 份配料表全领回来,再按 ID 分发到各桌。两次往返,但每次都是批量操作。
技术定义上:
| 概念 | 本质 | 触发时机 |
|---|---|---|
| N+1 | 懒加载(lazy loading)在循环中被逐条触发 | 访问关联属性时 |
select_related |
SQL JOIN,把外键/一对一的关联表列拼进主查询 |
查询构造时 |
prefetch_related |
先查主表,再用 IN (...) 批量查关联表,Python 层做映射 |
查询求值后立即执行 |
关键区别:select_related 走的是数据库的 JOIN,适合"一对一、多对一"(正向 FK / OneToOne);prefetch_related 走的是两次独立查询 + Python 内存拼接,适合"一对多、多对多"(反向 FK / M2M),因为 JOIN 一对多会产生笛卡尔积式的行膨胀。
源码/原理深度分析
1. 懒加载是如何"埋雷"的
Django 的模型字段描述符在访问时触发查询。以正向 FK 为例,django/db/models/fields/related_descriptors.py 中 ForwardManyToOneDescriptor.__get__ 的核心逻辑(简化):
class ForwardManyToOneDescriptor:
def __get__(self, instance, cls=None):
if instance is None:
return self
# 关键:先看缓存里有没有
rel_obj = self.field.get_cached_value(instance)
if rel_obj is None:
# 缓存没有 → 发起一次独立查询
rel_obj = self.get_object(instance)
# 查完塞进 instance._state.fields_cache
self.field.set_cached_value(instance, rel_obj)
return rel_objget_cached_value 读取的是 instance._state.fields_cache 这个字典。只要这个缓存没被填充,每次访问都会打一次数据库。而 select_related 做的事,就是在主查询执行完后,把 JOIN 出来的关联对象提前塞进这个 fields_cache,于是后续访问全部命中缓存、零查询。
反向一对多(p.comments)走的是 ReverseManyToOneDescriptor,返回一个 RelatedManager,它本质是一个惰性 QuerySet。p.comments.all()[:3] 每次都会生成新的 SQL——这就是为什么循环里访问反向关系必然 N+1。
2. select_related 的源码路径
QuerySet.select_related() 只是把字段名记到 self.query.select_related 上。真正干活的是 SQL 编译器 django/db/models/sql/compiler.py 的 get_related_selections:它递归遍历 select_related 指定的关联链,为每个关联生成 LEFT OUTER JOIN,并把关联表的列以别名(如 T2.name)附加到 SELECT 列表。
查询执行后,ModelIterable.__iter__(django/db/models/query.py)里有一段 _populate 逻辑,会依据 JOIN 别名把关联列还原成对象,写入 fields_cache。这也是为什么 select_related 只能用于单值关系——一对多 JOIN 会返回多行,无法塞进单个 fields_cache 槽位。
3. prefetch_related 的两阶段机制
prefetch_related 的实现分两阶段,核心在 django/db/models/query.py 的 prefetch_related_objects:
def prefetch_related_objects(model_instances, *related_lookups):
# 阶段一:主查询已完成,拿到一批 instance
# 阶段二:为每个 lookup 构造一个批量查询
for lookup in related_lookups:
# 收集所有 instance 的主键
# 生成 SELECT ... WHERE fk_id IN (1,2,3,...,50)
# 执行一次,拿到全部关联对象
# 在 Python 内存里按 fk_id 分组,回填到每个 instance 的 _prefetched_objects_cache
...以 Product.objects.prefetch_related('comments') 为例,实际 SQL 是:
-- 主查询
SELECT * FROM product WHERE status=1 LIMIT 50;
-- 预取查询(注意是 IN,不是 JOIN)
SELECT * FROM comment WHERE product_id IN (1,2,...,50);拿到全部评论后,Django 在内存里按 product_id 分组,把结果挂到每个 Product 的 _prefetched_objects_cache['comments'] 上。此后 p.comments.all() 直接读缓存,不再打库。
这里埋着三个坑,后面避坑章节展开:① 预取后的缓存是"快照",再 .filter() 会绕过缓存重新查库;② Prefetch 对象可以用自定义 QuerySet 控制预取范围;③ only() 裁剪字段时若漏掉关联外键列,会触发意外查询。
4. 整体执行链路
实战代码
示例一:复现 N+1 并用 select_related 修复
# models.py
from django.db import models
class Shop(models.Model):
name = models.CharField(max_length=100)
class Product(models.Model):
name = models.CharField(max_length=100)
status = models.SmallIntegerField(default=1)
shop = models.ForeignKey(Shop, on_delete=models.CASCADE, related_name='products')
class Comment(models.Model):
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='comments')
content = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)# views.py
from django.db import connection, reset_queries
from django.test.utils import override_settings
def bad_view():
"""N+1 版本:50 条商品 → 51 次查询"""
reset_queries()
products = Product.objects.filter(status=1)[:50]
result = []
for p in products:
# 每次访问 p.shop 都触发一次独立 SQL
result.append({'name': p.name, 'shop': p.shop.name})
print(f"查询次数: {len(connection.queries)}") # 输出 51
return result
def good_view():
"""select_related 版本:1 次查询(LEFT OUTER JOIN)"""
reset_queries()
# select_related('shop') 让主 SQL 直接 JOIN shop 表
products = Product.objects.select_related('shop').filter(status=1)[:50]
result = []
for p in products:
# p.shop 命中 fields_cache,零额外查询
result.append({'name': p.name, 'shop': p.shop.name})
print(f"查询次数: {len(connection.queries)}") # 输出 1
return result要点:select_related 生成的 SQL 类似 SELECT product.*, shop.* FROM product LEFT OUTER JOIN shop ON ... LIMIT 50。LIMIT 与 JOIN 同时存在时有语义陷阱——如果 JOIN 的是一对多,LIMIT 会按"行"截断导致数据错误,这正是它不支持一对多的根本原因。
示例二:prefetch_related + Prefetch 对象做精细控制
from django.db.models import Prefetch
def product_list_view():
"""
需求:50 条商品,每条带店铺名 + 最近 3 条评论。
反向一对多用 prefetch_related。
"""
# 用 Prefetch 对象把"最近3条评论"的筛选逻辑下推到预取 SQL 里,
# 避免先 prefetch 全部评论再在 Python 里切片(那样会把整表评论拉进内存)
recent_comments = Prefetch(
'comments',
queryset=Comment.objects.order_by('-created_at')[:3], # 注意:切片在预取查询里生效
to_attr='recent_comments', # 结果挂到独立属性,不污染默认 manager 缓存
)
products = (
Product.objects
.select_related('shop') # 正向 FK 用 JOIN
.prefetch_related(recent_comments) # 反向一对多用批量预取
.filter(status=1)[:50]
)
result = []
for p in products:
result.append({
'name': p.name,
'shop': p.shop.name, # 命中 fields_cache
'comments': [c.content for c in p.recent_comments], # 命中 to_attr
})
# 总查询数:主查询 1 + 预取评论 1 = 2
return resultto_attr 的价值:默认 prefetch 结果写入 _prefetched_objects_cache,一旦之后对该 manager 调用 .filter(),Django 会判断"缓存不能复用于带条件的查询",于是重新打库。使用 to_attr 把预取结果固化成普通列表属性,语义更清晰、也更不容易被误用。
示例三:用 django-debug-toolbar 之外的轻量手段做查询断言
from contextlib import contextmanager
from django.db import connection
from django.test import TestCase
@contextmanager
def assert_num_queries(expected):
"""在测试或调试中锁死查询次数,防止 N+1 回归"""
with connection.execute_wrapper(lambda execute, sql, params, many, ctx: execute(sql, params, many, ctx)):
from django.test.utils import CaptureQueriesContext
with CaptureQueriesContext(connection) as ctx:
yield ctx
actual = len(ctx.captured_queries)
assert actual == expected, f"期望 {expected} 次查询,实际 {actual} 次"
class ProductQueryTest(TestCase):
def test_product_list_no_n_plus_one(self):
# 造数据:3 个店铺、50 个商品、每商品 5 条评论
...
with assert_num_queries(2): # 主查询 + 评论预取,正好 2 次
products = (
Product.objects
.select_related('shop')
.prefetch_related(Prefetch('comments', queryset=Comment.objects.order_by('-created_at')[:3], to_attr='recent'))
.filter(status=1)[:50]
)
list(products) # 强制求值把这个断言写进 CI,任何一次"顺手在模板里加了个 p.shop.name"导致的 N+1 回归都会立刻红灯。比起靠人肉 review,这是最可靠的防线。
方案对比
| 方案 | 查询次数 | 适用关系 | 内存/带宽 | 典型坑 |
|---|---|---|---|---|
| 懒加载(默认) | 1+N | 任意 | 低 | N+1,循环里必爆 |
select_related |
1 | FK / OneToOne | 行变宽,JOIN 膨胀 | 不能用于一对多;配合 only 易漏列 |
prefetch_related |
2 | 反向 FK / M2M | 批量 IN,可控 |
缓存快照;对 manager .filter() 失效 |
Prefetch(to_attr=...) |
2 | 同上 | 可控 | 属性名冲突;切片在部分 DB 有兼容问题 |
手写 .values() / 原生 SQL |
1 | 报表类 | 最省 | 丢失模型方法与信号 |
django-zen-queries 库 |
强制断言 | 任意 | — | 需要额外依赖 |
横向对比其他 ORM 生态:SQLAlchemy 用 joinedload(等价 select_related)和 selectinload(等价 prefetch,内部正是 WHERE id IN (...),名字就来自 select-in);GORM(Go)的 Preload 对应 prefetch,Joins 对应 select_related。概念是相通的——"JOIN 拼宽一次拿全" vs "两次批量 IN 再内存拼装",是所有 ORM 面对 N+1 的两条主线。
一个常被忽略的替代思路:如果关联数据是字典/枚举这类小表(如店铺类型、地区码),更好的做法是应用层缓存(Redis / 进程内 lru_cache),连预取查询都省掉。判断标准是"关联表数据量小且变更频率低"。
最佳实践与避坑指南
only()/defer()与select_related的组合是重灾区。 如果你写Product.objects.only('name').select_related('shop'),Django 会为 JOIN 自动补上外键列;但如果手动defer('shop_id'),访问p.shop时会再打一次库。裁剪字段时务必确认外键列在 SELECT 列表里。
- prefetch 的缓存是快照,别再
.filter()。p.comments.all()命中缓存,但p.comments.filter(created_at__gt=...)会绕过缓存重新查库。需要带条件的预取,用Prefetch(queryset=...filter(...))把条件下推。
Prefetch里不要用无界切片。 部分数据库对LIMIT子查询兼容性差,且大数据量下切片预取仍可能拉回大量行。更稳的写法是"预取全量 + Python 端islice",或用窗口函数(Django 4.2+ 的Window函数配合Prefetch)。
select_related不要无脑链式套娃。select_related('a__b__c__d')会生成多级 JOIN,查询计划可能退化。一般控制在 2~3 层,且用EXPLAIN验证。
- M2M 的预取注意中间表。
prefetch_related('tags')会执行两条查询(主表 + 中间表 JOIN 标签表),而不是你想象中的一条。这是预期行为,不要误判为 N+1。
- 在序列化层统一治理。 DRF 的
SerializerMethodField里访问关联属性是 N+1 的高发区。约定:ViewSet 的get_queryset()必须显式声明select_related/prefetch_related,并由测试断言查询次数兜底。
- 监控层面看"查询数/请求"这个指标,而不是只看单条 SQL 耗时。用
django-prometheus或自研中间件统计每请求 SQL 条数,超过阈值告警——这才是 N+1 的唯一有效探针。
总结
N+1 的本质是懒加载在循环上下文中的放大效应:单条 SQL 都不慢,但往返次数把延迟乘了 N 倍。Django 给了两把对症的刀——select_related 用数据库 JOIN 一次拼宽,适合单值关系;prefetch_related 用批量 IN 两次往返、内存拼装,适合一对多/多对多。进阶上用 Prefetch(to_attr=...) 把筛选逻辑下推到预取查询,既省内存又语义清晰。
真正让优化"不反弹"的,是把查询次数断言写进测试和 CI,而不是依赖人肉 review。源码层面记住一点:所有优化的落点都是 fields_cache 和 _prefetched_objects_cache 这两个缓存字典——把数据提前、批量地塞进去,循环里就再也不会打库。
延伸思考:当关联数据跨服务(微服务架构下的"远程 N+1")、或需要聚合统计时,ORM 预取就力不从心了。此时要么引入 DataLoader 模式(GraphQL 生态的批量 + 请求级缓存),要么把读模型预计算成宽表。N+1 的思想会以"远程调用放大"的形式在分布式系统里反复出现,理解了它的本质,换到任何技术栈都能一眼识别。