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_obj

get_cached_value 读取的是 instance._state.fields_cache 这个字典。只要这个缓存没被填充,每次访问都会打一次数据库。而 select_related 做的事,就是在主查询执行完后,把 JOIN 出来的关联对象提前塞进这个 fields_cache,于是后续访问全部命中缓存、零查询。

反向一对多(p.comments)走的是 ReverseManyToOneDescriptor,返回一个 RelatedManager,它本质是一个惰性 QuerySet。p.comments.all()[:3] 每次都会生成新的 SQL——这就是为什么循环里访问反向关系必然 N+1。

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 槽位。

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. 整体执行链路

graph TD A[业务代码: Product.objects.filter...] --> B[构造 QuerySet 惰性对象] B --> C{求值触发: 迭代/切片/len} C --> D[compiler 生成主 SQL] D --> E[执行主查询, 得到 50 个 Product 实例] E --> F{是否 select_related?} F -->|是| G[主 SQL 内含 LEFT OUTER JOIN, 关联列随行返回] G --> H[_populate 回填 fields_cache] F -->|否| I{是否 prefetch_related?} I -->|是| J[收集主键, 生成 IN 批量查询] J --> K[执行预取 SQL, 内存分组] K --> L[写入 _prefetched_objects_cache] I -->|否| M[循环访问关联属性] M --> N[每次访问触发独立 SQL → N+1] H --> O[返回结果集] L --> O

实战代码

# 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 会按"行"截断导致数据错误,这正是它不支持一对多的根本原因。

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 result

to_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),连预取查询都省掉。判断标准是"关联表数据量小且变更频率低"。

最佳实践与避坑指南

  1. only() / defer() 与 select_related 的组合是重灾区。 如果你写 Product.objects.only('name').select_related('shop'),Django 会为 JOIN 自动补上外键列;但如果手动 defer('shop_id'),访问 p.shop 时会再打一次库。裁剪字段时务必确认外键列在 SELECT 列表里。
  1. prefetch 的缓存是快照,别再 .filter()。 p.comments.all() 命中缓存,但 p.comments.filter(created_at__gt=...) 会绕过缓存重新查库。需要带条件的预取,用 Prefetch(queryset=...filter(...)) 把条件下推。
  1. Prefetch 里不要用无界切片。 部分数据库对 LIMIT 子查询兼容性差,且大数据量下切片预取仍可能拉回大量行。更稳的写法是"预取全量 + Python 端 islice",或用窗口函数(Django 4.2+ 的 Window 函数配合 Prefetch)。
  1. select_related 不要无脑链式套娃。 select_related('a__b__c__d') 会生成多级 JOIN,查询计划可能退化。一般控制在 2~3 层,且用 EXPLAIN 验证。
  1. M2M 的预取注意中间表。 prefetch_related('tags') 会执行两条查询(主表 + 中间表 JOIN 标签表),而不是你想象中的一条。这是预期行为,不要误判为 N+1。
  1. 在序列化层统一治理。 DRF 的 SerializerMethodField 里访问关联属性是 N+1 的高发区。约定:ViewSet 的 get_queryset() 必须显式声明 select_related/prefetch_related,并由测试断言查询次数兜底。
  1. 监控层面看"查询数/请求"这个指标,而不是只看单条 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 的思想会以"远程调用放大"的形式在分布式系统里反复出现,理解了它的本质,换到任何技术栈都能一眼识别。