楼层: 首页/ 软件技术/ Python Web 开发/ Django 工程化深化
8

Django 工程化深化

Django in Production: Settings, ORM, Migrations, Signals

第 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。分支写法等于让线上代码成为"只在生产才第一次执行的代码"——出事是必然的。拆文件之后,每份配置都是"一条真实走过的路径"。

# config/settings/base.py —— 只放两种环境都一样的东西 from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent.parent INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "rest_framework", "accounts", # 我们的业务 app "orders", ] AUTH_USER_MODEL = "accounts.User" # 见下一节,越早定越好 LANGUAGE_CODE = "zh-hans" TIME_ZONE = "Asia/Shanghai" USE_TZ = True # 存 UTC,显示时转本地时区 DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# config/settings/dev.py —— 本地开发,怎么方便怎么来 from .base import * DEBUG = True SECRET_KEY = "dev-only-key-never-use-in-prod" ALLOWED_HOSTS = ["127.0.0.1", "localhost"] DATABASES = { "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": BASE_DIR / "db.sqlite3"} } EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" # 邮件打到终端 INTERNAL_IPS = ["127.0.0.1"] # 给 django-debug-toolbar 用
# config/settings/prod.py —— 线上,缺一个变量就应该启动失败 import os from .base import * DEBUG = False SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] # 不给默认值! ALLOWED_HOSTS = os.environ["DJANGO_ALLOWED_HOSTS"].split(",") DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql", "NAME": os.environ["DB_NAME"], "USER": os.environ["DB_USER"], "PASSWORD": os.environ["DB_PASSWORD"], "HOST": os.environ["DB_HOST"], "PORT": "5432", "CONN_MAX_AGE": 60, # 连接复用 60 秒,别每个请求重连 } } SECURE_SSL_REDIRECT = True # HTTP 自动跳 HTTPS SESSION_COOKIE_SECURE = True # cookie 只在 HTTPS 上发 CSRF_COOKIE_SECURE = True SECURE_HSTS_SECONDS = 31536000 # 告诉浏览器一年内只走 HTTPS SECURE_CONTENT_TYPE_NOSNIFF = True

怎么切换(manage.py / wsgi.py 里给默认值,部署时用环境变量覆盖)

# manage.py os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.dev") # 本地:直接 python manage.py runserver # 线上:容器启动脚本里 export $ DJANGO_SETTINGS_MODULE=config.settings.prod python manage.py migrate $ DJANGO_SETTINGS_MODULE=config.settings.prod gunicorn config.wsgi -b 0.0.0.0:8000
配置项dev.pyprod.py
DEBUGTrue(方便看报错)False(必须)
SECRET_KEY写死的假值环境变量,缺失即启动失败
DATABASESSQLite 文件PostgreSQL + 连接复用
EMAIL_BACKENDconsole(打到终端)SMTP
ALLOWED_HOSTS127.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——漏配直接裸奔。

DEBUG=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。

# accounts/models.py —— 换成"邮箱登录 + 手机号"的用户 from django.contrib.auth.models import AbstractUser from django.db import models class User(AbstractUser): email = models.EmailField("邮箱", unique=True) phone = models.CharField("手机号", max_length=20, blank=True) nickname = models.CharField(max_length=30, blank=True) USERNAME_FIELD = "email" # 登录用的字段 REQUIRED_FIELDS = ["username"] # createsuperuser 时会额外问的字段 def __str__(self): return self.email
# accounts/apps.py —— 别忘了这一行,否则自定义 User 不生效 from django.apps import AppConfig class AccountsConfig(AppConfig): name = "accounts" verbose_name = "账号"

引用用户模型:永远不要直接 import 具体的 User 类

# ✅ 正确:让别人替换 AUTH_USER_MODEL 时你的代码不用改 from django.contrib.auth import get_user_model User = get_user_model() from django.conf import settings class Order(models.Model): user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) # ❌ 错误:写死了默认用户模型,换 User 时全项目报错 # from django.contrib.auth.models import User
已经 migrate 过还想换 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 毫秒"的差距。

# ❌ 反例:1 + N 条 SQL for book in Book.objects.all(): print(book.author.name) # 每循环一次查一次 author 表 # ✅ 正解一:外键 / 一对一,用 select_related(一条 JOIN SQL) books = Book.objects.select_related("author", "publisher").all() # ✅ 正解二:多对多 / 一对多反向,用 prefetch_related(两条 SQL) books = Book.objects.prefetch_related("tags", "reviews__user").all() # ✅ 正解三:prefetch 时顺手过滤,别把全表拉进内存 from django.db.models import Prefetch books = Book.objects.prefetch_related( Prefetch( "reviews", queryset=Review.objects.filter(approved=True), to_attr="ok_reviews", # 结果直接挂在对象属性上,是个 list ) ) for b in books: print(len(b.ok_reviews)) # 读内存列表,不再查库

怎么定位 N+1:用 connection.queries 数一数真实 SQL 条数

from django.db import connection, reset_queries reset_queries() # 清空计数(需要 DEBUG=True) books = list(Book.objects.all()) # 先 list() 逼出查询 for b in books: _ = b.author.name print(len(connection.queries)) # 和 len(books)+1 相等 → 踩了 N+1 # 日常更推荐 django-debug-toolbar:页面侧边直接显示 SQL 条数与耗时 # 线上用 django-silk 或 Sentry Performance 采样慢接口
场景选择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。

prefetch 之后又 filter,等于白 pre 了

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 搞定。

from django.db.models import F, Q, Count, Sum, Avg, Subquery, OuterRef, Value, DecimalField from django.db.models.functions import Coalesce # ❌ 先读后写:并发下会超卖 p = Product.objects.get(id=1) p.stock = p.stock - 1 p.save() # ✅ F():数据库原子更新,还能顺手加个"库存不能为负"的护栏 updated = Product.objects.filter(id=1, stock__gte=1).update(stock=F("stock") - 1) if updated == 0: raise ValueError("库存不足") # 影响行数为 0 就是没抢到 # F() 也能出现在 filter 里:找出"售价比原价低"的书 Book.objects.filter(price__lt=F("original_price"))

Q() —— 构造 OR / NOT 条件:普通 filter(a=1, b=2) 是 AND,想写 OR 必须用 Q。

# 价格低于 50 元 或 是畅销书 Book.objects.filter(Q(price__lt=50) | Q(is_bestseller=True)) # 标题含 python 且 有货 Book.objects.filter(Q(title__icontains="python") & ~Q(stock=0)) # 动态拼装搜索条件:前端传了哪个就加哪个 q = Q() if keyword: q &= Q(title__icontains=keyword) | Q(author__name__icontains=keyword) if min_price: q &= Q(price__gte=min_price) Book.objects.filter(q)

annotate / aggregate / Subquery —— 让数据库帮你算:aggregate 把整个结果集压成一个值(总体统计);annotate 给"每一行"挂一个计算出来的字段(逐行统计);Subquery 是相关子查询,能把"每本书最新一条评论"这种需求压成一条 SQL。

# 逐行统计:每个作者有几本书、平均评分 Author.objects.annotate(book_count=Count("books"), avg_score=Avg("books__score")) # 相关子查询:给每本书挂上"最新一条评论内容",一条 SQL latest = Review.objects.filter(book=OuterRef("pk")).order_by("-created_at") Book.objects.annotate(latest_comment=Subquery(latest.values("content")[:1])) # 总体统计:注意 Sum 在空集上返回 None,会让前端崩 Book.objects.aggregate(total=Coalesce(Sum("price"), Value(0), output_field=DecimalField())) # HAVING:只看"书籍数 > 3"的作者 —— annotate 之后 filter 才是对聚合结果筛 Author.objects.annotate(n=Count("books")).filter(n__gt=3)
工具解决什么一句话记忆
F()字段间的比较与自增自减"这个值让数据库自己算"
Q()OR / NOT / 动态条件拼装"AND 用参数,其他用 Q"
annotate()给每行挂计算结果"逐行统计,结果是一张表"
aggregate()整表聚合成一个字典"总体统计,结果是一个数"
Subquery()相关子查询,避免额外往返"每行一个子查询,一条 SQL 拿全"
annotate 和 filter 的顺序决定语义

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 是空的,得回填。

$ python manage.py makemigrations orders --empty --name backfill_total # orders/migrations/0007_backfill_total.py from django.db import migrations def backfill(apps, schema_editor): # 关键:用 apps.get_model 拿"当时那一刻"的模型,不要 import 真模型 Order = apps.get_model("orders", "Order") for o in Order.objects.filter(total__isnull=True).iterator(chunk_size=500): o.total = sum(i.price * i.qty for i in o.items.all()) o.save(update_fields=["total"]) def noop(apps, schema_editor): pass # 回滚时什么都不做,或者写反向逻辑 class Migration(migrations.Migration): dependencies = [("orders", "0006_order_total")] operations = [migrations.RunPython(backfill, noop)]

为什么必须用 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 里完成,不用排期开发。

# orders/admin.py from django.contrib import admin from django.utils.html import format_html from .models import Order, OrderItem class OrderItemInline(admin.TabularInline): # 在订单页直接编辑明细 model = OrderItem extra = 0 # 不要那 3 个空行 @admin.register(Order) class OrderAdmin(admin.ModelAdmin): list_display = ("id", "user", "total_colored", "status", "created_at") list_filter = ("status", "created_at") # 右侧筛选器 search_fields = ("id", "user__email") # 顶部搜索框 list_select_related = ("user",) # 列表页少 N 条 SQL date_hierarchy = "created_at" inlines = [OrderItemInline] actions = ["mark_shipped"] readonly_fields = ("created_at",) @admin.display(description="金额", ordering="total") # 可点表头排序 def total_colored(self, obj): color = "#C2472E" if obj.total > 1000 else "#2E4B3D" return format_html('<b style="color:{}">¥{}</b>', color, obj.total) # 批量操作:勾选多行 → 下拉菜单里选"标记为已发货" @admin.action(description="标记为已发货") def mark_shipped(self, request, queryset): n = queryset.update(status="shipped") # 一条 SQL 搞定 self.message_user(request, f"已发货 {n} 单")
# 只让运营看到"自己店铺"的数据 —— 行级权限 class OrderAdmin(admin.ModelAdmin): def get_queryset(self, request): qs = super().get_queryset(request) if request.user.has_perm("orders.view_all_order"): return qs return qs.filter(shop=request.user.shop) # 数据隔离,不是隐藏按钮就行
需求用什么
列表显示计算字段(带颜色)@admin.display + format_html
列表页少查 SQLlist_select_related
父子表一起编辑TabularInline / StackedInline
批量操作@admin.action + queryset.update()
数据级隔离重写 get_queryset(不是 CSS 隐藏)
导出 CSVdjango-import-export
Admin 里别写"循环 save"

运营勾了 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 只负责"喊一声",谁关心谁自己订阅。

# orders/signals.py —— 订阅者 from django.db.models.signals import post_save from django.dispatch import receiver from .models import Order @receiver(post_save, sender=Order, dispatch_uid="order_created_notify") def on_order_created(sender, instance, created, **kwargs): if not created: # post_save 保存和更新都会触发,必须自己判断 return from notify.tasks import send_order_mail # 延迟导入,避开 app 加载顺序问题 send_order_mail.delay(instance.id) # 重活扔给任务队列(见第 9 章)
# orders/apps.py —— 必须在这里 import,否则信号不会注册 from django.apps import AppConfig class OrdersConfig(AppConfig): name = "orders" def ready(self): from . import signals # noqa: F401 —— 只为触发装饰器注册
用 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。

自测 · Django 工程化深化

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 任务异步做,别在请求里同步发。