数据库 N+1 查询完全指南:从一次"列表页慢到几秒、数据库 QPS 莫名暴涨"看懂 ORM 的隐藏查询风暴

2022 年我做一个后台管理系统有个订单列表页把订单查出来每一行显示订单号金额还有下单人的名字用 ORM 取关联数据这件事我压根没多想第一版我做得很省事ORM 用着真方便我要下单人的名字不就是点一下 order.user.name 循环里一行行拼每行点一下把名字取出来就完事了本地开发时真不错我库里就造了几十条订单列表页刷一下就出来几行代码搞定我心里很踏实可等这个系统真正上线订单涨到几万几十万条一串问题冒了出来第一种最先把我打懵订单列表页慢到要转好几秒才出来本地几十条飞快线上几千条卡得没法看第二种最难缠DBA 找上门说数据库 QPS 莫名其妙暴涨一查就是这个列表页打开一次打出去几百上千条 SQL 第三种最头疼我给查询加了缓存可还是慢缓存挡住了重复的查询但每一行订单的下单人都不一样该查的还是一条条在查第四种最莫名其妙我在本地怎么测都测不出问题本地就那么十几条数据慢一点点根本感觉不到我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为 order.user.name 就是读一个对象身上的字段这句话把访问一个关联属性当成了读一块内存可它不是ORM 为了不一次性把整张关系网都拖下来用的是惰性加载order 这个对象一开始只装了订单表自己那几列它的 user 是空的直到我第一次去访问 order.user ORM 才在那一刻偷偷地替我向数据库打了一条 SQL 把那个 user 查回来这意味着 order.user.name 这行代码长得像一次内存访问实质是一次数据库往返而当我把这行代码放进一个循环列表页有 N 条订单我就在循环里访问了 N 次也就悄悄打了 N 条 SQL 加上最开始查订单列表的那一条一共 N+1 条这就是 N+1 查询这个名字的来历真正用对 ORM 核心不是要什么关联数据点一下属性就有了而是把访问关联属性当作它背后可能藏着一条 SQL 来对待循环之前就想清楚这一批数据要用到哪些关联用预加载一次性把它们查回来绝不把惰性查询留在循环里逐条触发本文从头梳理为什么点一下属性就有了是错的怎么把藏起来的 N+1 揪出来怎么用预加载消灭它怎么手动做批量查询以及序列化嵌套查询数监控这些把 ORM 用扎实要避开的坑

2022 年我做一个后台管理系统,有个订单列表页:把订单查出来,每一行显示订单号、金额,还有下单人的名字。用 ORM 取关联数据这件事,我压根没多想。第一版我做得很省事:ORM 用着真方便,我要下单人的名字,不就是点一下 order.user.name?循环里一行行拼,每行点一下 order.user.name 把名字取出来,就完事了。本地开发时——真不错:我库里就造了几十条订单,列表页刷一下就出来,几行代码搞定。我心里很踏实:"ORM 嘛,属性点一下数据就有了?"可等这个系统真正上线、订单涨到几万几十万条,一串问题冒了出来。第一种最先把我打懵:订单列表页慢到要转好几秒才出来,本地几十条飞快,线上几千条卡得没法看。第二种最难缠:DBA 找上门,说数据库 QPS莫名其妙暴涨——一查,就是这个列表页,打开一次打出去几百上千条 SQL。第三种最头疼:我给查询加了缓存,可还是慢——缓存挡住了重复的查询,但每一行订单的下单人都不一样,该查的还是一条条在查。第四种最莫名其妙:我在本地怎么测都测不出问题——本地就那么十几条数据,慢一点点根本感觉不到。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"order.user.name 就是读一个对象身上的字段"。这句话把"访问一个关联属性"当成了"读一块内存"。可它不是我脑子里,order 这个对象,就像一个我从数据库里"整个捞上来的、自带全部信息的东西"——它的金额、它的下单人、下单人的名字,全都已经在这个对象身上了,我点 .user.name 不过是顺着它摸到那块内存。可 ORM 的对象根本不是这种东西。ORM 为了不一次性把整张关系网都拖下来,用的是"惰性加载":order 这个对象,一开始只装了订单表自己那几列,它的 user 是空的;直到我第一次去访问 order.user,ORM 才在那一刻,偷偷地、悄无声息地,替我向数据库打了一条 SQL,把那个 user 查回来。这意味着,order.user.name 这行代码,长得像一次内存访问,实质是一次数据库往返。而当我把这行代码放进一个循环里——列表页有 N 条订单,我就在循环里访问了 N 次 order.user,也就悄悄打了 N 条 SQL。加上最开始查订单列表的那 1 条,一共 N+1 条。这就是"N+1 查询"这个名字的来历。我第一版所有的麻烦,根上都是同一件事:我把 N 次悄无声息的数据库往返,看成了 N 次免费的内存访问。真正用对 ORM,核心不是"要什么关联数据,点一下属性就有了",而是把"访问关联属性"当作"它背后可能藏着一条 SQL"来对待:循环之前就想清楚这一批数据要用到哪些关联,用预加载一次性把它们查回来,绝不把惰性查询留在循环里逐条触发。这篇文章就把 N+1 查询梳理一遍:为什么"点一下属性就有了"是错的、怎么把藏起来的 N+1 揪出来、怎么用预加载消灭它、怎么手动做批量查询,以及序列化嵌套、查询数监控这些把 ORM 用扎实要避开的坑。

问题背景

先把那串问题的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。

现象:一个"循环里点一下属性取关联数据"的订单列表页,在订单涨到几万条后冒出一串问题:列表页慢到要转好几秒;数据库 QPS暴涨,一次打开打出几百上千条 SQL;加了缓存还是慢,因为每行的关联数据都不一样;而这一切在本地十几条数据时根本测不出来

我当时的错误认知:"order.user.name 就是读一个对象身上已经有的字段,跟读内存一样,不花钱。"

真相:这个认知错在它把 ORM 的对象想象成了一个"自带全部信息的完整快照"。在我脑子里,从数据库捞出一个 order,它的下单人、下单人的名字就都跟着来了,点 .user.name 只是顺着内存摸过去。可 ORM 的对象完全不是这种东西。ORM 为了不把整张关系网都拖下来,用的是惰性加载:order 一开始只装了订单表自己的几列,它的 user 是空的;直到你第一次访问 order.user,ORM 才在那一刻偷偷打一条 SQL 把它查回来。所以 order.user.name 长得像内存访问,实质是一次数据库往返。开头那四个问题,根上全是"把 N 次数据库往返当成了 N 次免费的内存访问":列表页慢,是因为一次请求里串行打了 N+1 条 SQL,每条都有网络往返;QPS 暴涨,是因为每个用户打开一次列表页,就替数据库制造了几百条查询;加缓存也没用,是因为缓存挡的是"重复的查询",而 N 条关联查询彼此各不相同;本地测不出,是因为N 很小的时候,N+1 和 1 几乎没差别。问题的根子清楚了:这不是"数据库太慢"的小毛病,而是要换一个根本的认知——访问一个关联属性,背后可能藏着一条 SQL,用对 ORM,就是绝不在循环里逐条触发这种隐藏的查询。

要把 N+1 查询处理对,需要几块认知:

  • 为什么"点一下属性就有了"是错的——属性访问背后是惰性查询;
  • 怎么把 N+1 揪出来——它是隐藏的,要靠数 SQL 条数才能现形;
  • 用预加载消灭它——select_relatedprefetch_related;
  • 关联模型不在手边时,怎么手动做批量查询;
  • 序列化、嵌套这些场景里,N+1 会藏在哪;
  • 查询数监控、测试断言这些工程坑怎么处理。

一、为什么"循环里点一下属性"是错的

先把这件最根本的事钉死:"循环里点一下属性取关联数据"错在它脑子里有一幅错误的图景——它把一个 ORM 对象,想象成一份"从数据库整个捞上来的、信息完备的快照"。在这幅图景里,你查出一个 order,就等于把这张订单的一切都搬到了内存里:它的金额在内存里,它的下单人在内存里,下单人的名字也在内存里;你写 order.user.name,只是从一块内存,顺着指针走到另一块内存。这幅图景之所以危险,是因为它假设了一件 ORM 根本做不到、也根本不想做的事——把一个对象关联到的所有数据,都预先、一次性地装进内存。想想看,如果 ORM 真这么做:你查一个用户,它要把这个用户的所有订单都拖出来;每个订单又要把所有商品项拖出来;每个商品项又要把商品详情拖出来……顺着外键,它几乎要把整个数据库都搬进你的内存。这显然不可行。所以 ORM 选择了另一条路,叫"惰性加载":一个 order 对象被创建出来时,它身上只有订单表自己的那几列,user 这个属性是一个"还没兑现的承诺"——它现在是空的。ORM 跟你的约定是:你什么时候真的用到了 order.user,我什么时候才去把它查回来。这个约定听起来很合理、很省事,问题就出在"查回来"这三个字上——它不是从内存里取,而是向数据库打一条 SQL。也就是说,order.user.name 这一行,在你眼里是一次属性访问,在 ORM 眼里是一条 SQL 语句。这两件事的代价相差了好几个数量级:读内存是纳秒级的,打一条 SQL 要经过网络、要数据库解析执行、再把结果传回来,是毫秒级的。平时单独写一次 order.user,你根本感觉不到这点延迟,所以这个错误图景一直没被戳破。可一旦你把这行代码放进一个循环——列表页有 N 条订单,循环体里访问一次 order.user,整个循环就打了 N 条 SQL;加上循环开始前查订单列表的那 1 条,一共 N+1 条。这就是"N+1 查询"。所以正确的图景是:ORM 对象不是信息完备的快照,而是一个只装了自己那几列、关联数据靠"用到才查"来兑现的东西;访问一个关联属性,极可能在背后触发一条你看不见的 SQL。把"对象是个完整快照、属性访问不花钱"换成"对象是个半成品、属性访问背后可能藏着一条 SQL",你才算站到了用对 ORM 的起点上。

下面这段代码,就是我那个"本地几十条飞快、上线几千条卡死"的第一版:

# 反面教材:在循环里访问关联属性,每访问一次,就偷偷打一条 SQL
def order_list_rows():
    orders = Order.objects.all()              # 1 条 SQL:把订单列表查出来
    rows = []
    for order in orders:
        rows.append({
            "id": order.id,
            "amount": order.amount,
            "buyer": order.user.name,         # 破绽:order.user 在这里又偷偷打了一条 SQL
        })
    return rows                               # N 条订单,就多打了 N 条 SQL,共 N+1 条

这段代码在本地开发时表现不错,因为本地我库里的数据,其实是"又少又安静"的——是我自己随手造的几十条订单order_list_rows() 跑起来确实打了 N+1 条 SQL,可 N 只有几十,几十条又快又小的查询,在本地数据库上一眨眼就跑完了。我亲手布置了一个又小又空的舞台,这个舞台小到让 N+1 这个问题的代价,小到我根本察觉不到。代码恰好一路平安,你看不出任何破绽。它的问题不在某一行语法上——Order.objects.all()for 循环、order.user.name,语法都对——而在它对"访问一个关联属性要付出多大代价",做了一个想当然的假设:它假设 order.user 是免费的内存访问。它从没意识到这行代码背后藏着一条 SQL,更没意识到把它放进循环,SQL 的条数就会跟着数据量一起涨。本地数据,这个假设的代价小到看不见;一上线、订单涨到几万条,N 变成了几千,N+1 条 SQL 一条条串行地打,假设就被击穿,列表页慢得没法看。问题的根子清楚了:用对 ORM,第一步不是加缓存、不是换数据库,而是承认"循环里那个属性访问背后藏着一条 SQL",然后把这条隐藏的查询从循环里揪出来。下面五节,就是这件事怎么落地。

二、怎么把 N+1 揪出来:数一数 SQL 条数

N+1 最难缠的地方,是它藏得深——代码看上去清清爽爽,功能完全正常,它从不报错,只是。要让它现形,办法只有一个:数一数,一段代码执行期间,到底向数据库打了多少条 SQL。ORM 一般都提供了拿到这个数字的手段:

from django.db import connection
from django.test.utils import CaptureQueriesContext

def count_queries(func, *args, **kwargs):
    """数一数一个函数执行期间,到底打了多少条 SQL —— 让隐藏的 N+1 现形。"""
    with CaptureQueriesContext(connection) as ctx:
        result = func(*args, **kwargs)
    n = len(ctx.captured_queries)              # 这期间累计打出去的 SQL 条数
    print(f"{func.__name__} 执行了 {n} 条 SQL")
    return result, n

把上一节那个反面教材套进去数一下,N+1 就无所遁形了——库里有 100 条订单,它就打出 101 条 SQL:

def diagnose():
    """同样的功能,数一下 SQL 条数,差距就暴露了。"""
    _, n_bad = count_queries(order_list_rows)        # 输出:执行了 101 条 SQL —— N+1
    _, n_good = count_queries(order_list_rows_fast)  # 输出:执行了 2 条 SQL —— 已优化
    # 关键不是"慢不慢",而是"SQL 条数会不会随数据量一起涨"
    return n_bad, n_good

这里的认知要点是:这一节要建立的观念是——N+1 是一个"沉默"的问题,你必须主动地、用数字去把它逼出来,不能等它自己冒头。先说它为什么沉默。一段有 N+1 的代码,它在功能上是完全正确的:订单列表照样能显示,下单人的名字一个不差。它也从不抛异常,从头到尾安安静静。它唯一的症状是"慢",而"慢"是一种极具欺骗性的症状——慢得多了你才会警觉,慢一点点你根本无感。偏偏 N+1 在开发阶段就是"慢一点点":开发库里数据少,N 是个很小的数,N+1 条小查询和 1 条查询在体感上几乎没有差别。于是 N+1 能一路平安地躲过你的开发、自测,直到生产环境数据量起来了才爆发。要对付一个这样沉默的问题,靠"跑一下感觉快不快"是完全不行的,你需要一个不依赖体感的、客观的标尺——那就是 SQL 的条数。count_queries 做的就是这件事:它利用 ORM 提供的查询捕获能力,把一个函数执行期间打出去的每一条 SQL 都记下来,最后给你一个数字。有了这个数字,N+1 就从一个模糊的"好像有点慢",变成了一个精确的"这个函数打了 101 条 SQL"。而真正的判断标准,还要再往前一步:不是看"打了多少条",而是看"条数会不会随数据量增长"。一个正确的列表查询,无论库里是 10 条还是 10 万条订单,它打出的 SQL 条数应该是一个固定的常数(比如 2 条);一个有 N+1 的查询,它的 SQL 条数会死死地跟着订单数一起涨。所以诊断 N+1 的标准动作是:用不同规模的数据各跑一次,看条数变不变。条数纹丝不动,就是好的;条数跟着数据量水涨船高,就是 N+1。这背后是一个通用的工程观念:对于性能问题,不要依赖主观体感,要找到一个能客观度量的指标,并且要关注这个指标"随规模如何变化",而不只是它此刻的绝对值。一句话:N+1 是个沉默的、只在数据量大时才疼的问题,要靠'数 SQL 条数、看它随数据量怎么变'来主动揪出它。揪出来了,下一步就是真正消灭它。

三、用预加载消灭 N+1:select_related 与 prefetch_related

消灭 N+1 的主力武器预加载:在循环开始之前,就明明白白告诉 ORM "这批数据我等会儿要用到 user",让它一次性、合并地把关联查回来。对多对一/一对一的外键,用 select_related,它走的是 JOIN:

def order_list_rows_fast():
    # select_related:用一条带 JOIN 的 SQL,把订单和它的 user 一次性查回来
    orders = Order.objects.select_related("user").all()   # 1 条 SQL(带 JOIN)
    rows = []
    for order in orders:
        rows.append({
            "id": order.id,
            "amount": order.amount,
            "buyer": order.user.name,         # 不再打 SQL:user 已经跟着 JOIN 一起来了
        })
    return rows                               # 无论多少订单,总共就 1 条 SQL

select_related 只能用在外键这种"多对一"上。如果是"一个订单有多个商品项"这种一对多,JOIN 会让主表行数翻倍,得换 prefetch_related——它分两条查、在内存里拼:

def order_list_with_items():
    # 一个订单有多个商品项(反向一对多):select_related 用不了,改用 prefetch_related
    orders = Order.objects.prefetch_related("items").all()  # 2 条 SQL:订单 + 一次性捞回全部 items
    rows = []
    for order in orders:
        rows.append({
            "id": order.id,
            "item_count": len(order.items.all()),  # 用 len 不用 count:count 会另打一条 SQL
        })
    return rows

下面这张图,把循环里要用关联数据时,该选哪种预加载画出来:

这里的认知要点是:这一节要想清楚的是预加载为什么能消灭 N+1,以及为什么它分成两种。先说为什么能消灭。N+1 的病根,在于"惰性"——关联数据是用到的那一刻、一条一条地、被动地查回来的。预加载做的事,就是把"惰性"改成"主动":你在写查询的时候,就用 select_related 或 prefetch_related 显式声明"这批数据我接下来要用到 user / items",ORM 收到这个声明,就会在你执行查询时,把这些关联数据一并、合并地查好,挂到每个对象身上。这样等你在循环里访问 order.user 时,数据已经在内存里现成地待着了,不需要再打 SQL。一句话:预加载把 N 条分散的、被动的查询,合并成了 1 到 2 条集中的、主动的查询。再说为什么是两种,而且不能用混。select_related 用的是 SQL 的 JOIN:它把订单表和用户表在一条 SQL 里连起来查。JOIN 适合"多对一"——很多订单对应一个用户,连出来的结果行数还是订单的行数,很干净。但如果你拿 JOIN 去查"一对多"——一个订单有多个商品项——结果集的行数会膨胀:一个有 5 个商品项的订单,会在结果里变成 5 行重复的订单数据,数据量和处理成本都失控。所以一对多、多对多要用 prefetch_related,它的策略完全不同:它不 JOIN,而是先查订单(第 1 条 SQL),拿到所有订单的 id,再用一条带 IN 的 SQL 把这些订单的全部商品项一次性查回来(第 2 条 SQL),最后由 ORM 在内存里,把商品项按订单 id 分好、挂回各自的订单上。两条 SQL,条数依然是常数,和订单多少无关。这里还藏着一个容易踩的小坑:prefetch_related 之后,要用 len(order.items.all()) 而不是 order.items.count() 去数数量——前者用的是已经预加载进内存的那批数据,后者会绕过预加载、另打一条 COUNT 查询,等于又把 N+1 引回来了。一句话:预加载把被动的惰性查询改成主动的合并查询,多对一用 select_related 走 JOIN,一对多多对多用 prefetch_related 分两条查。预加载是 ORM 给的"官方解法",可有时候关联模型并不在 ORM 的关系定义里——这就要自己动手做批量查询。

四、关联不在手边:手动做批量查询

不是所有关联都能靠 select_related 解决。有时数据来自另一个服务、另一个库,ORM 关系定义里根本没有它。这时要自己动手,而手动批量查询的核心套路就一句话:先把循环里要用到的 id 全收集起来,用一条 IN 查询一次查回,在内存里建好"id 到对象"的字典,再让循环去字典里取:

def order_list_manual_batch():
    """手动批量查询:收集 id -> 一条 IN 查回 -> 建字典 -> 循环里查字典。"""
    orders = list(Order.objects.all())                  # 1 条 SQL
    user_ids = {o.user_id for o in orders}              # 收集所有要用到的 user_id,去重
    users = User.objects.in_bulk(user_ids)              # 1 条 SQL:一次 IN 查询全部捞回 {id: user}
    rows = []
    for order in orders:
        user = users.get(order.user_id)                 # 直接从字典里取,循环里不再碰数据库
        rows.append({"id": order.id, "buyer": user.name if user else "未知"})
    return rows

这个套路反复用,就值得抽成一个通用函数——给它一组对象和外键字段名,它负责一条 IN 查询把关联全解析好:

def batch_load(objects, fk_field, related_model):
    """通用批量加载:把一组对象的某个外键,用一条 IN 查询全部解析成对象。"""
    ids = {getattr(o, fk_field) for o in objects}
    ids.discard(None)                                   # 外键可能为空,空值不参与查询
    related = related_model.objects.in_bulk(ids)        # 一条 IN 查询,得到 {id: 对象}
    # 返回 {原对象主键: 关联对象},循环里直接按主键取,零额外 SQL
    return {o.pk: related.get(getattr(o, fk_field)) for o in objects}

这里的认知要点是:这一节要掌握的,是 N+1 优化背后那个最朴素、也最通用的思想——把"N 次单点查询"换成"1 次批量查询",而 select_related、prefetch_related 不过是这个思想的两种现成封装。当现成封装用不上时,你要能自己把这个思想实现出来。先看清问题的形状:循环里的 N+1,本质是你在循环的每一圈,都拿着一个 id,去问数据库"这个 id 对应的东西是什么"。N 圈,就是 N 次这样的单点提问。这种"一次问一个"的模式,代价高在哪?不在数据库查一条数据本身——按主键查一条数据是极快的——而在"往返"。每一次提问,都要建立请求、走一遍网络、等数据库响应、再把结果传回来,这个往返的固定开销,乘以 N,就成了压垮列表页的那座山。批量查询掐死的就是这座山:既然你最终要问的是 N 个 id,那就别问 N 次,把这 N 个 id 攒成一个集合,用一条带 IN 的 SQL 一次性全问出来——一次往返,解决全部。查回来的结果,你再用一个字典组织好,键是 id,值是对象;接下来循环里要用哪个,就拿 id 去字典里取,这是纯内存操作,零成本。order_list_manual_batch 就是这个套路的完整样子:收集 id、去重、一条 in_bulk 查回、循环里查字典。而 batch_load 把这个套路抽象成了一个可复用的函数——这值得做,因为"收集 id、批量查、建字典"这三步,在任何需要手动解决 N+1 的地方都长得一模一样,抽出来一次,以后就不必每处都重写。这里有两个细节别漏。一是收集 id 时要去重(用集合而不是列表),很多订单可能是同一个用户下的,重复的 id 没必要查两遍。二是要处理外键为空的情况,空值不该参与 IN 查询,从字典里取不到时也要有兜底。理解了"N 次单点查询换成 1 次批量查询"这个内核,你就会发现它远不止用于 ORM:它和"把 N 个小文件读取合并成一次批量读""把 N 个网络请求合并成一个批量接口"是同一件事。一句话:N+1 优化的内核是'用一次 IN 批量查询替代 N 次单点查询',预加载是它的封装,封装够不着时就照着收集 id、批量查、建字典这个套路自己做。主干的解法齐了,可 N+1 还特别擅长藏在一个地方——序列化。

五、N+1 最爱藏的地方:序列化与嵌套

你可能把列表查询那段优化得好好的,N+1 却从另一个地方钻了出来——序列化。把对象转成 JSON 返回时,序列化器会逐个对象、逐个字段地访问,只要某个字段访问了没预加载的关联,N+1 就藏在了这一层:

# 反面教材:列表查询没问题,但 N+1 藏进了序列化器
class OrderSerializer:
    def serialize(self, order):
        return {
            "id": order.id,
            "buyer": order.user.name,         # 序列化器逐个访问 user,N+1 藏在这里
        }

def serialize_orders(order_queryset):
    s = OrderSerializer()
    return [s.serialize(o) for o in order_queryset]   # queryset 没预加载 user,逐个就是 N+1

修正它的关键,是认清一件事:预加载该发生在"数据进入序列化器之前"。序列化器只管逐个访问,数据齐不齐,是喂给它的那个 queryset 的责任:

def serialize_orders_safe(order_queryset):
    """正确做法:数据进序列化器之前,先把它要用到的关联全部预加载好。"""
    orders = order_queryset.select_related("user").prefetch_related("items")
    s = OrderSerializer()
    # 序列化器照常逐个访问,但关联早已就位,全程零额外 SQL
    return [s.serialize(o) for o in orders]

这里的认知要点是:这一节要扭过来的观念是——N+1 不是"列表查询"独有的毛病,它会出现在任何"拿到一批对象、然后逐个去访问它们关联属性"的地方,而序列化就是这种地方里最隐蔽、最高发的一个。先说为什么序列化是重灾区。一个 Web 接口返回数据,几乎总要经过序列化这一步:把一批 ORM 对象,转成能发给前端的 JSON。序列化器的工作方式,天然就是"遍历每个对象、读取它的每个字段"——这恰恰就是 N+1 需要的土壤。只要序列化器要输出的字段里,有一个是关联属性(比如 order.user.name),而传进来的对象又没预加载这个关联,那么序列化器每处理一个对象,就触发一次查询,N+1 就成立了。它隐蔽,是因为序列化器的代码通常和查询的代码离得很远——你在一个地方写查询,在另一个地方写序列化器,你优化查询时,眼睛根本看不到序列化器里那个 order.user.name。再说修正它的关键认知:责任的划分。serialize_orders_safe 的修法,看起来只是加了一行 select_related,但它背后是一个重要的观念——预加载是"数据准备"阶段的责任,不是"数据消费"阶段的责任。序列化器是数据的消费方,它的职责很单纯:你给它什么对象,它就逐个访问、转成 JSON。它没有义务、也不应该去关心这些对象的关联数据查回来了没有。真正该对"数据齐不齐"负责的,是上游那个构造 queryset 的地方——是它决定了要查什么、要预加载什么。所以修 N+1,不要去序列化器里改,而要回到构造 queryset 的源头,在那里把 select_related、prefetch_related 加足。这给了我们一条通用的排查线索:当你发现 N+1,不要只盯着写循环的那段代码,要顺着"这批对象从哪来、中途经过了谁、最后被谁逐个访问了",把整条链路都看一遍——N+1 一定发生在链路上"逐个访问关联"的那一环,而修复一定要回到链路源头"构造查询"的那一环。一句话:N+1 会藏在任何'逐个对象访问关联'的环节,序列化是最高发的一处,而修复永远要回到构造查询的源头去补预加载。主干都齐了,最后是几个把 N+1 治理真正用到生产里才会撞见的工程坑。

六、工程坑:测试断言、监控、IN 过长与过度预加载

主干之外,还有几个工程坑,不处理就会让你的 N+1 治理在边角上出问题坑 1:N+1 会"复发",要用测试把它焊死。你这次修好了,下个月别人加个字段、改个序列化器,N+1 可能又悄悄回来了。靠人盯不住,要写一个断言查询条数的测试——把"SQL 条数是常数"变成一条会失败的红线:

from django.test import TestCase

class OrderListQueryTest(TestCase):
    def test_no_n_plus_one(self):
        for _ in range(50):
            make_order()                       # 造 50 条订单测试数据
        # 断言:不管多少订单,SQL 条数都该是固定常数,多一条就让测试失败
        with self.assertNumQueries(2):
            order_list_rows_fast()

坑 2:线上要监控"单请求 SQL 条数"。测试只能覆盖你想到的接口,线上要兜底:用中间件统计每个请求打了多少 SQL,超阈值就告警:

from django.db import connection

class QueryCountMiddleware:
    """统计每个请求打了多少 SQL,超阈值告警 —— 把漏网的 N+1 挡在生产里。"""
    THRESHOLD = 30

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        before = len(connection.queries)
        response = self.get_response(request)
        n = len(connection.queries) - before
        if n > self.THRESHOLD:                 # 一个请求打了几十条 SQL,大概率是 N+1
            log.warning(f"{request.path} 打了 {n} 条 SQL,疑似 N+1")
        return response

坑 3:connection.queries 默认只在调试模式下记录。上面那个中间件,在生产环境(关闭调试)里 connection.queries空的。生产监控要换用专门的查询统计手段(数据库连接的执行钩子、APM 工具的 SQL 追踪),别直接照搬调试期的写法。坑 4:IN 列表别无限长。批量查询一次塞几万个 id 进 IN,SQL 本身会变得巨大、变慢,甚至超出数据库限制。id 多的时候要分批——每 500 或 1000 个一批,把"一条超大 IN"拆成"几条中等 IN"坑 5:别从一个极端走到另一个极端——过度预加载。看见 N+1 就怕,于是把所有关联全 prefetch 一遍,会把这次根本用不到的数据也全查回来,浪费内存和查询时间。预加载只加你这次真的会访问到的关联坑 6:深层嵌套关联要用"双下划线"一路点进去。order 要用 user,user 又要用 company,预加载要写成 select_related("user__company") 把整条链一次声明,漏掉中间一层,N+1 就从那一层钻出来坑 7:分页之后也别忘了预加载。就算列表加了分页、一页只有 20 条,这 20 条照样会触发 20 次关联查询——N 变小了,N+1 还在。分页不解决 N+1,只是把它变小了坑 8:聚合统计别用循环。"每个订单的商品总数""每个用户的下单数",别循环里逐个 count(),用数据库的分组聚合一条 SQL 算完——这是 N+1 思想在"统计"场景的同一个变体。

关键概念速查

概念 / 手段 说明
惰性加载 关联属性一开始是空的,第一次访问时才偷偷打 SQL 查回
N+1 查询 1 条主查询 + 循环里 N 次关联查询,SQL 条数随数据量涨
点属性就有了的错 把"访问关联属性"当成读内存,实为一次数据库往返
数 SQL 条数 N+1 沉默不报错,靠数条数、看它随数据量怎么变来揪出
select_related 多对一/一对一外键,用一条 JOIN 把关联一起查回
prefetch_related 一对多/多对多,分两条查、ORM 在内存里拼
手动批量查询 收集 id、一条 IN 查回、建字典,循环里查字典
序列化层的 N+1 序列化器逐个访问关联,修复要回到构造查询的源头
assertNumQueries 测试里断言 SQL 条数是常数,把 N+1 复发焊死
分页不解决 N+1 分页只把 N 变小,每页那 N 条照样逐条触发查询

避坑清单

  1. 把"访问关联属性"当作背后藏着一条 SQL,不是免费的内存访问。
  2. 循环里绝不逐条触发惰性查询,关联要在循环前预加载好。
  3. N+1 沉默不报错,靠数 SQL 条数、看它随数据量怎么变来揪出。
  4. 多对一/一对一外键用 select_related,走一条 JOIN 查回。
  5. 一对多/多对多用 prefetch_related,别用 JOIN 撑大结果集。
  6. prefetch 后数数量用 len 不用 count,count 会绕过预加载另打 SQL。
  7. 关联够不着预加载时,自己做"收集 id、IN 批量查、建字典"。
  8. 序列化器里的 N+1,修复要回到构造 queryset 的源头补预加载。
  9. 写 assertNumQueries 测试,把"SQL 条数是常数"变成红线。
  10. IN 列表别无限长,id 多了要分批;预加载只加真会用到的关联。

总结

回头看那串"列表页慢到转几秒、数据库 QPS 暴涨、加缓存也没用、本地却测不出"的问题,以及我后来在 N+1 上接连踩的坑,最该记住的不是 select_related 还是 prefetch_related 的用法,而是我动手前那个想当然的判断——"order.user.name 就是读一个对象身上已经有的字段"。这句话错在它把一个 ORM 对象,当成了一份信息完备的快照。我以为查出一个订单,它的下单人、下单人的名字就都跟着来了,我点 .user.name 只是顺着内存摸过去。可我忽略了一件最要紧的事:ORM 为了不把整张关系网都拖下来,用的是惰性加载——order 对象一开始只装了订单表自己的几列,它的 user 是空的,直到我第一次访问 order.user,ORM 才在那一刻偷偷打一条 SQL 把它查回来。所以 order.user.name 这一行,长得像内存访问,实质是一次数据库往返。而我把这行代码放进了循环——列表页有 N 条订单,我就在循环里悄悄打了 N 条 SQL,加上查列表的那 1 条,共 N+1 条。我第一版的错,就是把这 N 次沉默的数据库往返,看成了 N 次免费的内存访问。这个错配,本地开发时根本看不出来——因为本地库里就那么几十条订单,N 很小,N+1 条小查询一眨眼就跑完,代码恰好一路平安;它只会在真正上线、订单涨到几万条、N 变成几千时,以列表页慢得没法看、数据库 QPS 被一个页面打爆的方式爆出来。

所以用对 ORM,真正的功夫不在"循环里点一下属性取数据"那几行上。点属性本身不难。真正的功夫,在于你要从一开始就承认"访问一个关联属性,背后可能藏着一条你看不见的 SQL",然后绝不把这种隐藏的查询留在循环里逐条触发:你不能假设对象自带全部关联数据,就在循环开始前,用 select_relatedprefetch_related 把要用到的关联预加载好;ORM 的关系够不着时,你就自己收集 id、用一条 IN 查询批量查回、建好字典;你不能让 N+1 在沉默里溜过去,就主动数 SQL 条数、看它随数据量怎么变;你不能让序列化层把优化好的查询又拖回 N+1,就回到构造 queryset 的源头去补预加载;而到了测试断言、线上监控、IN 过长这些边角上,你还要处处守住,别让 N+1 又从某个角落复发。这篇文章的几节,其实就是顺着这套规矩展开的:先想清楚"点一下属性就有了"为什么错,再讲怎么数 SQL 揪出 N+1、用预加载消灭它、手动做批量查询、序列化层的 N+1,最后是测试、监控这几个把 N+1 治理守扎实的工程细节。

你会发现,N+1 查询这件事,和现实里"去仓库给一张订货单备货"完全相通。一个笨办法的备货员会怎么做?他拿着一张列了 100 种货品的订货单,看一行、跑一趟仓库、取一件、回来;再看下一行、再跑一趟、再取一件——一张单子,他在工位和仓库之间来来回回跑了 100 趟。每一趟取的那件货本身没多重,可跑这一个来回的脚程,是实打实的,100 趟脚程加起来,一整天就耗在路上了。而一个聪明的备货员怎么做?他拿到订货单,先不动,把整张单子从头到尾看一遍,把要取的 100 种货品先在纸上汇成一张完整的清单(这就是"循环前先想清楚要用到哪些关联");然后他拿着这张清单,只去仓库一趟,一次性把 100 种货全取齐(这就是 select_relatedprefetch_related 的一次性预加载);要是有些货在另一个分库,他也不会一件件跑,而是把那个库的货也列成一张清单、一趟取回(这就是手动的 IN 批量查询)。同样是给一张订货单备货,笨办法的人脑子里是"看一行取一件",聪明的人脑子里始终是"先汇总、再一趟取齐"——差别不在"取货这个动作本身难不难",只在备货员心里有没有"每跑一趟仓库都有脚程成本、能合并的就绝不分开跑"这根弦

最后想说,ORM 用没用对,N+1 治没治住,差距永远不会在"本地开发、自己造几十条数据测一测"时暴露——本地那几十条数据是你自己随手造的,N 小得可怜,N+1 条查询和 1 条查询快得几乎没差别,你那段"循环里点属性"的代码恰好在一个小到藏住了问题的舞台上一路平安,列表页刷一下就出来,你自然觉得"ORM 嘛,点一下属性数据就有了"一点问题都没有。它只在真实的、数据量起来了、N 大到让每一次隐藏查询的代价都被放大几千倍的环境里才显形。那时候它会用最难堪的方式给你结账:做不好,你会因为一个列表页打出去几百上千条 SQL,让页面慢到转好几秒,会因为一个接口的 N+1,把整个数据库的 QPS 打到告警,会因为加了缓存也挡不住一条条各不相同的关联查询而百思不得其解;而做了,你的每一个列表查询无论库里是一万条还是一百万条,打出去的 SQL 都是固定的两三条,关联数据在循环前就被预加载齐了,序列化层不会再把它拖回 N+1,测试里有一条断言查询条数的红线焊着,无论数据涨到多大,每一个页面都稳稳地、用常数条 SQL 把数据查得干净利落。所以别等"一个列表页把数据库 QPS 打爆"那一刻找上门,在你写下循环里那行 order.user.name 的时候就该想清楚:这行属性访问背后藏着 SQL 吗、这批关联我预加载了吗、SQL 条数会随数据量涨吗、序列化层会不会漏、测试里我焊死它了吗,这一道道关口,我是不是都替这个沉默的 N+1 守住了?这些问题有了答案,你交付的才不只是一套"本地造几十条数据看着快"的代码,而是一个无论数据涨到几万几百万,每一个查询都用常数条 SQL 跑得又快又稳的、让人放心的系统。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

大模型输出截断完全指南:从一次"模型返回的 JSON 莫名其妙少了半截"看懂 max_tokens 与 finish_reason

2026-5-22 16:47:43

技术教程

向量检索踩坑完全指南:从一次"换了个 embedding 模型、整个知识库检索全乱套"看懂向量空间不兼容

2026-5-22 17:04:34

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索