直接用 bigywater 项目自己的 Post/Series 模型,真实数一遍每一步到底有没有真的碰数据库——filter()/exclude() 链式调用本身完全不查库,str(queryset.query) 打印出来的字符串因为一处很朴素的实现细节,甚至可能跟真实发给数据库的 SQL 长得不完全一样。
QuerySet 本身只是一堆待处理的条件,真正"编译成 SQL 并执行"这件事,压缩在很少几行代码里。
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)) # 真正触发查询,并且只触发一次
django/db/models/query.py · django @ 6.0.7, L89 class ModelIterable(BaseIterable): def __iter__(self): queryset = self.queryset compiler = queryset.query.get_compiler(using=queryset.db) # 把 Query 对象编译成 SQLCompiler results = compiler.execute_sql(...) # 这一步才真的把 SQL 发给数据库
for post in qs、list(qs)、len(qs)、切片这些操作最终都会走到 _fetch_all()——只要 _result_cache 还是 None,就真的编译并执行一次 SQL,结果缓存下来;在这之前,不管 QuerySet 身上叠了多少个 filter(),都只是在往 Query 对象里追加条件,不会碰数据库。
from django.test.utils import CaptureQueriesContext
from django.db import connection
with CaptureQueriesContext(connection) as ctx:
qs1 = Post.objects.filter(series__key='django')
qs2 = qs1.filter(title__icontains='Signal')
qs3 = qs2.exclude(url='forms.html')
print('真实执行的 SQL 条数:', len(ctx.captured_queries))
真实执行的 SQL 条数: 0
CaptureQueriesContext 是 Django 自己测试框架里真实统计 SQL 执行次数的工具——三次链式调用,一条 SQL 都没有真的发出去。QuerySet 在被真正求值之前,永远只是一份"打算怎么查"的描述。
django/db/models/query.py · django @ 6.0.7, L1979 def _clone(self): c = self.__class__( model=self.model, query=self.query.chain(), # 克隆一份新的 Query,不是共享引用 using=self._db, hints=self._hints, ) ... return c
filter()/exclude() 内部都会先 _chain() 出一个新 QuerySet(带着一份全新克隆的 Query),再把新条件加到这个克隆体上——原来那个 QuerySet 一点都不会被改动。
print('qs1.query is qs2.query:', qs1.query is qs2.query)
print('qs1 有没有 icontains 条件:', 'LIKE' in str(qs1.query))
print('qs2 有没有 icontains 条件:', 'LIKE' in str(qs2.query))
qs1.query is qs2.query: False
qs1 有没有 icontains 条件: False
qs2 有没有 icontains 条件: True
这也是为什么可以放心把一个 QuerySet 存成变量反复复用、在它基础上派生出不同条件的查询——每次 filter() 都不会污染原对象,这是 Django ORM 从设计上就保证的。
django/db/models/sql/query.py · django @ 6.0.7, L346 def __str__(self): """Return the query as a string of SQL with the parameter values substituted in (use sql_with_params() to see the unsubstituted string). Parameter values won't necessarily be quoted correctly, since that is done by the database interface at execution time.""" sql, params = self.sql_with_params() return sql % params # 简单粗暴的字符串替换,不负责补引号
源码注释自己写得很清楚:str(query) 只是拿 sql % params 把参数糊进占位符,不会像数据库驱动那样正确加引号、转义。真实验证一下:
qs = Post.objects.filter(title__icontains='100% real') print(str(qs.query))... WHERE "article_post"."title" LIKE %100\% real% ESCAPE '\' ...
注意 LIKE 后面那个 %100\% real%——缺了引号,当字符串直接抄去跑会报 SQL 语法错误。再看真实发给 SQLite 的那句:
with CaptureQueriesContext(connection) as ctx:
list(qs)
print(ctx.captured_queries[0]['sql'])
... WHERE "article_post"."title" LIKE '%100\% real%' ESCAPE '\' ...
真实执行的这句,引号是齐的。str(queryset.query) 只适合调试时人眼扫一眼"大概查了什么条件",不能当成"数据库实际收到的 SQL"直接拿去复制粘贴——真要看这份,得用 connection.queries 或者 CaptureQueriesContext 抓真实执行记录。
Post 有一个指向 Series 的外键,filter(series__label=...) 这种跨关系条件,编译出来的 SQL 会带一个真实的 JOIN。
qs = Post.objects.filter(series__label='Django') print(str(qs.query))... FROM "article_post" INNER JOIN "article_series" ON ("article_post"."series_id" = "article_series"."id") WHERE "article_series"."label" = Django ...
但取出 Post 之后再挨个访问 .series.label,是完全独立的另一回事——外键描述符是每个实例各自惰性加载、互不共享缓存的:
with CaptureQueriesContext(connection) as ctx:
posts = list(Post.objects.filter(series__key='django')[:5])
labels = [p.series.label for p in posts] # 5 篇文章,都属于同一个 series
print('真实执行的 SQL 条数:', len(ctx.captured_queries))
真实执行的 SQL 条数: 6
1 条查 5 篇文章,另外 5 条是每篇文章各自访问 .series 时单独查出来的——哪怕这 5 篇文章的 series 其实是同一行数据,Django 也不会跨实例共享这份缓存,老老实实查了 5 次。加上 select_related('series'):
with CaptureQueriesContext(connection) as ctx:
posts = list(Post.objects.filter(series__key='django').select_related('series')[:5])
labels = [p.series.label for p in posts]
print('真实执行的 SQL 条数:', len(ctx.captured_queries))
真实执行的 SQL 条数: 1
select_related 把 JOIN 直接带进同一条 SQL 的 SELECT 列表里,一次查询把 Post 和它关联的 Series 一起取出来,访问 .series 时命中的是已经填好的缓存,不再触发新查询。
Post.objects.filter(series__key='django').select_related('series').explain()
SEARCH article_series USING INDEX sqlite_autoindex_article_series_1 (key=?)
SEARCH article_post USING INDEX article_post_series_id_fbf7a102 (series_id=?)
USE TEMP B-TREE FOR ORDER BY
真实的 EXPLAIN QUERY PLAN 确认这条 JOIN 真的走上了索引(Series.key 的唯一索引、Post.series_id 外键索引),排序部分因为要按 date 倒序,用了一个临时 B 树——编译出来的不只是"能跑的 SQL",还是数据库真的能高效执行的那种。
对应上面 5 篇文章的真实实验,切换看两种写法各自真实发了几条 SQL。
| 第几条 SQL | 查的是什么 |
|---|
数据直接取自上面真实实测的两组 CaptureQueriesContext 结果(不用 select_related 6 条、用了之后 1 条),演示动画只是把每一条真实发生的 SQL 逐步列出来。