执行计划、批量与查询预算
from tickets.models import Ticket
queryset = (
Ticket.objects.filter(status=Ticket.Status.OPEN)
.select_related("assignee")
.order_by("-created_at")
)
print(queryset.explain())changed_tickets = list(
Ticket.objects.filter(status=Ticket.Status.OPEN).order_by("id")[:500]
)
for ticket in changed_tickets:
ticket.priority = min(ticket.priority + 1, 5)
Ticket.objects.bulk_update(changed_tickets, ["priority"], batch_size=500)深水边界
执行计划依赖数据库、统计信息、参数和数据分布。同一 ORM 语句在小测试库与生产库可能选择不同路径。bulk_create() / bulk_update() 减少往返,但绕过逐对象 save() 与部分 signal;批量操作前明确哪些不变量在数据库,哪些需要显式预处理。
分页也会改变成本:大 OFFSET 需要跳过旧行;稳定游标需要唯一排序键和方向一致的条件。只选需要列可降 I/O,但 defer() 后在模板访问字段会重新查询。
验收
慢查询有 SQL、参数、计划和数据规模证据;批量操作保持约束;分页在并发插入下不重复/漏读既有项;查询数测试锁住回归。
练习(先独立完成)
- 比较 OFFSET 与
(created_at, id)游标分页。 - 验证 bulk_update 是否调用自定义 save。
- 构造一次模板访问 deferred 字段导致的 N+1。
深度加餐:批量 API 不是批量魔法
bulk_create/update 减少往返,却绕过 save、signals 和部分校验;使用前要先列出被跳过的合同。
先做一个可观察实验
from django.db import transaction
from .models import Ticket
def close_stale(ids):
with transaction.atomic():
updated = (Ticket.objects
.filter(pk__in=ids, status='OPEN')
.update(status='CLOSED'))
return updated
def import_tickets(rows, chunk_size=500):
objects = [Ticket(title=row['title'], status='OPEN') for row in rows]
return Ticket.objects.bulk_create(objects, batch_size=chunk_size)预期:close_stale 返回真正更新的行数;批量导入按 chunk_size 发出有限 SQL,但不会自动触发每个对象的 save/signals。
原理下潜与工程边界
QuerySet 惰性求值,exists/count/len/iteration 会触发不同 SQL;iterator 降低缓存占用,select_for_update 需要事务。EXPLAIN 读的是优化器计划,不是业务正确性证明。
bulk_update 不会调用 full_clean;大 IN 列表可能超过参数或计划阈值;update 返回 0 不一定是错误,可能表示条件已不再满足。
反例排错路线
先记录 SQL 数量与耗时,再看 EXPLAIN 的 rows、key、Extra;批量任务按主键范围切片,失败时记录已处理游标而非整批重跑。
练习加码(仍不提供答案)
加码:实现可恢复的批量关闭命令,支持 --limit、--dry-run 和最后一个主键游标,重复执行不得误关新工单。
验收时请补充:正常路径、权限或失败路径、以及一条能证明事务/资源/配置边界没有被绕过的测试。
继续查阅:Django 官方文档(外部资料已收录于证据账本) · Django 安全清单(外部资料已收录于证据账本)
课程合同:执行计划与批量写入
学习目标
学完本篇,读者应能把「用查询预算和 EXPLAIN 证明列表没有 N+1,批量更新保持边界」拆成输入、可信边界、Django 机制和可复查证据;同时能说明 Django 6.0 与 5.2 LTS 的差异不会改变业务不变量。
心智模型
请求/数据输入 → 边界检查 → Django 核心机制 → 可观察输出 → 恢复动作。配置、数据库提交和外部副作用分别拥有自己的生命周期;没有证据的“应该可以”不算完成。
本地机制图
图中的“输入”是不可信的 HTTP、表单、任务或配置;“边界”负责身份、格式、权限、大小、超时与版本;“机制”才是 URL、View、ORM、事务、缓存、Tasks 或 ASGI;“证据”是测试、响应、日志、查询计划和发布记录。
最小实验
在全新虚拟环境中运行:
python -m django --version
python manage.py check
python manage.py test实验只验证一个机制:输入一条有效值,再输入一条空值、重复值或无权限值。每次实验都记录 Django 版本、数据库、命令和退出码。
完整场景
把实验接到 tickets 工单流:请求携带用户身份和工单 ID,经过当前专题负责的边界,完成一次读或写,并留下响应状态、数据库变化、日志字段和清理动作。完整场景必须能第二次执行,或明确说明幂等键、事务回滚、缓存失效和任务重试策略。
运行输出
成功路径应出现:
Django 6.0.x
System check identified no issues (0 silenced).
OK失败路径要保留真实信号,例如 400(输入无效)、403(权限/CSRF)、404(资源不存在)、405(方法不允许)或数据库约束异常;不要把失败改写成含糊的 200。
失败边界
至少验证:空输入、重复提交、匿名用户、越权对象、并发写入、下游不可用、超时和版本不兼容。记录“谁拒绝、何时拒绝、状态是否回滚、重试是否安全”。开发模式的 traceback 不得进入生产响应,缓存不得跨用户复用敏感结果。
原理与排错
先确认版本和 settings,再用 manage.py check 定位启动问题;接着确认 URL/任务入口、输入解析和权限;最后检查 SQL、事务提交、响应和副作用。若结果异常,用最小复现打开 SQL/请求 ID/任务 ID 观测,沿「复现 → 缩小 → 观测 → 修复 → 回归」闭环,不靠重装依赖猜测。
练习(正文不泄答案)
- 为上述完整场景补一条正常路径和两条失败路径测试,并记录完整输出。
- 画出本篇机制图,标出身份 owner、数据提交点、缓存/任务副作用和恢复动作。
- 故意制造一次重复、越权或超时,说明应返回的状态、日志字段和是否可重试。
- 写一条 Django 5.2 LTS 兼容验收标准,再说明 Django 6.0 的新能力是否需要条件分支。
验收标准:命令可重复、输出可复查、正文没有练习答案;完成后再打开本阶段答案册。
答案册
本阶段答案册提供参考实现、关键推理、失败边界和替代方案;先独立完成练习再对照。
下一篇
下一篇是05-forms-auth-admin/01-forms-auth-admin,继续沿同一条输入—边界—机制—证据链前进。