Django 工程化深化
第 4 章我们把 Django 跑起来了,那一章解决的是"能跑";这一章解决"能维护、能上线、别人接手不骂人"。settings 分环境、User 模型定死、ORM 把 N+1 掐掉、迁移不乱来、signal 不当钩子用——这五件事,是 Django 项目从练习变成产品之间那道坎。
settings 分环境拆分:一个 settings.py 撑不住
是什么:把配置拆成 base.py(公共)+ dev.py(本地)+ prod.py(线上),后者用 from .base import * 继承公共项再覆盖差异项,最后用环境变量 DJANGO_SETTINGS_MODULE 决定进程加载哪一份。
为什么需要:很多人图省事,在一个 settings.py 里写 if DEBUG: 分支。问题不在"乱",而在于线上那条分支你本地永远没跑过。本地 SQLite、线上 PostgreSQL;本地 console 发邮件、线上 SMTP;本地 DEBUG=True、线上必须 False。分支写法等于让线上代码成为"只在生产才第一次执行的代码"——出事是必然的。拆文件之后,每份配置都是"一条真实走过的路径"。
怎么切换(manage.py / wsgi.py 里给默认值,部署时用环境变量覆盖)
| 配置项 | dev.py | prod.py |
|---|---|---|
DEBUG | True(方便看报错) | False(必须) |
SECRET_KEY | 写死的假值 | 环境变量,缺失即启动失败 |
DATABASES | SQLite 文件 | PostgreSQL + 连接复用 |
EMAIL_BACKEND | console(打到终端) | SMTP |
ALLOWED_HOSTS | 127.0.0.1 / localhost | 真实域名,从环境变量切分 |
| 安全头 / HTTPS | 不管 | 全开 |
论为什么 prod 里坚持用 os.environ[...] 而不是 .get(..., 默认值)
① 失败要趁早:配置缺失时"启动就崩"是好事——你会在发版阶段发现,而不是在某个用户注册时发现邮件发不出去。② 默认值会掩盖事故:os.environ.get("DJANGO_SECRET_KEY", "abc") 一旦漏配,线上会用 "abc" 签名 session,任何人都能伪造登录。③ 一个反例:DEBUG = os.environ.get("DEBUG", "True") == "True" 语法没问题,但默认值给了 True——漏配直接裸奔。
Django 在 DEBUG=True 时会:把完整 traceback(含源码片段)、全部 settings(含 SECRET_KEY、数据库密码)展示在错误页上;把 settings.ALLOWED_HOSTS 的校验放宽;静态文件由开发服务器服务(性能极差)。生产必须 DEBUG=False,并且错误页交给 handler500 或 Sentry。自查一条命令:python manage.py check --deploy --settings=config.settings.prod,它会把你漏掉的安全项逐条列出来。
自定义 User 模型:必须在下第一次 migrate 之前
是什么:用 AUTH_USER_MODEL = "accounts.User" 把 Django 的默认用户表换成你自己的模型,通常继承 AbstractUser(想保留用户名+密码那套)或 AbstractBaseUser(想彻底自己定义)。
为什么必须一开始就定:因为默认的 auth_user 表会被人引用。你项目里每一个 ForeignKey(User)、OneToOneField(User)、admin 的日志表、第三方 app 的外键,数据库里存的都是 auth_user.id 这个具体表名。等你上线三个月再想换,就得给一堆已有表改外键、迁数据——官方文档原话是"极其困难(highly difficult)"。所以:新建项目的第一个动作就是建 accounts app、写好 User、设好 AUTH_USER_MODEL,然后才第一次 migrate。反过来,如果已经不巧用了默认 User,就新建一个 Profile 一对一挂着,别再动 User。
引用用户模型:永远不要直接 import 具体的 User 类
别再走"删库重来"之外的路。migrate 已经建了 auth_user 并在 django_migrations 里记了账,此时改 AUTH_USER_MODEL 会遇到 ValueError: The field admin.LogEntry.user was declared with a lazy reference ... which refers to a model that isn't installed。三个务实选择:① 项目还没上线——删掉 db 文件、删掉 migrations 目录下的初始迁移,重新 makemigrations && migrate;② 已上线——保留默认 User,加一个 UserProfile 一对一表补字段,用 post_save 信号自动创建;③ 数据不能丢——写数据迁移把 auth_user 逐行搬到新表,再改所有外键,成本最高,务必先在预发环境演练一遍。
ORM 进阶一:select_related 与 prefetch_related,把 N+1 掐死
是什么:两个都能"预取关联对象",但机制完全不同。select_related 把关联表 JOIN 进同一条 SQL(只适合"一对一"和"多对一",也就是你从"多"这边拿"一");prefetch_related 先查主表、再用 IN (...) 查关联表,最后在 Python 里拼起来(适合"多对多"、"一对多"的反向)。
为什么需要:不写就是经典的 N+1 查询:一条 SQL 查列表,然后在循环里每行又查一次关联表。列表 100 条就是 101 条 SQL。本地区别不明显,线上就是"接口 3 秒变 300 毫秒"的差距。
怎么定位 N+1:用 connection.queries 数一数真实 SQL 条数
| 场景 | 选择 | SQL 条数 |
|---|---|---|
| 从"多"取"一":订单 → 用户 | select_related("user") | 1 |
| 一对一:用户 → 资料 | select_related("profile") | 1 |
| 一对多反向:订单 → 明细 | prefetch_related("items") | 2 |
| 多对多:文章 → 标签 | prefetch_related("tags") | 2 |
| 嵌套预取:文章 → 评论 → 评论人 | prefetch_related("reviews__user") | 每层 1 条 |
论为什么不能"全都 select_related"
① 行数被放大:一对多用 JOIN,父行会跟着子行重复。10 篇文章 × 每篇 100 条评论 = 一条 SQL 返回 1000 行,每行都带着重复的文章字段——网络传输和内存都被浪费。② 数据库优化器压力大:多层 JOIN 会让执行计划变得不可预测。③ 结论:"多对一"用 JOIN 不会放大行数(一只有一个),所以 select_related 安全;"一对多/多对多"必须用两条 SQL 的 prefetch_related。判断口诀:目标对象"只有一个"用 select_related,"可能有一堆"用 prefetch_related。
books = Book.objects.prefetch_related("reviews") 之后写 book.reviews.filter(approved=True),这个 filter 会重新发一条 SQL,之前预取的数据完全没用上。正确做法是上文的 Prefetch(..., queryset=..., to_attr=...),在预取阶段就把条件加好。同理,qs.filter(author__name="张三") 这类"跨表过滤"不会自动变成 JOIN 预取——过滤用 filter,取数据用 select_related,两件事。
ORM 进阶二:F()、Q()、Subquery、annotate 聚合
是什么:这四个是"把计算下推到数据库"的工具。它们让你少写 Python 循环、少一次查询、避开并发竞态。
F() —— 在数据库层面引用字段:库存扣减是典型场景。先读进 Python 再写回,两个请求同时下单就会超卖;F() 让数据库自己算,一条原子 SQL 搞定。
Q() —— 构造 OR / NOT 条件:普通 filter(a=1, b=2) 是 AND,想写 OR 必须用 Q。
annotate / aggregate / Subquery —— 让数据库帮你算:aggregate 把整个结果集压成一个值(总体统计);annotate 给"每一行"挂一个计算出来的字段(逐行统计);Subquery 是相关子查询,能把"每本书最新一条评论"这种需求压成一条 SQL。
| 工具 | 解决什么 | 一句话记忆 |
|---|---|---|
F() | 字段间的比较与自增自减 | "这个值让数据库自己算" |
Q() | OR / NOT / 动态条件拼装 | "AND 用参数,其他用 Q" |
annotate() | 给每行挂计算结果 | "逐行统计,结果是一张表" |
aggregate() | 整表聚合成一个字典 | "总体统计,结果是一个数" |
Subquery() | 相关子查询,避免额外往返 | "每行一个子查询,一条 SQL 拿全" |
Author.objects.filter(books__score__gt=4).annotate(n=Count("books")):先过滤,JOIN 出来的行已经被筛过,n 是"高分书的数量"。Author.objects.annotate(n=Count("books")).filter(n__gt=3):先统计全部书,再对统计结果筛,等价 SQL 的 HAVING。同一个接口,两行代码顺序一换,结果完全不同。写完聚合查询,一定用 print(qs.query) 把真实 SQL 打出来看一眼,或 qs.explain() 看执行计划。
迁移进阶:数据迁移、--fake 的风险、冲突处理
是什么:迁移(migration)是"数据库结构的 Git 提交"。它分两类:结构迁移(makemigrations 自动生成,改表结构)和数据迁移(手写 RunPython,改历史数据)。
怎么做数据迁移:场景很常见——给订单加了 total 字段,但已有 10 万行数据的 total 是空的,得回填。
为什么必须用 apps.get_model():迁移是按"历史状态"一条条执行的。如果直接 from orders.models import Order,你拿到的是今天最新的模型——它可能有 0007 之后才加的字段,于是这条历史迁移在别人机器上就会报"字段不存在"。apps.get_model 给的是"跑到这一步时"那张表的定义,这才是可重放的前提。
| 命令 | 用途 | 危险度 |
|---|---|---|
showmigrations | 看哪些迁移已跑 / 未跑 | 只读,安全 |
sqlmigrate app 0007 | 把迁移翻译成真实 SQL | 只读,上大表前必看 |
migrate --plan | 先看要执行哪些迁移 | 只读,推荐 |
makemigrations --merge | 合并两个叶子迁移(冲突时) | 中,要 review 生成的文件 |
migrate --fake | 只写记录,不执行 SQL | 高,容易让库与记录不一致 |
migrate app 0005 | 回滚到 0005(反向执行) | 高,反向迁移可能丢数据 |
论--fake 到底"假"在哪,什么时候才敢用
① 它做的事:只往 django_migrations 表插一行"某迁移已应用",一条 DDL 都不执行。② 安全场景:表是别人手工建好的,或者你从旧系统导结构进来,DBA 已经把列加上了——这时用 --fake 让记账追上现实。判断标准是"数据库实际状态已经等于迁移后的状态"。③ 危险场景:迁移真没跑,你为了让 migrate 不再报错而 --fake。后果是后续所有迁移都基于"字段其实不存在"这个错误前提,报错点飘到很远的地方,排查成本极高。④ --fake-initial:只在"项目第一个迁移 + 这些表已经存在"时安全,用于把老项目纳入迁移体系。⑤ 铁律:用 --fake 之前先 pg_dump 备份,并 \d 表名 或 SHOW CREATE TABLE 核对实际结构。
① 加字段:先 null=True(或给 default)上线,等数据回填完再改 null=False。直接给大表加 NOT NULL 字段会锁表。② 删字段:分两步——先发布"代码不再引用该字段"的版本,确认没问题,下一个版本再删列。一步到位等于把回滚路堵死。③ 改索引:PostgreSQL 用 AddIndexConcurrently + atomic = False 避免长时间锁表;MySQL 用 ALGORITHM=INPLACE,并避开业务高峰。另外:迁移脚本不要写 sleep 或长事务,Django 默认把整个迁移放在一个事务里,跑得越久锁得越死。
Admin 定制:把后台变成能用的运营工具
是什么:Django Admin 是"不用写前端就能管理数据"的后台。默认它只能看不能干,加几行配置就能变成运营同学真正能用的工具。
为什么值得花时间:因为大多数内部系统 80% 的"改一条数据""查一个订单"的需求,运营自己就能在 Admin 里完成,不用排期开发。
| 需求 | 用什么 |
|---|---|
| 列表显示计算字段(带颜色) | @admin.display + format_html |
| 列表页少查 SQL | list_select_related |
| 父子表一起编辑 | TabularInline / StackedInline |
| 批量操作 | @admin.action + queryset.update() |
| 数据级隔离 | 重写 get_queryset(不是 CSS 隐藏) |
| 导出 CSV | django-import-export |
运营勾了 1 万条订单,你的 action 写 for o in queryset: o.status = "shipped"; o.save()——这是 1 万条 UPDATE,页面直接超时,还可能把 worker 卡死。批量改字段一律 queryset.update(...),一条 SQL。另一个常见坑:list_display 里放了外键字段(如 "user")却没配 list_select_related,后台列表页每行查一次用户表,100 条数据 101 条 SQL——和第 3 节的 N+1 是同一个病。
信号 signal:什么时候该用,什么时候是反模式
是什么:signal 是 Django 内置的观察者模式。模型发生动作时(pre_save / post_save / pre_delete / m2m_changed / request_finished)发一个通知,任何"订阅者"都能收到。
为什么需要:为了解耦跨 app 的副作用。订单保存后要发通知——如果直接在 Order.save() 里 import 通知模块,订单 app 就被通知 app 绑死了;换成 signal,订单 app 只负责"喊一声",谁关心谁自己订阅。
| 用 signal 的好场景 | 反模式(别这么干) |
|---|---|
| 发通知、写审计日志、清缓存 | 把核心业务写在 post_save 里——新人读 Order.save() 根本看不到总价是怎么算出来的 |
| 第三方 app 之间解耦 | 用 signal 代替显式的服务函数调用(place_order() 该做的事就写在函数里) |
| 多个 app 对同一事件各做各的 | 在 signal 里抛异常——会让整个请求 500,而且调用方不知道为什么 |
监听 m2m_changed 维护冗余统计字段 | 在 post_save 里又 instance.save() → 无限递归 |
| — | 在 signal 里做耗时操作(发邮件、调 API)——请求会被阻塞 |
论signal 的"隐式"是它最大的优点,也是最大的缺点
① 调试靠猜:你 save() 了一个对象,结果日志里出现了一条邮件发送记录——不 grep 全项目根本不知道谁订阅了。项目里 signal 超过 10 个就该建一个 signals/ 目录集中登记。② 测试里会被意外触发:写单元测试时 Order.objects.create() 顺手就发了邮件,得用 mock.patch 把 send_order_mail 打桩,或加 @override_settings。③ 注册要防重复:dispatch_uid 是防"同一个 receiver 被注册两次导致逻辑跑两遍"的保险——尤其在有 autoreload 或测试环境反复 import 时。④ 与事务的关系:post_save 在事务内部触发,此时数据还没提交。任务队列在这个时点去查库会查不到——第 9 章会专门讲 transaction.on_commit 这个坑。
① settings 拆 base/dev/prod,用 DJANGO_SETTINGS_MODULE 切;prod 里缺配置就让它启动失败,别给默认值。
② User 模型必须在第一次 migrate 之前定好,之后只走 get_user_model() / settings.AUTH_USER_MODEL 引用。
③ ORM:目标对象只有一个用 select_related,可能有一堆用 prefetch_related;用 connection.queries 或 debug-toolbar 数 SQL 条数抓 N+1。
④ 迁移:数据迁移用 RunPython + apps.get_model();--fake 只用于"库结构已经等于迁移后状态"的场面;大表改结构分两步走。
⑤ signal 用来解耦副作用(通知、日志、清缓存),不用来实现主业务;记得在 apps.ready() 注册、加 dispatch_uid、判断 created。
1.(概念题)为什么 AUTH_USER_MODEL 必须在下第一次 migrate 之前设好?已经 migrate 了还有救吗?
查看答案
答案:因为其他表的外键已经把 auth_user.id 写进数据库了,中途换用户表要改一堆已有表的外键和迁数据,官方明确说极其困难。已 migrate 的补救:项目未上线就删库重来;已上线就保留默认 User + 加 Profile 一对一表补字段,用 post_save 自动创建。
2.(概念题)select_related 和 prefetch_related 分别适合什么关系?为什么不能"全都 select_related"?
查看答案
答案:select_related 走 JOIN,适合一对一/多对一(目标只有一个);prefetch_related 走两条 SQL 再拼,适合多对多/一对多反向(目标可能有一堆)。全用 JOIN 的问题是:一对多用 JOIN 会把父行按子行数量重复,10 篇 × 100 评论 = 返回 1000 行且父字段重复传输,内存和网络都被浪费。
3.(代码题)商品表要扣库存,下面的写法在并发下有什么问题?怎么改?p = Product.objects.get(id=1) → p.stock -= 1 → p.save()
查看答案
答案:先读后写有竞态:两个请求同时读到 stock=1,各自减成 0 再写回,实际卖了 2 件——超卖。改成 Product.objects.filter(id=1, stock__gte=1).update(stock=F("stock") - 1),用返回的影响行数判断是否抢到(0 表示库存不足)。这是 F() 存在的核心原因。
4.(概念题)数据迁移里为什么必须用 apps.get_model() 而不是直接 from orders.models import Order?
查看答案
答案:迁移是按历史状态重放的。apps.get_model() 给你"跑到这一步时"的模型定义;直接 import 拿到的是今天最新的模型,可能包含这条迁移之后才加的字段,导致在别人机器或新环境重放时字段不存在直接报错。
5.(思考题)运营反馈"点了后台的批量发货,页面转圈两分钟然后 502",你怀疑是 Admin action 的问题。怎么验证、怎么改?
查看答案
答案:先看代码是不是 for o in queryset: o.save()。用 debug-toolbar 或 connection.queries 数 SQL 条数——1 万条数据就是 1 万条 UPDATE,请求必然超时。改成 n = queryset.update(status="shipped") 一条 SQL;如果还要发通知,queryset.values_list("id", flat=True) 拿到 id 列表后投递 Celery 任务异步做,别在请求里同步发。