Django ORM prefetch_related关联集调用first()未走预取缓存触发N+1查询问题咨询
first()不会命中预取缓存? Django的prefetch_related本质是在主表查询完成后,单独执行一次关联表查询,将匹配的所有关联对象按外键关系分组后,挂载到每个主表实例的_prefetched_objects_cache缓存字典中。只有当访问的关联查询集与预取时的查询签名完全一致、且复用原始查询集实例时,才会命中这个内存缓存,不会触发新的数据库查询。
first()方法的实现逻辑存在两处直接导致缓存失效的设计:
- 该方法会基于原始关联查询集生成全新的查询集实例,而非在已经填充了缓存结果的原始查询集上操作。这个新实例不会自动继承原实例已经加载好的内存结果缓存。
- 当原始关联查询集没有显式排序时,
first()会主动追加order_by('pk')的查询条件;同时无论是否有排序,都会为查询集追加LIMIT 1的截断限制。这些改动会让新生成的查询集的签名与预取时拉取全量关联集的查询参数不匹配,被Django判定为未被预取覆盖的全新查询,最终触发独立的数据库查询。
这就是n+2次查询问题的核心原因:主表1次查询、关联集预取1次查询,随后每个X实例上的x.y_set.first()都会因为缓存不命中,单独触发1次查询,累计下来查询次数就是2 + n。
根据实际业务场景,可以直接选择对应的原生方案,不需要自行封装工具方法:
场景1:已经预取了全量关联集,需要快速获取每个关联集的首元素
如果你保留了原有的prefetch_related("y_set")逻辑,且需要在遍历全量关联集的同时取首元素,不需要触发新的数据库查询,可以将原来的first()调用替换为:
qs = X.objects.all().prefetch_related("y_set") for x in qs: for y in x.y_set.all(): print(y) # 原代码此处的print(e)属于笔误 first = next(iter(x.y_set.all()), None)
这个写法的行为和first()完全对齐:
- 关联集非空时返回第一个匹配对象
- 关联集为空时返回
None
由于x.y_set.all()是已经被预取缓存填充的原始查询集,iter()会直接基于内存中的对象列表创建迭代器,全程不会触发新的数据库查询。相比之前手写的“手动判断查询集非空再取索引0”的冗余逻辑,这个写法不需要重复调用查询集,也不需要自行处理空值判断,更不需要访问Django的内部私有缓存属性。
场景2:不需要遍历全量关联集,仅需要每个X对应的关联首元素
如果实际业务中根本不需要加载完整的y_set列表,仅需要获取每个X关联的第一个Y对象,完全可以去掉冗余的全量预取,通过子查询注解在主查询阶段就把首对象加载出来,从根源上避免N+1问题:
from django.db.models import OuterRef, Subquery # 定义关联Y的排序规则,与业务中预期的首元素排序逻辑保持一致 first_y_subquery = Y.objects.filter(x_id=OuterRef("pk")).order_by("pk") # 主查询时直接将首对象的ID作为注解字段加载 qs = X.objects.annotate( first_y_id=Subquery(first_y_subquery.values("id")[:1]) )
如果业务既需要遍历全量y_set,又需要快速取首元素,只需要保留原有的prefetch_related("y_set"),配合上面的子查询注解即可:整个流程仅需要2次数据库查询(主表查询+全量关联预取),访问x.first_y_id时不会触发任何额外查询,性能远高于从缓存手动取值的方案。
内容的提问来源于stack exchange,提问作者Rafał Kowalski

