← 返回题目列表

Django 模型字段怎么选?索引和约束该怎么加?

中等 第 17 / 27 题 更新于 2026/08/01
Django模型索引约束字段类型

简化版

Django 模型设计有三个层次的决策:字段类型、索引、约束。****字段类型最容易踩的是 nullblank 的区别——null=True 是「数据库层允许 NULL」,blank=True 是「表单/校验层允许留空」,两者独立;而且字符串字段不要用 null=True(Django 官方建议),因为那样「空」就有了 ""NULL 两种表示,查询和去重都会出问题。其他常见选择:金额必须用 DecimalField 而不是 FloatField(浮点数有精度误差)、CharFieldTextField 在 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+)

⚠️ 三个必须分清的点:① nullblank 是两个层次的东西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_togetherindex_together 已被弃用(分别由 UniqueConstraintMeta.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,金额计算会累积误差导致对账对不上(另一种做法是用整数存「分」)。CharFieldTextField 在 PostgreSQL 上性能几乎相同(PG 的 varchar 和 text 存储方式一样),选择依据主要是长度校验和表单控件;MySQL 上则有区别(TEXT 建索引要指定前缀长度)。时间字段有两个坑:auto_nowqueryset.update() 时不会更新(不走 save())、auto_now_add 之后无法修改——需要灵活控制就用 default=timezone.now不加括号)。JSONField(3.1+)适合日志、扩展属性、第三方回调原文,但无法建普通索引、无法用约束保证结构,核心业务字段仍应规范化。最后是可变默认值的坑(和 Python 的可变默认参数同源):default=uuid.uuid4() 加了括号会让所有行拿到同一个值,必须传函数对象 default=uuid.uuid4default=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)

nullblank 是 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_togetherindex_together 已被弃用,取代它们的 UniqueConstraint 功能强得多:条件唯一condition=Q(is_deleted=False)——软删除场景的刚需,删除后可以再注册同一个 email,这是 unique=True 做不到的)、表达式约束Lower("email") 实现大小写不敏感唯一)、以及 nulls_distinctCheckConstraint 把业务规则固化在数据库层,价值是即使有人绕过 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_deleterelated_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=Trueblank=True 差不多,加哪个都行。 它们作用在完全不同的层次null=True 是数据库层——生成的 DDL 里该列允许 NULLblank=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()validatorschoices 校验只在 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」**。

八、加强记忆

模型设计有三个层次:字段类型、索引、约束。****最经典的区分是 nullblank——null=True 管数据库(列允许 NULL),blank=True 管校验层(表单/full_clean),后者完全不影响数据库结构;铁律是字符串字段用 blank 不用 null(否则「空」有 ""NULL 两种表示,查询要写 Q(x="") | Q(x__isnull=True),而且唯一约束下多个 NULL 不算重复但多个 "" 算重复),非字符串的可选字段则要 null=True, blank=True。字段选择:金额必须用 Decimal 不能用 Float0.1+0.2 ≠ 0.3)、CharFieldTextField 在 PostgreSQL 上性能几乎一样、JSONField 灵活但无法建普通索引也无法用约束保证结构默认值传函数不加括号default=uuid.uuid4 而非 uuid.uuid4(),否则所有行拿到同一个值)、auto_nowqueryset.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 且无默认值的字段要重写全表(分三步:加可空 → 回填 → 加约束)、改名和删字段要用扩展-收缩模式**。