ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python开发从入门到精通:Web框架Django高级应用实战

Python开发从入门到精通:Web框架Django高级应用实战

前言


「从入门到精通」这个说法本身值得澄清:精通不是某个可以打勾的里程碑,而是一组具体能力的集合——知道 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 JOIN1 次

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 约定的,路径写错命令就不会出现。


六、上线检查清单




项目开发时的值上线必须改成



DEBUGTrueFalse

ALLOWED_HOSTS[]真实域名列表

SECRET_KEY硬编码从环境变量读取

数据库SQLite生产级数据库(并列好连接参数)

静态文件runserver代劳collectstatic后由 Web 服务器托管

服务器manage.py runserverWSGI/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,每次自增想一下会不会竞态,每次上线前过一遍清单。把这些变成肌肉记忆,才算真正走到了「精通」那一段路上。




RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进