前言
「从入门到精通」这个说法本身值得澄清:精通不是某个可以打勾的里程碑,而是一组具体能力的集合——知道 ORM 什么时候会偷偷发出额外的 SQL、会用事务保证一组写操作的原子性、能写中间件处理横切关注点、在上线前改掉那几个致命的默认配置。本文就把这几块挑出来讲,它们才是「入门」和「能上生产」之间的分水岭。
还有一个前提要摆正:Django 的「高级」不体现在用了多少冷门 API,而体现在知不知道默认行为的代价。比如ForeignKey的取值会触发额外查询、queryset的惰性求值会在循环里爆发成 N+1、DEBUG=True上线等于把内部信息送人。这些都是默认行为,不写错代码也会踩。
本文以 Django 5.x / 6.x 为背景,示例基于一个博客模型。Django 6.x 要求 Python 3.12 及以上(5.2 支持 3.10 起),请按自己的 Django 版本核对 Python 版本。
一、N+1 查询:性能问题里最常见的一种
看这段代码,逻辑上没问题,但它会发多少次查询?
# 适用于 Django 5.x / 6.x
# 假设 Post 有一个指向 Author 的外键 author
for post in Post.objects.all(): # 第 1 次查询:取出所有 Post
print(post.author.name) # 每循环一次,再查一次 Author如果有 100 篇文章,就会发出1 + 100 = 101次查询,这就是N+1 查询问题。它的可怕之处在于:本地数据少时毫无异样,上线数据一多就慢得莫名其妙。
修法有两种,对应两种关系形态:
# 外键 / 一对一:用 select_related,底层是 SQL JOIN,一次查完
for post in Post.objects.select_related("author"):
print(post.author.name) # 不再产生额外查询
# 多对多 / 反向一对多:用 prefetch_related,单独发一条 IN 查询再在 Python 里拼
for tag in Post.objects.prefetch_related("tags"):
print(tag.name)两者的机制不同,别记混:
| 方法 | 适用关系 | 实现方式 | 查询次数 |
|---|
select_related | 外键、一对一 | SQL JOIN | 1 次 |
prefetch_related | 多对多、反向关系 | 额外一条查询 + Python 拼接 | 2 次 |
select_related还能继续深入字段(如select_related("author__profile")),但每深一层 JOIN 都更贵,不要滥用。
另外两个省流的工具:only("title")只取指定字段、defer("big_text")排除某些字段。当表里有大文本或 blob 字段,而你只需要列表页的几个字段时,它们能显著减少传输量。注意:only之后如果又访问了没取回的字段,Django 会再发一次查询去补,反而更慢。
想验证查询次数,可以在测试里用assertNumQueries:
# 适用于 Django 5.x / 6.x
from django.test import TestCase
class QueryCountTests(TestCase):
def test_list_view_query_count(self):
with self.assertNumQueries(1):
list(Post.objects.select_related("author"))二、表达式、条件与聚合
F():在数据库层面做运算,避免「读出来改完再写回」的竞态。
# 适用于 Django 5.x / 6.x
from django.db.models import F
# 计数器加一:直接生成 UPDATE ... SET views = views + 1
Post.objects.filter(pk=1).update(views=F("views") + 1)对比一下错误写法:先post = Post.objects.get(pk=1)、再post.views += 1、再post.save()。这三步之间,另一个请求可能也在做同样的事,于是两次自增只生效一次。F()把运算交给数据库执行,天然原子。
Q():构造复杂的条件表达式。
# 适用于 Django 5.x / 6.x
from django.db.models import Q
# 标题或正文中包含关键词
Post.objects.filter(Q(title__icontains="django") | Q(body__icontains="django"))Q对象之间可以用|(或)、&(与)、~(非)组合。默认的多个关键字参数是「与」的关系,一旦需要「或」就必须请出Q。
annotate()与aggregate():分组聚合。
# 适用于 Django 5.x / 6.x
from django.db.models import Avg, Count
# annotate:给每一行附上聚合结果
authors = Author.objects.annotate(post_count=Count("post"))
for a in authors:
print(a.name, a.post_count)
# aggregate:对整个结果集算一个汇总值
print(Post.objects.aggregate(avg_views=Avg("views")))区别一句话记牢:annotate是「每行都加一列」,aggregate是「整张表算一个数」。顺带一提,aggregate返回的是字典,不是 QuerySet。
三、事务:一组写操作要么全成功,要么全不做
最常用的工具是transaction.atomic,它既能当装饰器,也能当上下文管理器:
# 适用于 Django 5.x / 6.x
from django.db import transaction
def transfer(from_id, to_id, amount):
with transaction.atomic(): # 进入事务
Post.objects.filter(pk=from_id).update(views=F("views") - amount)
Post.objects.filter(pk=to_id).update(views=F("views") + amount)
# 出块时提交;块内抛异常则整体回滚要点:
atomic块可以嵌套。内层块默认用保存点(savepoint)实现,内层失败可以只回滚内层。durable=True参数用来强制某个块成为最外层事务,如果它被嵌套在另一个 atomic 里会抛RuntimeError。- 还有一种更省心的做法:把
DATABASES里对应连接的ATOMIC_REQUESTS设为True,Django 会给每个请求自动套一个事务;个别不想套事务的视图用@transaction.non_atomic_requests排除。
事务里有一个陷阱必须单独讲:不要在事务未提交时就触发「外部副作用」。比如发邮件、调第三方接口、给消息队列发消息。因为事务有可能回滚,邮件却已经发出去了,无法撤回。正确做法是把这类动作交给on_commit():
# 适用于 Django 5.x / 6.x
from django.db import transaction
def create_post(...):
with transaction.atomic():
post = Post.objects.create(...)
# 只有事务真正提交后才执行,回滚就不执行
transaction.on_commit(lambda: send_notification(post.pk))四、中间件:把横切逻辑挂到请求链上
中间件(middleware)适合处理「每个请求都要过一遍」的事:日志、耗时统计、给响应加头部、登录校验的补充逻辑。
函数式写法最直观:
# 适用于 Django 5.x / 6.x
import time
def timing_middleware(get_response):
# 这里在启动时执行一次
def middleware(request):
start = time.perf_counter()
response = get_response(request) # 交给下一层
response["X-Elapsed-Ms"] = f"{(time.perf_counter() - start) * 1000:.1f}"
return response
return middleware也可以写成类,实例可调用:
# 适用于 Django 5.x / 6.x
class TimingMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
start = time.perf_counter()
response = self.get_response(request)
response["X-Elapsed-Ms"] = f"{(time.perf_counter() - start) * 1000:.1f}"
return response官方文档的模型是「洋葱」:请求自外向内穿过每一层,响应再自内向外穿回来。所以在get_response之前写的代码在处理请求时执行,之后写的代码在处理响应时执行。写完记得在settings.py的MIDDLEWARE列表里登记,顺序有意义:越靠前的越外层。
五、类视图与自定义管理命令
类视图(CBV)把常见的请求分发逻辑抽象成类方法:get()处理 GET、post()处理 POST,基类负责分发。
# 适用于 Django 5.x / 6.x
from django.views.generic import ListView
class PostListView(ListView):
model = Post
template_name = "blog/post_list.html"
context_object_name = "posts"
paginate_by = 20
def get_queryset(self):
# 用 select_related 避免列表页的 N+1
return Post.objects.select_related("author").order_by("-created")CBV 牺牲了一些直观性换来复用:ListView自动帮你做分页、自动把结果放进上下文。代价是「这个名字的方法到底在哪被调用」变得不直观,调试时要顺着继承链看。团队里最好约定用哪一种,别混着来。
自定义管理命令用来写运维脚本,比如每天清理过期数据:
# blog/management/commands/cleanup_posts.py
import datetime
from django.core.management.base import BaseCommand
from django.utils import timezone
from blog.models import Post
class Command(BaseCommand):
help = "删除创建时间超过 30 天的草稿"
def add_arguments(self, parser):
parser.add_argument("--days", type=int, default=30)
def handle(self, *args, **options):
cutoff = timezone.now() - datetime.timedelta(days=options["days"])
deleted, _ = Post.objects.filter(created__lt=cutoff).delete()
self.stdout.write(self.style.SUCCESS(f"已删除 {deleted} 条"))注意目录必须是应用名/management/commands/,文件名就是命令名,之后用python manage.py cleanup_posts --days 7调用。这套结构是 Django 约定的,路径写错命令就不会出现。
六、上线检查清单
| 项目 | 开发时的值 | 上线必须改成 |
|---|
DEBUG | True | False |
ALLOWED_HOSTS | [] | 真实域名列表 |
SECRET_KEY | 硬编码 | 从环境变量读取 |
| 数据库 | SQLite | 生产级数据库(并列好连接参数) |
| 静态文件 | runserver代劳 | collectstatic后由 Web 服务器托管 |
| 服务器 | manage.py runserver | WSGI/ASGI 服务器 + 反向代理 |
官方文档的原话是:永远不要把DEBUG = True部署到生产环境。开启时,报错页会显示详细追踪和大量环境信息,等于把服务器内部结构展示给攻击者;而且 Django 会记录执行过的每一条 SQL,内存会快速上涨。另外,DEBUG = False时如果不设置ALLOWED_HOSTS,所有请求都会收到 400。
常见坑点
1. 循环里访问外键,写成 N+1
❌for p in Post.objects.all(): p.author.name✅Post.objects.select_related("author")。
2. 把select_related用在多对多上
❌select_related("tags"),多对多关系报错或不生效。 ✅ 多对多、反向关系用prefetch_related。
3. 用「读-改-写」自增计数
❌p = Post.objects.get(pk=1); p.views += 1; p.save(),高并发下丢更新。 ✅Post.objects.filter(pk=1).update(views=F("views") + 1)。
4. 混淆annotate与aggregate
❌ 以为annotate返回一个汇总数字。 ✅annotate给每行加列(返回 QuerySet),aggregate给整体算一个值(返回字典)。
5. 在事务里发邮件/调外部接口
❌ 事务内直接send_mail(),回滚了邮件却已发出。 ✅ 用transaction.on_commit(lambda: ...)推迟到提交后。
6.durable=True用在嵌套的 atomic 里
❌ 内层块写atomic(durable=True),抛RuntimeError。 ✅durable只用于确保成为最外层的事务块。
7. 中间件写了却忘了注册或顺序搞反
❌ 中间件文件写好了,MIDDLEWARE里没加,毫无效果。 ✅ 加进MIDDLEWARE,并注意「越靠前越外层」。
8.DEBUG = True或ALLOWED_HOSTS没配就上线
❌ 生产保留DEBUG=True;或改成False却忘设ALLOWED_HOSTS,全部请求 400。 ✅ 上线前逐项核对上面的清单。
总结
| 主题 | 核心结论 |
|---|
| 查询优化 | 外键用select_related,多对多用prefetch_related,避免 N+1 |
| 原子更新 | 自增类操作交给F(),别用读-改-写 |
| 聚合 | annotate逐行加列,aggregate整体汇总 |
| 事务 | transaction.atomic;外部副作用交给on_commit |
| 中间件 | 洋葱模型,「请求前」与「响应后」的代码位置决定执行时机 |
| 部署 | DEBUG=False、ALLOWED_HOSTS配好、SECRET_KEY走环境变量 |
从「会写 Django」到「能上生产」,跨越的不是 API 数量,而是对默认行为代价的敏感度:每次循环取关联对象时想一下会不会 N+1,每次自增想一下会不会竞态,每次上线前过一遍清单。把这些变成肌肉记忆,才算真正走到了「精通」那一段路上。