Django 模型字段怎么选?索引和约束该怎么加?
简化版
Django 模型设计有三个层次的决策:字段类型、索引、约束。****字段类型最容易踩的是 null 和 blank 的区别——null=True 是「数据库层允许 NULL」,blank=True 是「表单/校验层允许留空」,两者独立;而且字符串字段不要用 null=True(Django 官方建议),因为那样「空」就有了 "" 和 NULL 两种表示,查询和去重都会出问题。其他常见选择:金额必须用 DecimalField 而不是 FloatField(浮点数有精度误差)、CharField 和 TextField 在 PostgreSQL 上性能几乎一样(区别主要在表单控件和 max_length 校验)、JSONField(3.1+ 所有后端可用)适合结构不固定的数据但无法建普通索引。索引有两种写法:字段上的 db_index=True(单列)和 Meta.indexes = [models.Index(fields=[...])](推荐,能建复合索引、条件索引、指定名字);关键原则是复合索引的字段顺序必须匹配查询条件(Index(fields=["a", "b"]) 能加速 filter(a=..) 和 filter(a=.., b=..),但加速不了单独的 filter(b=..))。约束方面,unique_together 已被 UniqueConstraint 取代,后者还支持条件唯一(condition=Q(is_deleted=False),实现「软删除下的唯一」);CheckConstraint 则把业务规则固化在数据库层。最后一个工程要点:给大表加索引会锁表——PostgreSQL 要用 CREATE INDEX CONCURRENTLY(Django 提供 AddIndexConcurrently)。核心记忆:null 管数据库、blank 管校验,字符串别用 null=True;复合索引看字段顺序;用 UniqueConstraint 而不是 unique_together。
详细版
字段选择速查:
| 需求 | 用什么 | 注意 |
|---|---|---|
| 短文本 | CharField(max_length=n) | 必须有 max_length |
| 长文本 | TextField() | PostgreSQL 上性能与 CharField 相同 |
| 金额 | DecimalField(max_digits, decimal_places) | ❌ 绝不用 FloatField |
| 整数 | IntegerField / BigIntegerField | 主键默认 BigAutoField(3.2+) |
| 布尔 | BooleanField | 可空用 null=True 而不是 NullBooleanField(已移除) |
| 时间 | DateTimeField(auto_now_add/auto_now) | 配合 USE_TZ=True |
| 半结构化 | JSONField() | 无法建普通索引(PG 可用 GIN) |
| 唯一标识 | UUIDField(default=uuid.uuid4) | 别用 default=uuid.uuid4()(加了括号就固定了) |
| 枚举 | TextChoices / IntegerChoices(3.0+) | 比裸 choices 元组好用 |
from django.db import models
from django.db.models import Q, F, UniqueConstraint, CheckConstraint, Index
class Order(models.Model):
# ① ★枚举用 TextChoices(3.0+)★
class Status(models.TextChoices):
PENDING = "pending", "待支付"
PAID = "paid", "已支付"
CANCELLED = "cancelled", "已取消"
# ② ★null vs blank★
user = models.ForeignKey("User", on_delete=models.PROTECT, related_name="orders")
note = models.CharField(max_length=200, blank=True) # ★字符串:blank 不 null★
paid_at = models.DateTimeField(null=True, blank=True) # ★非字符串:两个都要★
# ③ ★金额用 Decimal★
amount = models.DecimalField(max_digits=12, decimal_places=2)
# amount = models.FloatField() # ✗ ★0.1+0.2 != 0.3★
status = models.CharField(max_length=20, choices=Status.choices,
default=Status.PENDING, db_index=True)
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
is_deleted = models.BooleanField(default=False)
class Meta:
# ④ ★索引:推荐用 Meta.indexes(能建复合/条件索引)★
indexes = [
Index(fields=["user", "-created"], name="idx_order_user_created"),
Index(fields=["status", "created"]),
Index(fields=["created"], name="idx_recent",
condition=Q(is_deleted=False)), # ★条件索引(PG)★
]
# ⑤ ★约束:UniqueConstraint 取代 unique_together★
constraints = [
UniqueConstraint(fields=["user", "order_no"], name="uniq_user_orderno"),
# ★条件唯一:软删除场景下"未删除的记录才唯一"★
UniqueConstraint(fields=["order_no"], condition=Q(is_deleted=False),
name="uniq_active_orderno"),
# ★检查约束:业务规则固化到数据库★
CheckConstraint(check=Q(amount__gte=0), name="amount_non_negative"),
CheckConstraint(check=Q(paid_at__isnull=True) | Q(status="paid"),
name="paid_at_requires_paid"),
]
ordering = ["-created"] # ★注意:会影响 GROUP BY(见 ORM 高级查询专题)★
# ⑥ ★UUID 的经典坑★
class Doc(models.Model):
uid = models.UUIDField(default=uuid.uuid4) # ✓ ★传函数★
# uid = models.UUIDField(default=uuid.uuid4()) # ✗ ★所有行同一个值!★
# ⑦ 外键的 on_delete
models.CASCADE # 级联删除(★慎用★)
models.PROTECT # ★阻止删除(推荐用于重要关联)★
models.SET_NULL # 置空(需要 null=True)
models.SET_DEFAULT / models.SET(fn) / models.DO_NOTHING / models.RESTRICT(3.1+)
⚠️ 三个必须分清的点:①
null和blank是两个层次的东西:null=True影响数据库(该列允许NULL),blank=True影响表单和full_clean()校验(允许提交空值)。两者可以任意组合,但有一条铁律:字符串类型的字段(CharField/TextField)不要设null=True——Django 官方建议用blank=True加默认的空字符串,因为同时允许""和NULL会让「空值」有两种表示,导致查询要写Q(x="") | Q(x__isnull=True)、唯一约束和排序行为也变得诡异。非字符串字段(日期、数字、外键)想表示「没有值」则必须null=True,通常和blank=True一起给。② 复合索引的字段顺序决定它能加速哪些查询:Index(fields=["a", "b"])对应 SQL 的(a, b)复合索引,遵循「最左前缀」原则——能加速filter(a=x)和filter(a=x, b=y),但加速不了只按b过滤的查询。所以索引要按实际的查询模式来建,而不是「给每个字段都加db_index=True」(索引会拖慢写入、占用空间)。③unique_together和index_together已被弃用(分别由UniqueConstraint和Meta.indexes取代)。UniqueConstraint不只是换个写法——它支持condition=Q(...)条件唯一(软删除场景的刚需:「只有未删除的记录之间才要求唯一」)、支持表达式和nulls_distinct,这些是unique_together做不到的。
完整版教学
一、字段类型的选择
★ 字符串:CharField vs TextField
CharField(max_length=n) → VARCHAR(n),★必须指定 max_length★
TextField() → TEXT,无长度限制
★ PostgreSQL 上两者性能★几乎相同★(PG 的 varchar 和 text 存储方式一样)
★ MySQL 上有区别:VARCHAR 可以直接建索引,★TEXT 建索引要指定前缀长度★
→ 选择依据主要是:
- 需要长度校验、需要单行输入框 → CharField
- 长文本、不确定长度 → TextField
★ ★数值:金额绝对不能用 FloatField★
FloatField → 双精度浮点 → ★0.1 + 0.2 = 0.30000000000000004★
→ 金额计算会累积误差,对账对不上
DecimalField(max_digits=12, decimal_places=2)
→ ★精确十进制★,Python 侧是 Decimal
★ max_digits 是总位数(含小数),decimal_places 是小数位
→ 12/2 表示最大 9999999999.99
★ 也可以用整数存"分"(IntegerField),避免 Decimal 的序列化麻烦
★ 整数与主键:
IntegerField 4 字节(约 21 亿)
BigIntegerField 8 字节
★Django 3.2 起默认主键是 BigAutoField★(DEFAULT_AUTO_FIELD)
→ 老项目升级时会生成一堆"改主键类型"的迁移 → 显式设置 DEFAULT_AUTO_FIELD 避免
★ 时间:
DateTimeField(auto_now_add=True) ★创建时设置一次★
DateTimeField(auto_now=True) ★每次 save() 更新★
★ 两个坑:
① ★auto_now 在 queryset.update() 时不会更新★(update 不走 save)
② ★auto_now_add 之后无法修改★(editable=False)
✓ 需要灵活控制就用 default=timezone.now(★不加括号★)
★ 配合 USE_TZ=True:数据库存 UTC,展示时转本地(见时区专题)
★ JSONField(3.1+ 所有后端支持):
data = models.JSONField(default=dict) # ★default 传 dict 不是 {}★
查询:filter(data__key="v")、filter(data__nested__x__gt=1)
★ 优点:结构灵活、省去多张表
★ 缺点:
- ★无法建普通索引★(PostgreSQL 可以建 GIN 索引)
- 无法用数据库约束保证结构
- ★查询性能不如规范化的列★
→ 适合"日志、扩展属性、第三方回调原文";★核心业务字段仍应规范化★
★ 枚举(3.0+ 的 TextChoices/IntegerChoices):
class Status(models.TextChoices):
PENDING = "pending", "待处理" # (值, 显示名)
status = models.CharField(choices=Status.choices, default=Status.PENDING)
→ 用法:Status.PENDING、obj.get_status_display()、Status.PENDING.label
★ 比裸元组 choices 好在:有类型提示、可以在代码里引用常量
★ ★可变默认值的坑(和 Python 的可变默认参数同源)★:
✗ models.UUIDField(default=uuid.uuid4()) # ★加了括号 → 所有行同一个值★
✓ models.UUIDField(default=uuid.uuid4) # ★传函数对象★
✗ models.JSONField(default={}) # ★所有行共享同一个 dict★(Django 会报警告)
✓ models.JSONField(default=dict)
字段选择有几个高频决策点。金额必须用 DecimalField 而不是 FloatField——浮点数的 0.1 + 0.2 = 0.30000000000000004,金额计算会累积误差导致对账对不上(另一种做法是用整数存「分」)。CharField 和 TextField 在 PostgreSQL 上性能几乎相同(PG 的 varchar 和 text 存储方式一样),选择依据主要是长度校验和表单控件;MySQL 上则有区别(TEXT 建索引要指定前缀长度)。时间字段有两个坑:auto_now 在 queryset.update() 时不会更新(不走 save())、auto_now_add 之后无法修改——需要灵活控制就用 default=timezone.now(不加括号)。JSONField(3.1+)适合日志、扩展属性、第三方回调原文,但无法建普通索引、无法用约束保证结构,核心业务字段仍应规范化。最后是可变默认值的坑(和 Python 的可变默认参数同源):default=uuid.uuid4() 加了括号会让所有行拿到同一个值,必须传函数对象 default=uuid.uuid4、default=dict。
二、null 与 blank:最经典的区分
★ 两者作用在不同层次:
┌──────────┬────────────────────┬────────────────────────┐
│ │ null=True │ blank=True │
├──────────┼────────────────────┼────────────────────────┤
│ 作用层次 │ ★数据库★(允许 NULL)│ ★校验层★(表单/full_clean)│
│ 影响 SQL │ ✅ 列定义 NULL │ ❌ ★完全不影响数据库★ │
│ 影响表单 │ ❌ │ ✅ 该字段非必填 │
│ 默认值 │ False │ False │
└──────────┴────────────────────┴────────────────────────┘
★ 四种组合的含义:
(都不设) 必填,数据库不允许 NULL
blank=True ★表单可以不填★,但数据库仍不允许 NULL
→ ★字符串字段的推荐组合★(存 "" 而不是 NULL)
null=True 数据库允许 NULL,★但表单仍然必填★(很少单独用)
null=True, blank=True ★非字符串字段"可选"的标准写法★
★ ★铁律:字符串字段不要用 null=True★
Django 官方文档明确建议:
"Avoid using null on string-based fields"
原因:
① ★"空"会有两种表示:'' 和 NULL★
→ 查询要写 Q(x='') | Q(x__isnull=True)
→ 忘了其中一种就漏数据
② 唯一约束下:★多个 NULL 不算重复(SQL 标准),但多个 '' 算重复★
→ 行为不一致,容易踩坑
③ 排序、比较、聚合中 NULL 的行为特殊(NULL 参与比较结果是 NULL)
✓ 正确:models.CharField(max_length=100, blank=True) # 默认 ''
★ 例外:需要区分"没填"和"填了空",且用了 unique 时可能需要 null=True
★ 非字符串字段的"可选":
paid_at = models.DateTimeField(null=True, blank=True)
user = models.ForeignKey(User, null=True, blank=True, on_delete=SET_NULL)
count = models.IntegerField(null=True, blank=True)
★ 只给 blank=True 不给 null=True → 表单能不填,但保存时数据库报 NOT NULL 错误
★ 一个相关的常见问题:
BooleanField 想表示"未知" → ★null=True★(NullBooleanField 已在 4.0 移除)
is_active = models.BooleanField(null=True) # True / False / None
★ 查询空值:
filter(note="") 空字符串
filter(note__isnull=True) NULL
filter(Q(note="") | Q(note__isnull=True)) ★如果两种都可能存在(就是要避免的情况)★
★ exclude() 与 NULL:exclude(x=1) ★不会包含 x 为 NULL 的行★(SQL 三值逻辑)
→ 需要包含就写 exclude(x=1) | filter(x__isnull=True)
null 和 blank 是 Django 最经典的区分:null=True 作用在数据库层(列允许 NULL),blank=True 作用在校验层(表单和 full_clean() 允许留空)——它完全不影响数据库结构。四种组合里最常用的两个是:字符串字段用 blank=True(不加 null)、非字符串字段用 null=True, blank=True。「字符串字段不要用 null=True」是 Django 官方的明确建议,理由有三:「空」会有 "" 和 NULL 两种表示(查询要写 Q(x="") | Q(x__isnull=True),漏一种就丢数据)、唯一约束下多个 NULL 不算重复而多个 "" 算重复(行为不一致)、以及 NULL 在排序比较聚合中的特殊语义。还有个容易忽略的关联知识:exclude(x=1) 不会包含 x 为 NULL 的行(SQL 的三值逻辑),需要包含时要显式加 filter(x__isnull=True)。
三、索引:db_index vs Meta.indexes
★ 两种写法:
① 字段级:models.CharField(db_index=True)
→ 只能建★单列索引★,名字自动生成
② ★Meta.indexes(推荐)★:
class Meta:
indexes = [
models.Index(fields=["user", "-created"], name="idx_user_created"),
models.Index(fields=["status"], condition=Q(is_deleted=False),
name="idx_active_status"), # ★条件索引★
models.Index(fields=["email"], name="idx_email_lower",
# 表达式索引(3.2+)
),
models.Index(Lower("email"), name="idx_email_lower2"), # ★3.2+★
]
→ 支持:★复合索引、降序、条件索引、表达式索引、指定名字、include(PG 覆盖索引)★
★ ★复合索引的最左前缀原则(★核心考点★)★:
Index(fields=["a", "b", "c"]) → 数据库里是 (a, b, c) 的 B-Tree 索引
能加速:
✓ filter(a=1)
✓ filter(a=1, b=2)
✓ filter(a=1, b=2, c=3)
✓ filter(a=1).order_by("b")
★不能★加速:
✗ filter(b=2) ← ★跳过了最左列★
✗ filter(c=3)
✗ filter(b=2, c=3)
→ ★索引的字段顺序必须匹配查询模式★
★ 排序也要考虑索引:
Index(fields=["user", "-created"]) → ★支持 filter(user=x).order_by("-created")★
★ 如果索引是升序而查询要降序,多数数据库仍能反向扫描(代价略高)
★ 但★混合方向★(a 升 b 降)就必须在索引里指定方向
★ 什么时候该加索引:
✓ ★频繁出现在 filter/exclude 的字段★
✓ ★外键★(Django ★自动给 ForeignKey 建索引★,不用手动加 db_index)
✓ 频繁 order_by 的字段
✓ unique 约束(★自动带索引★)
✗ ★区分度低的字段★(如 is_active 只有两个值)—— 索引意义不大
→ 但★条件索引★可以:Index(fields=["created"], condition=Q(is_active=True))
✗ 很少查询的字段
✗ 频繁更新的字段(★每次更新都要维护索引★)
★ 索引的代价(★别无脑加★):
- ★写入变慢★:每次 INSERT/UPDATE/DELETE 都要维护所有相关索引
- ★占用空间★:大表的索引可能比数据还大
- 优化器可能选错索引
→ ★经验:一张表的索引控制在 5 个以内;用 explain() 验证每个索引真的被用到★
★ 特殊索引(PostgreSQL):
from django.contrib.postgres.indexes import GinIndex, BrinIndex
GinIndex(fields=["data"]) ★JSONField / 数组 / 全文搜索★
BrinIndex(fields=["created"]) ★超大表的时间列(体积极小)★
Index(fields=["a"], include=["b"]) ★覆盖索引(3.2+)★
★ 查看索引是否生效:
print(qs.explain()) # ★看有没有 Index Scan★
# PostgreSQL: EXPLAIN ANALYZE
# 数据库里:\d+ table_name(PG)/ SHOW INDEX FROM table(MySQL)
索引有两种写法:字段上的 db_index=True(只能单列)和 Meta.indexes(推荐,支持复合、降序、条件、表达式索引和自定义名字)。核心考点是复合索引的最左前缀原则:Index(fields=["a","b","c"]) 能加速 filter(a=..)、filter(a=.., b=..)、filter(a=.., b=.., c=..),但加速不了跳过最左列的 filter(b=..)——所以索引的字段顺序必须匹配实际的查询模式。要知道 Django 会自动为 ForeignKey 建索引(不用手动加 db_index=True),unique 也自带索引。不该加索引的情况:区分度低的字段(is_active 只有两个值,但可以改用条件索引)、很少查询的字段、频繁更新的字段。索引的代价是实打实的:写入变慢、占用空间(大表的索引可能比数据还大)——经验是一张表控制在 5 个索引以内,并用 explain() 验证每个索引真的被用到。PostgreSQL 还有 GinIndex(JSONField/全文搜索)、BrinIndex(超大表的时间列)等特殊索引。
四、约束:UniqueConstraint 与 CheckConstraint
★ unique_together / index_together 已弃用★
✗ class Meta: unique_together = [("user", "date")] # ★弃用★
✓ class Meta:
constraints = [UniqueConstraint(fields=["user", "date"],
name="uniq_user_date")]
★ 为什么换:UniqueConstraint 功能强得多(见下),且 name 可控(迁移更稳定)
★ ★UniqueConstraint 的三个进阶能力★:
① ★条件唯一(condition)—— 软删除场景的刚需★
UniqueConstraint(fields=["email"], condition=Q(is_deleted=False),
name="uniq_active_email")
→ ★只有未删除的记录之间要求 email 唯一★
→ 删除后可以再注册同一个 email
★ 用 unique=True 做不到这件事
② ★表达式约束(2.2+/4.0+)★
UniqueConstraint(Lower("email"), name="uniq_email_ci") # ★大小写不敏感唯一★
③ ★nulls_distinct(5.0+,PostgreSQL 15+)★
控制多个 NULL 算不算重复(SQL 标准是"不算")
★ ★CheckConstraint:把业务规则固化在数据库★
constraints = [
CheckConstraint(check=Q(amount__gte=0), name="amount_gte_0"),
CheckConstraint(check=Q(end__gt=F("start")), name="end_after_start"),
CheckConstraint(check=Q(status__in=["a","b","c"]), name="valid_status"),
]
★ 价值:★即使有人绕过 Django 直接写数据库,规则依然生效★
★ 代价:违反时抛 IntegrityError(★不是 ValidationError★)→ 要在视图里捕获
★ 4.1+ 参数名从 check 改为 condition(旧名仍兼容一段时间)
★ 模型层校验 vs 数据库约束(★两个层次都要★):
┌──────────────┬────────────────┬──────────────────────┐
│ │ 模型 clean/validators │ 数据库 constraints │
├──────────────┼────────────────┼──────────────────────┤
│ 触发时机 │ full_clean()/表单 │ ★每次写入(无法绕过)★│
│ save() 时 │ ★不自动调用!★ │ 总是生效 │
│ 错误类型 │ ValidationError │ ★IntegrityError★ │
│ 用户体验 │ ★友好的字段级错误★│ 一个数据库异常 │
└──────────────┴────────────────┴──────────────────────┘
★ 关键认知:★Model.save() 不会自动调用 full_clean()★
→ 只有 ModelForm / DRF Serializer 会触发校验
→ ★所以数据库约束是最后一道防线★
✓ 实践:★表单/序列化器做友好校验 + 数据库约束兜底★
★ 外键的 on_delete(★必填参数★):
CASCADE ★级联删除★(删用户 → 删他的所有订单,★慎用于重要数据★)
PROTECT ★阻止删除★(有关联就抛 ProtectedError,★推荐用于重要关联★)
RESTRICT 3.1+,类似 PROTECT 但允许"同一次删除里级联清理"
SET_NULL 置为 NULL(★需要 null=True★)
SET_DEFAULT 置为默认值
SET(fn) 置为函数返回值(如"已注销用户")
DO_NOTHING ★什么都不做(数据库层可能报外键错误)★
★ 选择建议:
- 从属数据(订单的明细行)→ CASCADE
- 重要引用(订单的用户)→ ★PROTECT★(防止误删导致数据丢失)
- 可选引用(文章的分类)→ SET_NULL
★ 注意:CASCADE 是★Django 层实现★(会加载对象、触发信号),
大批量删除时★可能很慢★ → 大数据量考虑数据库层的 ON DELETE CASCADE
unique_together 和 index_together 已被弃用,取代它们的 UniqueConstraint 功能强得多:条件唯一(condition=Q(is_deleted=False)——软删除场景的刚需,删除后可以再注册同一个 email,这是 unique=True 做不到的)、表达式约束(Lower("email") 实现大小写不敏感唯一)、以及 nulls_distinct。CheckConstraint 把业务规则固化在数据库层,价值是即使有人绕过 Django 直接写数据库,规则依然生效;代价是违反时抛 IntegrityError 而不是友好的 ValidationError。这里有个关键认知:Model.save() 不会自动调用 full_clean()——模型层的 clean() 和 validators 只在 ModelForm 或 DRF Serializer 中才被触发,所以数据库约束是最后一道防线,实践上应该「表单做友好校验 + 数据库约束兜底」。外键的 on_delete 选择建议是:从属数据用 CASCADE、重要引用用 PROTECT(防止误删导致数据丢失)、可选引用用 SET_NULL;注意 Django 的 CASCADE 是应用层实现的(会加载对象、触发信号),大批量删除时可能很慢。
五、字段变更与线上安全
★ 加索引会锁表(★大表的头号风险★):
PostgreSQL:普通 CREATE INDEX 会持有 ★SHARE 锁★ → ★阻塞写入★
MySQL:5.6+ 的 Online DDL 大多不阻塞,但仍有代价
✓ PostgreSQL 的解法:
from django.contrib.postgres.operations import AddIndexConcurrently
class Migration(migrations.Migration):
atomic = False # ★必须关闭事务★
operations = [AddIndexConcurrently("order", Index(fields=["x"], name="..."))]
→ ★CREATE INDEX CONCURRENTLY:不阻塞写入,但耗时更长且可能失败★
★ 字段变更的风险等级:
★安全(几乎瞬时)★:
- 加可空字段(PG 11+ 加带默认值的字段也快)
- 删除索引
- 加/删 CHECK 约束(NOT VALID 时)
★危险(可能锁表很久)★:
- ★加 NOT NULL 且无默认值的字段★(要重写全表)
- ★改字段类型★(varchar → int 等)
- ★加唯一约束★(要全表扫描校验)
- 大表加索引(不用 CONCURRENTLY 时)
✓ 大表的安全做法:★分多次迁移★
① 加可空字段 → ② 后台回填数据 → ③ 加 NOT NULL 约束
★ 改字段名/删字段的两阶段发布:
✗ 直接改名 → ★旧代码还在跑时会报错(列不存在)★
✓ 阶段一:加新字段 + 双写 + 数据回填
阶段二:代码全部切到新字段
阶段三:删除旧字段
★ 这就是"扩展-收缩(expand-contract)"模式
★ choices 变更不需要迁移吗:
★需要★——Django 会为 choices 变化生成迁移(AlterField)
★ 但它★不改变数据库结构★(choices 只是 Django 层的校验)
→ 可以安全执行;如果嫌烦可以在 Meta 里忽略
★ 相关的一些细节:
- ★db_column★:指定数据库列名(对接遗留库时用)
- ★db_table★:指定表名
- ★managed=False★:Django 不管理这张表(不生成迁移)
- ★editable=False★:不出现在表单里
- ★verbose_name / help_text★:admin 和表单里显示
★ 一个实用的检查清单(新建模型时):
□ 主键类型(BigAutoField 还是 UUID)
□ ★字符串字段用 blank 不用 null★
□ ★金额用 Decimal★
□ 外键的 ★on_delete 选对了吗★(PROTECT 还是 CASCADE)
□ ★related_name★ 是否清晰(默认的 xxx_set 不好用)
□ 需要软删除吗(is_deleted + ★条件唯一约束★)
□ ★索引按查询模式建★(不是每个字段都加)
□ 重要业务规则加 ★CheckConstraint★
□ 时间字段(created/updated)
□ Meta.ordering(★注意它会影响 GROUP BY★)
字段变更的最大风险是加索引会锁表:PostgreSQL 的普通 CREATE INDEX 会持有 SHARE 锁阻塞写入,解法是用 AddIndexConcurrently(对应 CREATE INDEX CONCURRENTLY,必须设 atomic = False)。变更的风险分级要清楚:加可空字段、删索引是安全的;加 NOT NULL 且无默认值的字段、改字段类型、加唯一约束则可能锁表很久——大表的安全做法是分多次迁移(先加可空字段 → 后台回填 → 再加 NOT NULL)。改名和删字段要用**「扩展-收缩」模式**(加新字段 + 双写 → 切代码 → 删旧字段),因为直接改名会让还在运行的旧代码报错。最后那份新建模型的检查清单值得对照:字符串用 blank 不用 null、金额用 Decimal、外键的 on_delete 选对、related_name 清晰、索引按查询模式建、重要规则加 CheckConstraint。
六、实践建议
★ 一个设计良好的模型示例:
class Article(models.Model):
class Status(models.TextChoices):
DRAFT = "draft", "草稿"
PUBLISHED = "published", "已发布"
# 标识
id = models.BigAutoField(primary_key=True)
slug = models.SlugField(max_length=100, unique=True)
# 关联(★on_delete 要想清楚★)
author = models.ForeignKey("User", on_delete=models.PROTECT,
related_name="articles") # ★related_name★
category = models.ForeignKey("Category", on_delete=models.SET_NULL,
null=True, blank=True, related_name="articles")
tags = models.ManyToManyField("Tag", blank=True, related_name="articles")
# 内容(★字符串用 blank★)
title = models.CharField(max_length=200)
summary = models.CharField(max_length=500, blank=True)
content = models.TextField()
extra = models.JSONField(default=dict, blank=True)
# 状态与统计
status = models.CharField(max_length=20, choices=Status.choices,
default=Status.DRAFT)
view_count = models.PositiveIntegerField(default=0)
is_deleted = models.BooleanField(default=False)
# 时间
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
published_at = models.DateTimeField(null=True, blank=True)
class Meta:
indexes = [
models.Index(fields=["status", "-published_at"]), # 列表页
models.Index(fields=["author", "-created"]), # 作者页
models.Index(fields=["-created"], condition=Q(is_deleted=False),
name="idx_active_recent"), # ★条件索引★
]
constraints = [
models.UniqueConstraint(fields=["slug"], condition=Q(is_deleted=False),
name="uniq_active_slug"), # ★软删除下唯一★
models.CheckConstraint(check=Q(view_count__gte=0),
name="view_count_gte_0"),
models.CheckConstraint(
check=Q(status="draft") | Q(published_at__isnull=False),
name="published_needs_time"), # ★状态一致性★
]
ordering = ["-created"]
★ 常见的设计权衡:
┌────────────────────┬──────────────────────────────────────┐
│ 软删除 vs 物理删除 │ 软删除要处理★唯一约束和查询过滤★ │
│ JSONField vs 建表 │ 灵活 vs ★可索引、可约束★ │
│ 冗余字段 vs JOIN │ 读快 vs ★一致性维护成本★ │
│ 自增 ID vs UUID │ 有序紧凑 vs ★不可枚举、可离线生成★ │
│ choices vs 外键表 │ 简单 vs ★可动态增删★ │
└────────────────────┴──────────────────────────────────────┘
★ 关于 related_name(★容易被忽略但很重要★):
✗ 不设 → 反向访问是 article_set(★不直观,多个外键指向同一模型时会冲突★)
✓ related_name="articles" → user.articles.all()
✓ ★related_name="+" 表示不需要反向关系★(省一点开销)
✓ related_query_name 控制反向查询时用的名字
★ 最后的检查:★让数据库帮你兜底★
Django 层的校验(clean、validators)★只在表单/序列化器里生效★
→ save() 不会自动调用 full_clean()
→ ★脚本、管理命令、批量导入、DRF 之外的入口都会绕过★
→ ★所以关键的业务规则一定要有数据库约束★
那个完整示例展示了一个设计良好的模型:枚举用 TextChoices、外键明确 on_delete 和 related_name、字符串用 blank、按查询模式建索引(含条件索引)、软删除配合条件唯一约束、状态一致性用 CheckConstraint。设计权衡上要清楚几组取舍:软删除需要额外处理唯一约束和查询过滤、JSONField 是「灵活」换「可索引可约束」、冗余字段是「读快」换「一致性维护成本」。related_name 容易被忽略但很重要——不设的话反向访问是不直观的 article_set,而且多个外键指向同一模型时会冲突。最后重申最关键的一条:Django 层的校验只在表单和序列化器里生效,save() 不会自动调用 full_clean()——脚本、管理命令、批量导入都会绕过它,所以关键的业务规则一定要有数据库约束兜底。
记忆钩子:「模型设计三个层次:字段类型、索引、约束。★最经典的区分是 null 和 blank★——★null=True 管数据库(列允许 NULL),blank=True 管校验层(表单/full_clean),后者完全不影响数据库★;铁律是★字符串字段用 blank 不用 null★(否则『空』有 ” 和 NULL 两种表示,查询要写 Q(x=”)|Q(x__isnull=True),而且唯一约束下多个 NULL 不算重复但多个 ” 算重复),★非字符串字段可选则要 null=True + blank=True★。字段选择:★金额必须 Decimal 不能 Float★(0.1+0.2≠0.3)、CharField 和 TextField 在 PG 上性能几乎一样、JSONField 灵活但★无法建普通索引也无法用约束★、★默认值传函数不加括号★(default=uuid.uuid4 而不是 uuid.uuid4(),否则所有行同一个值)、auto_now ★在 queryset.update() 时不更新★。索引:★推荐 Meta.indexes 而不是 db_index★(能建复合/条件/表达式索引);★核心是复合索引的最左前缀原则★——Index(fields=[‘a’,‘b’]) 加速 filter(a=) 和 filter(a=,b=) 但★加速不了 filter(b=)★,所以要按查询模式建;★Django 自动给 ForeignKey 建索引★;★别无脑加★(写入变慢+占空间,一表 5 个以内,用 explain 验证)。约束:★unique_together 已弃用,用 UniqueConstraint★——它支持★条件唯一 condition=Q(is_deleted=False)(软删除场景的刚需)★和表达式唯一(Lower(‘email’) 实现大小写不敏感);★CheckConstraint 把业务规则固化在数据库★,因为★Model.save() 不会自动调用 full_clean()★(只有 ModelForm/DRF 才触发),脚本和批量导入都会绕过 → ★数据库约束是最后一道防线★。外键 on_delete:★从属数据 CASCADE、重要引用 PROTECT、可选引用 SET_NULL★,注意 Django 的 CASCADE 是应用层实现(会加载对象触发信号,大批量删除很慢)。线上变更:★大表加索引会锁表★(PG 用 AddIndexConcurrently 且必须 atomic=False)、★加 NOT NULL 无默认值的字段要重写全表★(分三步:加可空 → 回填 → 加约束)、改名删字段要用★扩展-收缩模式★。」
七、常见误区与追问
- 误区:
null=True和blank=True差不多,加哪个都行。 它们作用在完全不同的层次:null=True是数据库层——生成的 DDL 里该列允许NULL;blank=True是校验层——影响 ModelForm、admin 和full_clean()是否要求这个字段必填,对数据库结构没有任何影响。只写blank=True而字段又没有默认值时,表单可以不填,但保存时数据库会报NOT NULL约束错误;只写null=True则数据库允许空,但表单仍然强制必填。正确组合是:字符串字段用blank=True(不加 null),非字符串的可选字段用null=True, blank=True。 - 误区:
CharField(null=True)更灵活,能区分「没填」和「填了空字符串」。 Django 官方明确建议避免在字符串字段上使用null=True,因为它会让「空」出现两种表示:""和NULL。后果是三重的:查询必须写Q(x="") | Q(x__isnull=True),漏掉一种就会静默丢数据;唯一约束的行为不一致——SQL 标准下多个NULL不算重复(可以有任意多行是 NULL),但多个""算重复(只能有一行),于是同一个约束在两种「空」下表现完全不同;排序和比较中 NULL 有特殊语义(exclude(x=1)不会包含x IS NULL的行)。所以除非确实需要区分这两种语义(罕见),一律用blank=True+ 默认空字符串。 - 误区:给每个可能查询的字段都加
db_index=True更保险。 索引不是免费的:每次 INSERT/UPDATE/DELETE 都要维护所有相关索引(写入变慢)、占用磁盘空间(大表的索引总量可能超过数据本身)、索引过多还会让查询优化器选错执行计划。而且很多索引根本用不上:区分度低的字段(is_active只有两个值)建索引意义不大(优化器可能干脆全表扫描);复合索引已经覆盖了最左前缀,再给第一个字段单独建索引就是浪费;Django 已经自动给ForeignKey建了索引,手动加db_index=True是重复的。正确做法是按实际的查询模式建索引(从慢查询日志和 debug-toolbar 出发),建完用explain()确认真的走了索引,并把单表索引数量控制在合理范围(经验值 5 个以内)。 - 误区:
Index(fields=["a", "b"])可以同时加速按 a 查和按 b 查。 复合索引遵循 最左前缀原则:(a, b)索引在物理上是「先按 a 排序、a 相同再按 b 排序」,所以它能加速filter(a=x)、filter(a=x, b=y)、以及filter(a=x).order_by("b"),但对只按b过滤的查询完全没用(就像字典按「姓+名」排序时,你没法快速找出所有名叫「明」的人)。所以索引的字段顺序必须匹配查询条件的组合方式:如果既有filter(a=)又有filter(b=),要么建两个索引,要么把更常用/区分度更高的放在前面。排序方向也要注意:Index(fields=["user", "-created"])才能高效支持filter(user=x).order_by("-created")——单列反向扫描多数数据库支持,但混合方向(a 升 b 降)必须在索引里显式指定。 - 误区:在模型的
clean()里写了校验,数据就一定符合规则。Model.save()不会自动调用full_clean()——这是 Django 一个反直觉但重要的设计。模型层的clean()、validators、choices校验只在 ModelForm、admin、DRF Serializer 的验证流程里被触发;而直接obj.save()、queryset.update()、bulk_create()、管理命令、数据迁移、批量导入脚本全都会绕过它们。所以只靠模型层校验,脏数据迟早会进来。正确的做法是双层防护:表单/序列化器层做友好的字段级校验(给用户清晰的错误提示),数据库层用CheckConstraint/UniqueConstraint兜底(任何写入路径都无法绕过)。代价是数据库约束违反时抛的是IntegrityError而不是ValidationError,需要在视图里捕获并转换成友好提示。 - 追问:软删除场景下的唯一约束该怎么处理? 这是
UniqueConstraint最典型的用武之地。问题是:如果email设了unique=True,用户 A 软删除(is_deleted=True)之后,新用户就再也不能用这个 email 注册了——因为那行数据还在表里,唯一约束依然生效。三种解法:① 条件唯一(推荐)——UniqueConstraint(fields=["email"], condition=Q(is_deleted=False), name="uniq_active_email"),只在未删除的记录之间要求唯一(PostgreSQL 生成部分索引,MySQL 8 不支持部分索引,需要用其他方案);② 把删除标记纳入唯一键——UniqueConstraint(fields=["email", "deleted_at"]),未删除时deleted_at为 NULL(多个 NULL 不算重复,所以只能有一行未删除的),已删除的行deleted_at各不相同;③ 软删除时改写字段(把 email 改成email+时间戳),简单但破坏了原始数据。同时别忘了查询侧也要统一过滤——通常配一个自定义 Manager 默认加上filter(is_deleted=False)(详见 Manager 专题)。 - 追问:外键的
on_delete各个选项该怎么选?为什么它是必填参数? Django 2.0 起on_delete成为必填参数,正是因为默认值(早期是CASCADE)太危险——删一个用户就悄悄删掉他所有的订单、评论、日志,往往不是本意。选择原则按「数据的从属关系」:真正从属的数据用CASCADE(订单删除时它的明细行没有独立存在的意义);重要的引用用PROTECT(订单引用用户——删用户时应该报错而不是把订单也删掉,ProtectedError会强迫你显式处理);可选的引用用SET_NULL(文章的分类被删了,文章还在,分类置空);SET_DEFAULT/SET(fn)用于「置为某个占位值」(如「已注销用户」);DO_NOTHING只在你自己用数据库层外键管理时用。还有个性能要点:Django 的CASCADE是在应用层实现的——它会把要删除的对象加载进内存、触发pre_delete/post_delete信号、再逐层删除,删除一个有几十万关联记录的对象可能极慢甚至内存溢出;大数据量场景要考虑用数据库层的ON DELETE CASCADE(配合DO_NOTHING)或分批删除。 - 追问:什么时候该用
JSONField,什么时候该老老实实建表? 判断标准是**「这个数据需不需要被查询、约束和统计」。适合JSONField的:第三方回调的原始报文(存档用,几乎不查)、日志的扩展上下文、用户自定义的表单字段(结构因租户而异)、A/B 实验的配置快照——它们的共同点是结构不固定、只按整体读写、不需要跨行统计**。应该建表的:任何需要filter/order_by/aggregate的字段、需要外键关联的字段、需要唯一或检查约束的字段、需要在 admin 里编辑的字段。三个具体代价要清楚:① 索引——JSONField无法建普通 B-Tree 索引,PostgreSQL 可以建 GIN 索引(支持包含查询但不支持范围和排序),MySQL 需要建虚拟列再索引;② 约束——数据库无法保证 JSON 内部结构,类型错误、字段缺失只能靠应用层发现;③ 演进——改结构时没有迁移机制,新旧格式会长期共存,代码里到处是data.get("k", 默认值)。折中方案是**「核心字段规范化 + 边缘属性放 JSON」**。
八、加强记忆
模型设计有三个层次:字段类型、索引、约束。****最经典的区分是 null 和 blank——null=True 管数据库(列允许 NULL),blank=True 管校验层(表单/full_clean),后者完全不影响数据库结构;铁律是字符串字段用 blank 不用 null(否则「空」有 "" 和 NULL 两种表示,查询要写 Q(x="") | Q(x__isnull=True),而且唯一约束下多个 NULL 不算重复但多个 "" 算重复),非字符串的可选字段则要 null=True, blank=True。字段选择:金额必须用 Decimal 不能用 Float(0.1+0.2 ≠ 0.3)、CharField 和 TextField 在 PostgreSQL 上性能几乎一样、JSONField 灵活但无法建普通索引也无法用约束保证结构、默认值传函数不加括号(default=uuid.uuid4 而非 uuid.uuid4(),否则所有行拿到同一个值)、auto_now 在 queryset.update() 时不会更新。索引:推荐用 Meta.indexes 而不是 db_index(支持复合、条件、表达式索引);核心是复合索引的最左前缀原则——Index(fields=["a","b"]) 能加速 filter(a=) 和 filter(a=, b=) 但加速不了 filter(b=),所以要按查询模式建;Django 会自动给 ForeignKey 建索引;别无脑加(写入变慢 + 占空间,单表控制在 5 个以内,用 explain() 验证)。约束:unique_together 已弃用,改用 UniqueConstraint——它支持条件唯一 condition=Q(is_deleted=False)(软删除场景的刚需) 和表达式唯一(Lower("email") 实现大小写不敏感);CheckConstraint 把业务规则固化在数据库,因为**Model.save() 不会自动调用 full_clean()(只有 ModelForm/DRF 才触发),脚本和批量导入都会绕过——数据库约束是最后一道防线。外键 on_delete:从属数据用 CASCADE、重要引用用 PROTECT、可选引用用 SET_NULL,注意 Django 的 CASCADE 是应用层实现的(会加载对象、触发信号,大批量删除很慢)。线上变更:大表加索引会锁表(PostgreSQL 用 AddIndexConcurrently 且必须 atomic = False)、加 NOT NULL 且无默认值的字段要重写全表(分三步:加可空 → 回填 → 加约束)、改名和删字段要用扩展-收缩模式**。