Django 系列 · 第二篇

Article.objects.filter(...).exclude(...).order_by(...) 这一整行,数据库到底什么时候被真正查了?

上一篇看到访问一个被 .only() 排除的字段会偷偷多打一次查询——那是"惰性"在单个字段上的体现。这一篇把镜头拉远,看惰性求值在整个 QuerySet 级别怎么工作:链式调用几个过滤条件,到底是在干什么;真正触发查询的是哪一步;同一个查询结果又是怎么被缓存、不会重复查两次的。全部结论都用本机真实 SQL 日志核对过,不是看文档转述。

0 次查询
本机真实实测:三次链式 filter/exclude/order_by,一次数据库都没碰
1 次查询
本机真实实测:list(qs) 触发的,合并了全部三个条件的单条 SQL

真实实测:链式调用不查库,list() 才查

本机真实建表插入 5 条数据,监控 connection.queries 的真实变化。

真实实测
qs = Article.objects.filter(views__gte=10)
print('after .filter():   queries =', len(connection.queries))
qs = qs.exclude(views__gte=40)
print('after .exclude():  queries =', len(connection.queries))
qs = qs.order_by('-views')
print('after .order_by(): queries =', len(connection.queries))

results = list(qs)
print('after list(qs):    queries =', len(connection.queries))
print(connection.queries[-1][\"sql\"])
after .filter(): queries = 0 after .exclude(): queries = 0 after .order_by(): queries = 0 after list(qs): queries = 1 SELECT ... FROM article WHERE (views >= 10 AND NOT (views >= 40)) ORDER BY views DESC

三次链式调用,一次查询都没触发——它们做的事情是"往一个查询描述里追加条件",不是"执行查询"。真正的 SQL 直到 list(qs) 才拼出来,而且是一次性把三个条件合并成一条语句执行,不是查一次、过滤一次、排序一次这样分三步。

真实源码:三个不同的入口,同一个检查点

list()for 循环、bool()len()——这些"看起来在用普通序列"的操作,背后全部走同一个函数。

django/db/models/query.py · django @ 6.0.7, L371 def __len__(self): self._fetch_all() return len(self._result_cache) def __iter__(self): self._fetch_all() return iter(self._result_cache) def __bool__(self): self._fetch_all() return bool(self._result_cache)
django/db/models/query.py · django @ 6.0.7, L1998 def _fetch_all(self): if self._result_cache is None: self._result_cache = list(self._iterable_class(self)) ...

只有一个真正的检查点:_result_cache 是不是 None。第一次访问时是 None,于是真的执行查询、把结果存进去;之后不管再访问多少次,只要 _result_cache 还在,就直接用缓存,不会再碰数据库——这解释了为什么上面那次实验里,链式调用的三步全是"追加条件"("惰性"),而 list() 是"第一次有人真的要结果了"("求值")。

真实实测:同一个 QuerySet 遍历两次,只查一次

真实实测
list(qs)   # 第一次求值
for a in qs:   # 再遍历一次同一个对象
    pass
print('queries after second iteration:', len(connection.queries))
queries after second iteration: 1

还是 1 次——第二次遍历完全没有新查询,直接用的是 _result_cache 里已经存好的结果。QuerySet 表现得像一个"第一次用的时候才去算,算完就记住"的容器。

真实实测 + 真实源码:链式调用不是原地修改

既然 qs = qs.exclude(...) 这种写法很像"原地修改",要确认一下:.filter() 到底是改了原对象,还是造了个新的?

真实实测
base = Article.objects.all()
filtered = base.filter(views__gte=20)
print('base is filtered?', base is filtered)

list(filtered)
print('filtered._result_cache is populated:', filtered._result_cache is not None)
print('base._result_cache is still:', base._result_cache)
base is filtered? False filtered._result_cache is populated: True base._result_cache is still: None

basefiltered 是两个不同的对象,求值 filtered 完全不影响 base 的缓存状态——它俩各有各的 _result_cache。源码里 .filter() 的文档字符串写得很直接:

django/db/models/query.py · django @ 6.0.7, L1536 def filter(self, *args, **kwargs): """ Return a new QuerySet instance with the args ANDed to the existing set. """ ... return self._filter_or_exclude(False, args, kwargs)
django/db/models/query.py · django @ 6.0.7, L1979 def _clone(self): """Return a copy of the current QuerySet. A lightweight alternative to deepcopy().""" c = self.__class__(model=self.model, query=self.query.chain(), ...) ... return c

.filter()/.exclude()/.order_by() 内部都会先 _clone() 出一个新对象(带着查询描述的一份拷贝),再修改这个新对象,原对象从头到尾没被碰过。这也是为什么"惰性"是安全的:可以放心地把同一个 base 拿去派生出好几个不同方向的查询,互相之间不会打架。

真实实测:.exists()/.count() 走的是完全不同的 SQL

"求值"不是只有一种形态——有些操作压根不需要把整个结果集搬出来。

真实实测
print(Article.objects.filter(views__gte=20).exists())
print(connection.queries[-1]['sql'])
print()
print(Article.objects.filter(views__gte=20).count())
print(connection.queries[-1]['sql'])
True SELECT 1 AS "a" FROM article WHERE views >= 20 LIMIT 1 3 SELECT COUNT(*) AS "__count" FROM article WHERE views >= 20

.exists()SELECT 1 ... LIMIT 1,数据库找到第一条匹配就能停;.count()SELECT COUNT(*),交给数据库自己数,压根不会把行数据传回 Python 这边。两个都不会碰 _result_cache,也都不会把完整的模型对象实例化出来——这也是为什么"判断存在用 .exists(),别写 if list(qs):"是真实有性能依据的建议。

真实实测:切片的行为,取决于求值没求值

最容易被忽略的一个细节:qs[3:6] 到底是不是查询数据库的 LIMIT/OFFSET,要看这个 qs 之前有没有被求值过。

真实实测
qs = Article.objects.all().order_by('id')[3:6]   # 还没求值就切片
print(list(qs))
print(connection.queries[-1]['sql'])
[...] SELECT ... FROM article ORDER BY id ASC LIMIT 3 OFFSET 3
qs2 = Article.objects.all().order_by('id')
list(qs2)   # 先强制求值,填满 _result_cache
before = len(connection.queries)
sliced = qs2[3:6]   # 再切片
print('新增查询数:', len(connection.queries) - before)
print(type(sliced))
新增查询数: 0 <class 'list'>

还没求值QuerySet 上切片,切片信息会被编译进真实 SQL 的 LIMIT/OFFSET 里,数据库只返回需要的那几行;在已经求值过(_result_cache 已经填满)的 QuerySet 上切片,直接是 Python 原生 list 在内存里切,一次新查询都不会有。同样是 qs[3:6] 这行代码,实际发生的事完全取决于它执行时 _result_cache 是不是 None

演示:三次链式调用 + 两次求值,完整走一遍

复现前面"filter → exclude → order_by → 求值 → 再求值 → 求值另一个中间对象"的完整场景。

QuerySet 惰性求值演示
(还没有任何 SQL 执行)

数据来自独立验证过的 Python 模拟——每个链式调用产生的都是独立对象(用真实的 _clone() 思路复现),哪次求值是真查、哪次是命中缓存,用一套独立回放算法核对过。

参考与说明

  • 本文全部真实实验(链式调用零查询、list() 触发单条合并 SQL、二次遍历命中缓存、.filter() 返回新对象、.exists()/.count() 的专用 SQL、切片行为的求值状态依赖)均在本机真实运行的 Django 6.0.7(配合内存 SQLite 数据库,开启 DEBUG=True 记录真实查询日志)上完成,数据未做删改。
  • 源码引用(django/db/models/query.py__len__/__iter__/__bool__/_fetch_all/filter/_clone)取自本机通过 pip 安装的 Django 6.0.7 包本身,并逐字节比对确认与 django/django 仓库 6.0.7 标签完全一致。
  • 演示动画的链式求值模型(每次链式调用产生独立对象、_result_cache 是否为空作为唯一判断依据)由 Python 独立实现并交叉验证过——用真实实测过的三步链式 + 三次求值场景做参数,哪次是真查询、哪次是缓存命中都用独立回放算法核对过。
  • 没有涉及:select_related/prefetch_related 怎么把多次查询合并/预取(下一篇计划专门讲这个和经典的 N+1 问题)、QuerySetQ 对象组合查询、聚合(annotate/aggregate)对求值时机的影响。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电