bigywater.tech
Django 系列 · 第十篇

连续写三个 .filter(),数据库到底被查了几次?str(queryset.query) 打印出来的,是不是真的发给数据库的那句 SQL?

直接用 bigywater 项目自己的 Post/Series 模型,真实数一遍每一步到底有没有真的碰数据库——filter()/exclude() 链式调用本身完全不查库,str(queryset.query) 打印出来的字符串因为一处很朴素的实现细节,甚至可能跟真实发给数据库的 SQL 长得不完全一样。

0 次查询
本机真实实测:三层 filter()/exclude() 链式调用,真实执行的 SQL 条数是 0
6 → 1
本机真实实测:5 篇文章各自访问 .series.label,不用 select_related 真实触发 6 条 SQL,加上后变成 1 条

真实源码:QuerySet 什么时候才真正查库

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 对象里追加条件,不会碰数据库。

真实实测:三层 filter/exclude,真实执行的 SQL 是 0 条

真实实测
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 在被真正求值之前,永远只是一份"打算怎么查"的描述。

真实源码 + 实测:每次 filter() 都是一个全新对象

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 从设计上就保证的。

真实源码 + 实测:str(query) 打印出来的,不一定是真实发给数据库的那句

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 抓真实执行记录。

真实实测:跨关系过滤生成真实 JOIN,不用 select_related 真的会 N+1

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",还是数据库真的能高效执行的那种。

演示:同一批数据,N+1 和 select_related 两种真实查询轨迹

对应上面 5 篇文章的真实实验,切换看两种写法各自真实发了几条 SQL。

select_related · 真实查询次数对照
不用 select_related
第几条 SQL查的是什么

数据直接取自上面真实实测的两组 CaptureQueriesContext 结果(不用 select_related 6 条、用了之后 1 条),演示动画只是把每一条真实发生的 SQL 逐步列出来。

参考与说明

  • 本文全部真实实验(filter/exclude 链式调用零查询、每次 filter() 返回全新对象、str(query) 缺失引号与真实发送 SQL 的差异、跨关系过滤的真实 JOIN、N+1 与 select_related 的真实查询条数、真实 EXPLAIN QUERY PLAN)均在本机真实运行的 Django 6.0.7 上,直接对 bigywater 项目自己的 Post/Series 模型和真实 SQLite 数据库操作完成,用 django.test.utils.CaptureQueriesContext 统计真实执行的 SQL(不依赖 settings.DEBUG),数据未做删改。
  • 源码引用(django/db/models/query.py 的 QuerySet._fetch_all/_clone、ModelIterable.__iter__、django/db/models/sql/query.py 的 Query.__str__/sql_with_params)取自本机通过 pip 安装的 Django 6.0.7 包本身,并逐字节比对确认与 django/django 仓库 6.0.7 标签完全一致。
  • 演示动画的查询轨迹数据直接取自真实实测的 CaptureQueriesContext 结果,没有额外模拟或编造的查询记录。
  • 没有涉及:SQLCompiler.as_sql() 处理 GROUP BY/聚合/UNION 这些更复杂查询形态的细节、prefetch_related(多对多/反向外键场景下用额外查询 + Python 内存拼接代替 JOIN 的另一套优化路径)、查询结果集缓存与 QuerySet 支持多次迭代之间的关系、数据库连接池/事务隔离级别对同一份 explain() 结果的影响。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电
京ICP备2026067502号-1京公网安备11010502063487号