← 返回题目列表

Django 模型继承有哪几种?抽象基类、多表继承和代理模型怎么选?

中等 第 26 / 27 题 更新于 2026/08/02
Django模型继承抽象基类代理模型MTI

简化版

Django 有三种模型继承,区别全在「数据库里建几张表」① 抽象基类(abstract = True)——不建表,父类只是字段和方法的模板,子类各自建一张包含所有字段的完整表② 多表继承 MTI(父类是普通模型)——父子各建一张表,子表通过一个隐式的 OneToOneField(parent_link=True) 主键指向父表,访问子类时 Django 会自动 JOIN③ 代理模型(proxy = True)——完全不建表,和父类共用同一张表,只是换了一套 Python 行为(默认排序、Manager、方法)。选择原则很简单:绝大多数时候用抽象基类——它没有 JOIN 开销、没有额外的表,是「复用字段和方法」的正确工具;代理模型用于「同一份数据、不同视角」(比如在 admin 里注册两次,一个叫「全部订单」一个叫「待发货订单」);多表继承要谨慎——它带来的隐式 JOIN、级联删除的复杂性、以及「查父表拿不到子类类型」的问题,往往超过它的收益,大多数场景应该用「外键 + 组合」代替。三者还有个共同的关键点:只有抽象基类的 Meta 会被子类继承(子类可以 class Meta(Base.Meta) 显式继承并覆盖),而 abstract 属性不会被继承(子类默认是具体模型)。核心记忆:抽象基类=只复用代码不建表(首选)MTI=父子两张表+隐式 JOIN(慎用)代理模型=同表不同行为

详细版

三种继承对比

抽象基类多表继承(MTI)代理模型
声明abstract = True父类是普通模型proxy = True
父表不建✅ 建共用父表
子表含全部字段只含新字段
查询无 JOIN每次自动 JOIN
能加字段不能加字段
能改 Manager/排序
多态查询❌ 各表独立✅ 查父表能拿到全部——
推荐度⭐⭐⭐ 首选慎用⭐⭐ 特定场景
# ① ★抽象基类:只复用代码,不建表(★最常用★)★
class TimeStampedModel(models.Model):
    created = models.DateTimeField(auto_now_add=True, db_index=True)
    updated = models.DateTimeField(auto_now=True)

    class Meta:
        abstract = True                 # ★关键★
        ordering = ["-created"]
        get_latest_by = "created"

    def age_in_days(self):
        return (timezone.now() - self.created).days

class Article(TimeStampedModel):        # ★article 表含 created/updated★
    title = models.CharField(max_length=200)

class Comment(TimeStampedModel):        # ★comment 表也含 created/updated★
    body = models.TextField()
    class Meta(TimeStampedModel.Meta):  # ★★要继承 Meta 必须显式写★★
        ordering = ["created"]          # ★覆盖父类的排序★
        # abstract 不会被继承 → Comment 是具体模型 ✓

# ② ★多表继承(MTI):父子各一张表★
class Place(models.Model):
    name = models.CharField(max_length=100)
    address = models.CharField(max_length=200)

class Restaurant(Place):                # ★没写 abstract → MTI★
    serves_pizza = models.BooleanField(default=False)
    # ★Django 隐式添加:place_ptr = OneToOneField(Place, parent_link=True,
    #                                             primary_key=True)★

r = Restaurant.objects.create(name="小李", address="x", serves_pizza=True)
# ★SQL:INSERT INTO place ...; INSERT INTO restaurant (place_ptr_id, ...) ...★
Restaurant.objects.all()      # ★SELECT ... FROM restaurant INNER JOIN place ...★
Place.objects.all()           # ★只有 Place 的字段,拿不到 serves_pizza★
p = Place.objects.get(pk=r.pk)
p.restaurant                  # ★反向访问子类(不存在时抛 Restaurant.DoesNotExist)★

# ③ ★代理模型:同一张表,不同行为★
class OrderedArticle(Article):
    class Meta:
        proxy = True                    # ★关键:不建表★
        ordering = ["-views"]
    objects = HotArticleManager()       # ★可以换 Manager★
    def summary(self):                  # ★可以加方法★
        return self.title[:20]
    # ✗ ★不能加字段★(会报错)

# ★典型用途:admin 里注册两个入口★
class PendingOrder(Order):
    class Meta:
        proxy = True
        verbose_name = "待发货订单"
    objects = PendingOrderManager()     # get_queryset 过滤 status="pending"
admin.site.register(Order, OrderAdmin)
admin.site.register(PendingOrder, PendingOrderAdmin)   # ★同一张表,两个菜单★

# ④ ★组合(外键)——MTI 的替代方案(★常常更好★)★
class Place(models.Model):
    name = models.CharField(max_length=100)
class Restaurant(models.Model):
    place = models.OneToOneField(Place, on_delete=models.CASCADE,
                                 related_name="restaurant")
    serves_pizza = models.BooleanField(default=False)
# ★显式的关系 > 隐式的继承★:JOIN 什么时候发生一目了然

⚠️ 三个必须记住的点:① abstract = True 的类不会建表,也不能被实例化和查询——它纯粹是「字段 + 方法的模板」,子类会把它的字段原样复制到自己的表里。这是复用代码的首选方式TimeStampedModelSoftDeleteModel 这类基类几乎每个项目都有),因为它零运行时开销:没有 JOIN、没有额外的表、查询和普通模型完全一样。② 多表继承(MTI)的每次查询都会隐式 JOINRestaurant.objects.all() 实际执行的是 SELECT ... FROM restaurant INNER JOIN place ON ...,创建对象要插两张表,删除要级联删两张表——这些开销都是隐式的、看不见的。更麻烦的是**「查父表拿不到子类类型」Place.objects.all() 返回的全是 Place 实例,你不知道哪些其实是 Restaurant,要判断只能逐个试 hasattr(p, "restaurant")这就是 N+1)。所以大多数场景应该用显式的 OneToOneField 组合代替。③ 只有抽象基类的 Meta 会被继承,而且必须显式写 class Meta(Base.Meta)——如果子类自己定义了 class Meta 但没继承父类的,父类 Meta 里的 orderingindexes全部丢失**。另外 abstract 属性本身不会被继承(Django 特意这样设计),所以子类默认是具体模型;要让子类也保持抽象必须再写一次 abstract = True

完整版教学

一、抽象基类:复用代码的正确方式

★ 它做了什么:
  class TimeStampedModel(models.Model):
      created = models.DateTimeField(auto_now_add=True)
      class Meta:
          abstract = True
  class Article(TimeStampedModel):
      title = models.CharField(max_length=200)

  ★ 数据库里:
    ✗ 没有 timestampedmodel 表
    ✓ article 表 = ★id + created + title★(★字段被复制进来★)

  ★ 等价于:
    class Article(models.Model):
        created = models.DateTimeField(auto_now_add=True)
        title = models.CharField(max_length=200)
    → ★运行时零开销★

★ ★Meta 的继承规则(★最容易踩的坑★)★:
  ① 子类★没写 Meta★ → ★自动继承父类的 Meta★(但 abstract 变 False)
  ② 子类★写了 Meta 但没继承★ → ★父类 Meta 全部丢失★
     class Comment(TimeStampedModel):
         class Meta:                    # ✗ ★ordering 丢了★
             db_table = "my_comment"
  ③ 子类★显式继承★ → ★合并★
     class Comment(TimeStampedModel):
         class Meta(TimeStampedModel.Meta):    # ✓
             db_table = "my_comment"           # ordering 保留

  ★ ★abstract 特殊:永远不被继承★
    Django 会在创建子类时★强制把 abstract 设为 False★
    → 想让子类也抽象,必须★再写一次 abstract = True★

★ ★related_name 的冲突问题(★MTI 和抽象基类都会遇到★)★:
  class BaseModel(models.Model):
      author = models.ForeignKey(User, on_delete=models.CASCADE,
                                 related_name="articles")   # ✗ ★两个子类会冲突★
      class Meta: abstract = True
  class Article(BaseModel): pass
  class Comment(BaseModel): pass
  → ★ERROR: Reverse accessor clashes★(User.articles 指向谁?)

  ✓ 用 ★%(class)s / %(app_label)s 占位符★:
      related_name="%(app_label)s_%(class)s_set"
      related_query_name="%(class)s"
  → Article 生成 myapp_article_set,Comment 生成 myapp_comment_set

★ ★多重继承(Mixin 模式)★:
  class TimeStampedMixin(models.Model):
      created = models.DateTimeField(auto_now_add=True)
      class Meta: abstract = True
  class SoftDeleteMixin(models.Model):
      is_deleted = models.BooleanField(default=False)
      objects = SoftDeleteManager()
      class Meta: abstract = True
  class Article(TimeStampedMixin, SoftDeleteMixin):    # ★组合多个★
      title = models.CharField(max_length=200)

  ★ 注意:
    - ★字段同名会报错★(不像普通 Python 类那样按 MRO 覆盖)
    - ★Meta 只从第一个父类继承★(要合并得自己写)
    - ★Manager 的继承顺序按 MRO★

★ 什么时候用抽象基类:
  ✓ ★多个模型有相同字段★(created/updated、is_deleted、tenant_id)
  ✓ ★多个模型有相同方法★(get_absolute_url、序列化逻辑)
  ✓ ★想统一 Manager★(软删除、多租户)
  ✗ 需要"查询所有子类"→ ★抽象基类做不到(各表独立)★

抽象基类的字段是「复制」进子类表的,所以运行时零开销——查询和普通模型完全一样。它有两个坑。一是 Meta 的继承规则:子类没写 Meta 会自动继承写了但没显式继承会导致父类 Meta 全部丢失orderingindexes 都没了)、显式写 class Meta(Base.Meta) 才是合并;而且 abstract 属性永远不被继承(Django 强制把子类的设为 False),要让子类也抽象必须再写一次。二是 related_name 冲突——基类里的外键如果写死了 related_name="articles",两个子类会争抢同一个反向访问器而报错,正确做法是用 %(class)s%(app_label)s 占位符。多重继承(Mixin 模式)很常用,但要注意字段同名会直接报错(不像普通 Python 类那样按 MRO 覆盖)、Meta 只从第一个父类继承

二、多表继承(MTI)的真实代价

★ 数据库结构:
  class Place(models.Model):
      name = models.CharField(max_length=100)
  class Restaurant(Place):
      serves_pizza = models.BooleanField()

  ┌─────────── place ───────────┐   ┌──────── restaurant ────────┐
  │ id (PK) │ name              │   │ place_ptr_id (PK, FK) │ ... │
  │   1     │ 小李              │←──│        1              │ True│
  │   2     │ 公园              │   └────────────────────────────┘
  └─────────────────────────────┘
  ★ Django 隐式加了:
    place_ptr = OneToOneField(Place, on_delete=CASCADE,
                              parent_link=True, primary_key=True)
  ★ ★子表的主键就是父表的主键★(不是自增)

★ ★每个操作的隐藏成本★:
  ┌──────────────────┬────────────────────────────────────────┐
  │ Restaurant.objects.create() │ ★2 条 INSERT★(先父后子)      │
  │ Restaurant.objects.all()    │ ★INNER JOIN★                  │
  │ restaurant.save()           │ ★2 条 UPDATE★                 │
  │ restaurant.delete()         │ ★2 条 DELETE★                 │
  │ Place.objects.filter(...)   │ 只查 place 表(★拿不到子字段★)│
  │ ★bulk_create★               │ ★不支持!抛 ValueError★        │
  └──────────────────┴────────────────────────────────────────┘

★ ★最大的问题:多态查询的 N+1★
  places = Place.objects.all()          # 100 条
  for p in places:
      if hasattr(p, "restaurant"):      # ★每次一条 SQL!★
          print(p.restaurant.serves_pizza)
  → ★101 条查询★
  ✓ 缓解:select_related("restaurant")(★但要预先知道有哪些子类★)
  ✓ 或加一个 ★type 字段★手动记录类型
  ✓ 或用 django-polymorphic(★但它本质是自动帮你做上面这些★)

★ ★其他坑★:
  ① ★不能用 bulk_create★(要插两张表)
  ② ★父类的 Meta.ordering 会影响子类查询★
  ③ ★两个子类不能有同名字段和父类冲突★
  ④ ★删除父对象会级联删除子对象★(OneToOne + CASCADE)
  ⑤ ★迁移复杂★:给父类加字段影响所有子类的查询
  ⑥ ★子类实例的 pk 和父类实例相同★(容易混淆)
  ⑦ ★把 Place 转成 Restaurant 很别扭★
     r = Restaurant(place_ptr=p)   # ★需要 hack★
     r.__dict__.update(p.__dict__)
     r.save()

★ ★什么时候 MTI 才是对的★(★确实存在★):
  ✓ ★真正的 is-a 关系 + 需要统一查询父类★
    如:Payment ← AlipayPayment / WechatPayment
    需要"列出所有支付记录"(不管类型)
  ✓ ★子类字段很多且差异大★(不适合塞进一张宽表)
  ✓ ★父类要被外键引用★(Order.payment → Payment)
    ★这是 MTI 最有说服力的场景★:外键指向父类,
    实际对象可以是任意子类

★ ★替代方案对比★:
  ┌──────────────────────┬────────────────────────────────────┐
  │ ★组合(OneToOne)★    │ ★显式、JOIN 可控、可选关系★         │
  │ ★单表 + type 字段★    │ ★最快★,字段少时首选;★空列多★      │
  │ ★JSONField 存差异★    │ 灵活;★无法索引/约束(PG 可部分索引)★│
  │ ★抽象基类 + 各自建表★ │ 无 JOIN;★但不能统一查询★           │
  └──────────────────────┴────────────────────────────────────┘

MTI 的核心机制是「子表主键就是父表主键的 OneToOne」,Django 隐式添加了 place_ptr。它的隐藏成本很具体:创建插两张表、查询自动 JOIN、更新两条 UPDATE、删除两条 DELETE、而且完全不支持 bulk_create(直接抛 ValueError)。最大的问题是多态查询的 N+1——Place.objects.all() 返回的全是 Place 实例,判断某条是不是 Restaurant 只能 hasattr(p, "restaurant")每次触发一条 SQL,100 条数据就是 101 次查询。但 MTI 也确实有它对的场景真正的 is-a 关系 + 需要统一查询父类,尤其是父类要被外键引用时Order.payment → Payment,实际对象可以是任意子类)——这是 MTI 最有说服力的理由。其他情况应该考虑替代方案:组合(OneToOne)显式且 JOIN 可控单表 + type 字段最快(字段少时首选,代价是空列多)、JSONField 存差异(灵活但难索引)。

三、代理模型:同一份数据的不同视角

★ 它不改变数据库,只改变 Python 行为:
  class Order(models.Model):
      status = models.CharField(max_length=20)
      amount = models.DecimalField(max_digits=10, decimal_places=2)

  class PendingOrder(Order):
      class Meta:
          proxy = True                  # ★不建表★
          ordering = ["-created"]
          verbose_name = "待发货订单"
      objects = PendingOrderManager()   # get_queryset() 过滤 status="pending"
      def ship(self): ...               # ★可以加方法★
      # ✗ ★不能加字段(FieldError)★
      # ✗ ★不能有多个具体父类★

  PendingOrder.objects.all()    # ★SELECT * FROM order WHERE status='pending'★
  # ★与 Order.objects.filter(status='pending') 查的是同一张表★

★ ★三大用途★:
  ① ★admin 里的多个入口(★最常见★)★
     admin.site.register(Order, OrderAdmin)          # "订单"
     admin.site.register(PendingOrder, PendingAdmin) # ★"待发货订单"(另一个菜单)★
     admin.site.register(RefundOrder, RefundAdmin)   # "退款订单"
     ★ 同一张表,三个不同的列表页、不同的筛选和操作按钮★

  ② ★给第三方模型加方法(★不能改它的源码时★)★
     from django.contrib.auth.models import User
     class MyUser(User):
         class Meta:
             proxy = True
         def display_name(self):
             return self.get_full_name() or self.username
     ★ 不用改 django.contrib.auth,也不用改数据库

  ③ ★不同的默认排序/Manager★
     class NewestArticle(Article):
         class Meta:
             proxy = True
             ordering = ["-created"]

★ ★限制(★问到会加分★)★:
  ✗ ★不能加字段★(它没有自己的表)
  ✗ ★不能继承多个具体模型★(只能有一个非抽象父类)
  ✓ ★可以继承抽象基类★(拿方法,但不能带字段)
  ✓ ★可以有多个 proxy 指向同一个模型★
  ★ ★代理模型有自己的 ContentType★!
    → 权限是★独立★的(add_pendingorder ≠ add_order)
    → ★admin 权限要单独授予★(这既是特性也是坑)

★ ★对象身份问题(★容易踩★)★:
  o = Order.objects.get(pk=1)
  p = PendingOrder.objects.get(pk=1)
  o == p              # ★True★(Django 的 __eq__ 比较 pk + 具体模型类)
  # ★注意:Django 3.0+ 起,proxy 和父类被视为相同的具体模型 → 相等★
  type(o) is type(p)  # ★False★(类不同)
  ★ 保存 p 会更新同一行 —— ★它们就是同一条数据★

★ 代理 vs 自定义 Manager(★该用哪个★):
  只是想要一个"常用查询" → ★用 QuerySet 方法★
    Order.objects.pending()
  想要"独立的实体感"(admin 菜单、权限、不同方法集)→ ★用代理模型★
  ★ 判断:★需要在 admin 里单独出现吗?需要独立的权限吗?★

代理模型不碰数据库,只换一套 Python 行为。它有三大用途:① admin 里的多个入口(最常见——同一张订单表在后台变成「全部订单」「待发货订单」「退款订单」三个菜单,各有各的列表页、筛选和操作按钮);② 给第三方模型加方法(比如给 django.contrib.auth.User 加个 display_name(),不用改人家的源码也不动数据库);③ 不同的默认排序或 Manager。限制方面:不能加字段、不能继承多个具体模型,但可以继承抽象基类拿方法。一个容易被忽略的细节是代理模型有自己的 ContentType,所以权限是独立的add_pendingorderadd_order)——这既是特性(可以给不同角色分不同权限)也是坑(admin 里要单独授权)。判断该用代理模型还是自定义 Manager 很简单:「需要在 admin 里单独出现吗?需要独立的权限吗?」——只是想要一个常用查询就用 QuerySet 方法。

四、三者的选择决策

★ 决策树:
  你想解决什么问题?
   ├─ ★多个模型有相同的字段/方法★
   │   → ★抽象基类★(99% 的情况)
   ├─ ★同一份数据要有不同的行为/入口★
   │   → ★代理模型★
   ├─ ★不同类型的实体要能统一查询/被统一引用★
   │   ├─ 字段差异小(≤5 个)→ ★单表 + type 字段★
   │   ├─ 字段差异大 + 要被外键引用 → ★MTI★
   │   └─ 字段差异大 + 不需要统一查询 → ★抽象基类各自建表★
   └─ 只是想复用一段查询逻辑
       → ★自定义 QuerySet 方法(不是继承)★

★ ★"单表 + type 字段"方案(★被低估的选择★)★:
  class Payment(models.Model):
      TYPE_CHOICES = [("alipay", "支付宝"), ("wechat", "微信")]
      type = models.CharField(max_length=20, choices=TYPE_CHOICES)
      amount = models.DecimalField(...)
      # 各渠道特有字段(可空)
      alipay_trade_no = models.CharField(max_length=64, blank=True)
      wechat_transaction_id = models.CharField(max_length=64, blank=True)
      # 或统一放 JSONField
      extra = models.JSONField(default=dict)

  ★ 优点:★零 JOIN、bulk_create 可用、查询简单、迁移简单★
  ★ 缺点:★空列多、约束难写(不能"支付宝必填 trade_no")★
  ✓ 用 ★CheckConstraint 表达条件必填★:
    CheckConstraint(
        check=~Q(type="alipay") | ~Q(alipay_trade_no=""),
        name="alipay_requires_trade_no")
  ★ 字段差异 ≤5 个时,★这个方案通常比 MTI 好★

★ ★组合优于继承(Django 里同样成立)★:
  ✗ class Restaurant(Place):  ...                    # MTI
  ✓ class Restaurant(models.Model):
        place = models.OneToOneField(Place, on_delete=CASCADE)
  ★ 好处:
    - ★JOIN 什么时候发生一目了然★(要 select_related 才 JOIN)
    - ★关系可选★(一个 Place 可以没有 Restaurant)
    - ★可以有多个"侧面"★(Place 既是 Restaurant 又是 Museum?)
    - ★bulk_create 可用★
    - ★删除策略可控★(SET_NULL / PROTECT,不强制 CASCADE)

★ 常见项目里的实际组合:
  # 几乎每个项目都有的抽象基类
  class TimeStampedModel(models.Model):     # created/updated
      class Meta: abstract = True
  class SoftDeleteModel(models.Model):      # is_deleted + Manager
      class Meta: abstract = True
  class TenantModel(models.Model):          # tenant_id + 自动过滤
      class Meta: abstract = True

  class Article(TimeStampedModel, SoftDeleteModel):
      ...
  ★ ★这就是 Django 模型继承 90% 的正确用法★

★ 迁移注意:
  - ★把具体模型改成 abstract★ → 要删表、数据迁移(★很麻烦★)
  - ★加 proxy = True★ → ★不产生数据库变更★(但会生成一条迁移记录)
  - ★MTI 改成组合★ → 要重建关系(★通常需要写数据迁移★)
  ★ 结论:★继承方式一开始就要选对★,后期改代价大

决策树很清晰:想复用字段和方法就用抽象基类(99% 的情况);同一份数据要有不同行为或入口就用代理模型;不同类型的实体要统一查询或被统一引用时才考虑 MTI——而且字段差异 ≤5 个时,「单表 + type 字段」通常比 MTI 更好(零 JOIN、bulk_create 可用、迁移简单,代价是空列多,条件必填可以用 CheckConstraint 表达)。「组合优于继承」在 Django 里同样成立——用显式的 OneToOneField 代替 MTI,好处是 JOIN 什么时候发生一目了然、关系可选、可以有多个「侧面」、bulk_create 可用、删除策略可控(不强制 CASCADE)。实际项目里 90% 的正确用法就是几个抽象基类(TimeStampedModelSoftDeleteModelTenantModel)的组合。最后提醒一点:继承方式一开始就要选对——把具体模型改成抽象要删表加数据迁移、MTI 改成组合要重建关系,后期改动代价很大。

五、继承下的 Manager、Meta 与信号

★ Manager 的继承规则:
  ① ★抽象基类的 Manager 会被子类继承★
     class Base(models.Model):
         objects = MyManager()
         class Meta: abstract = True
     class Article(Base): pass
     Article.objects   # ★MyManager 实例★

  ② ★子类定义了自己的 Manager 就覆盖父类的★
  ③ ★MTI:子类继承父类的 Manager★(但作用在子类的 JOIN 查询上)
  ④ ★代理模型:可以有完全不同的 Manager★
  ★ 注意:★Manager 的继承顺序按 MRO★,多重继承时第一个父类优先

★ Meta 继承的完整规则:
  ┌──────────────────┬────────────────────────────────────────┐
  │ ★抽象基类★        │ ★子类继承 Meta(abstract 除外)★        │
  │                   │ 子类写了 Meta 但没继承 → ★父类 Meta 丢失★│
  │ ★MTI★            │ ★子类不继承父类 Meta★(除了 ordering 和  │
  │                   │ get_latest_by 会被继承)                │
  │ ★代理模型★        │ ★继承父类 Meta★                        │
  └──────────────────┴────────────────────────────────────────┘
  ★ MTI 下 db_table / unique_together 等★不会继承★(各表独立)

★ ★信号在继承下的行为(★面试常问★)★:
  @receiver(post_save, sender=Article)
  def handler(sender, instance, **kwargs): ...

  ① ★抽象基类★:
     信号要挂在★每个具体子类★上(抽象类不能当 sender)
     ✓ 用 class_prepared 或在 __init_subclass__ 里批量注册
     ✓ 或不指定 sender,在 handler 里判断 isinstance

  ② ★MTI★:
     Restaurant.objects.create() 会触发:
       ★Place 的 post_save★ + ★Restaurant 的 post_save★(★两次!★)
     ★ 这是很多人没意识到的
     ✓ 在 handler 里检查 sender 或用 raw/created 参数区分

  ③ ★代理模型★:
     ★信号的 sender 是父类★(因为 proxy 没有独立的表和实例类型?)
     ★ 实际:Django 会用★具体模型★作为 sender
     → 挂在 Order 上的信号,PendingOrder.save() ★也会触发★
     → 但挂在 PendingOrder 上的★不会被 Order.save() 触发★

★ ★ContentType 的差异★:
  ContentType.objects.get_for_model(Article)     # 抽象基类子类 → 各自独立
  ContentType.objects.get_for_model(Restaurant)  # MTI → ★独立的 ContentType★
  ContentType.objects.get_for_model(PendingOrder)# ★代理也有独立 ContentType!★
  ContentType.objects.get_for_model(PendingOrder, for_concrete_model=False)
  # ★for_concrete_model=False 才拿到代理自己的★
  ★ 影响:权限(add_xxx/change_xxx)、通用外键、admin 日志

★ ★权限的继承★:
  MTI:★父子各有一套权限★(add_place / add_restaurant)
  代理:★有自己的一套权限★(★Django 2.2+ 起会自动创建★)
  抽象:★只有子类有权限★

继承下有三块「隐藏行为」值得记住。Manager:抽象基类的 Manager 会被子类继承,子类定义了自己的就覆盖,代理模型可以完全换一套。Meta 继承规则各不相同——抽象基类是继承(但子类写了 Meta 不显式继承就会丢失)、MTI 下子类基本不继承父类 Meta(只有 orderingget_latest_by 例外)、代理模型继承。信号是最容易出问题的:抽象基类不能当 sender(要挂在每个具体子类上);MTI 下 Restaurant.objects.create() 会触发两次 post_save(Place 一次、Restaurant 一次)——这是很多人没意识到的;代理模型的信号用具体模型当 sender,所以挂在 Order 上的信号会被 PendingOrder.save() 触发,反之不成立。还有 ContentType代理模型也有独立的 ContentType(要用 for_concrete_model=False 才拿得到),这直接影响权限、通用外键和 admin 日志——Django 2.2+ 起会自动为代理模型创建权限

六、实践建议

★ 项目起步时的推荐结构:
  # core/models.py
  class TimeStampedModel(models.Model):
      created = models.DateTimeField(auto_now_add=True, db_index=True)
      updated = models.DateTimeField(auto_now=True)
      class Meta:
          abstract = True
          get_latest_by = "created"

  class UUIDModel(models.Model):
      id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
      class Meta: abstract = True

  class SoftDeleteModel(models.Model):
      is_deleted = models.BooleanField(default=False, db_index=True)
      deleted_at = models.DateTimeField(null=True, blank=True)
      all_objects = models.Manager()          # ★第一个:不过滤★
      objects = SoftDeleteManager()
      class Meta:
          abstract = True
          default_manager_name = "objects"

  # 业务模型
  class Article(TimeStampedModel, SoftDeleteModel):
      title = models.CharField(max_length=200)
  ★ ★这套组合覆盖了绝大多数项目需求★

★ 检查清单:
  □ ★基类是不是该加 abstract = True★(不加就变 MTI 了!)
  □ ★子类的 Meta 有没有显式继承父类 Meta★
  □ ★基类的外键 related_name 用了 %(class)s 吗★
  □ ★用 MTI 是因为真的需要多态查询,还是只是想复用字段★
  □ ★代理模型的权限在 admin 里授予了吗★
  □ ★MTI 下的信号会触发两次,处理了吗★
  □ ★需要 bulk_create 吗(MTI 不支持)★

★ ★"忘了写 abstract" 是真实事故★:
  class BaseModel(models.Model):      # ★忘了 abstract = True★
      created = models.DateTimeField(auto_now_add=True)
  class Article(BaseModel): ...
  → ★生成了 basemodel 表 + article 表 + 隐式 JOIN★
  → 上线后才发现,★改回抽象要写数据迁移★
  ✓ 写基类时★第一件事就是加 abstract = True★

★ 排查技巧:
  python manage.py sqlmigrate myapp 0001    # ★看实际建了什么表★
  python manage.py shell -c "
    from myapp.models import Article
    print(Article._meta.parents)            # ★MTI 的父类映射(空 = 不是 MTI)★
    print(Article._meta.proxy)              # 是否代理
    print(Article._meta.abstract)
    print([f.name for f in Article._meta.get_fields()])
  "
  print(Article.objects.all().query)        # ★看有没有 JOIN★

★ 一句话总结:
  ★"抽象基类复用代码不建表(首选)、代理模型换行为不换表(admin 多入口)、
    MTI 父子两张表带隐式 JOIN(慎用,多数场景用 OneToOne 组合或
    单表+type 字段代替);写基类时第一件事就是加 abstract = True。"★

推荐的项目结构就是几个抽象基类的组合(TimeStampedModel + UUIDModel + SoftDeleteModel),这套组合覆盖了绝大多数项目需求。检查清单里第一条是真实事故的来源基类忘了写 abstract = True 就变成了 MTI——数据库里会多出一张 basemodel 表、所有查询都带隐式 JOIN,而且上线后才发现的话,改回抽象要写数据迁移,代价很大。所以写基类时第一件事就是加 abstract = True。排查上有几个实用技巧:sqlmigrate 看实际建了什么表、Model._meta.parents(空字典说明不是 MTI)_meta.proxy_meta.abstract,以及 print(qs.query) 看有没有意外的 JOIN。

记忆钩子:「Django 三种模型继承,★区别全在『数据库里建几张表』★:★① 抽象基类(abstract=True)——不建表,字段被复制进每个子类的表★(★零运行时开销、首选、99% 的场景★);★② 多表继承 MTI(父类是普通模型)——父子各一张表,Django 隐式加 place_ptr = OneToOneField(parent_link=True, primary_key=True)★,★子表主键就是父表主键★;★③ 代理模型(proxy=True)——完全不建表,共用父表,只换 Python 行为★。MTI 的隐藏成本很具体:★创建插两张表、查询自动 INNER JOIN、更新/删除各两条、完全不支持 bulk_create(抛 ValueError)★,最大的问题是★多态查询 N+1★——Place.objects.all() 全是 Place 实例,判断是不是 Restaurant 只能 hasattr(p,‘restaurant’),★每次一条 SQL★;而且 ★MTI 下 create() 会触发两次 post_save★(父一次子一次)。★MTI 唯一有说服力的场景:父类要被外键引用(Order.payment → Payment,实际可以是任意子类)★,其余情况用 ★显式 OneToOne 组合★(JOIN 可控、关系可选、bulk_create 可用)或 ★单表+type 字段★(字段差异≤5 个时更好,条件必填用 CheckConstraint 表达)。代理模型三大用途:★admin 多入口(同一张表变成『全部订单/待发货/退款』三个菜单)★、★给第三方模型加方法(不改人家源码)★、换默认排序/Manager;★不能加字段★,但★有独立的 ContentType 和独立的权限★(2.2+ 自动创建)。三个易踩的坑:★① 忘写 abstract=True 就变成 MTI★(多一张表 + 隐式 JOIN,上线后改要写数据迁移——写基类第一件事就是加它);★② 子类写了 class Meta 但没显式继承 class Meta(Base.Meta) → 父类 Meta 全部丢失★,而且 ★abstract 属性永远不被继承★;★③ 基类外键的 related_name 会在两个子类间冲突★ → 用 ★%(class)s / %(app_label)s 占位符★。排查用 ★Model._meta.parents(空 = 不是 MTI)★ 和 print(qs.query) 看 JOIN。」

七、常见误区与追问

  • 误区:写个基类把公共字段放进去就行,abstract = True 加不加无所谓。 这是真实的线上事故来源。不加 abstract = True,Django 会把它当成一个普通的具体模型,于是变成了多表继承:数据库里会多出一张 basemodel 表,每个子类的每次查询都带隐式 INNER JOIN、每次创建插两张表、每次删除删两张表,而且从此不能用 bulk_create。更麻烦的是这些开销完全不可见——代码看起来一模一样,只有看 SQL 才能发现。而等到上线后想改回抽象基类,需要把父表的数据合并回子表、删掉父表、重建主键,得手写数据迁移。所以规矩很简单:写基类的第一件事就是加 abstract = True,写完用 python manage.py sqlmigrate 确认建了几张表。
  • 误区:子类里写 class Meta 时,父类 Meta 里的配置会自动保留。 不会——如果子类定义了自己的 class Meta没有显式继承,父类 Meta 里的 orderingget_latest_byindexespermissions全部丢失,而且没有任何警告。正确写法是 class Meta(TimeStampedModel.Meta):,这样才是「继承并覆盖」。补充两点:① 子类完全不写 Meta 时是自动继承的(这时反而没问题);abstract 属性是个例外——它永远不会被继承,Django 在创建子类时会强制把它设为 False,这是刻意设计(否则所有子类都变成抽象类就没法用了),所以想让子类也保持抽象必须再写一遍 abstract = True。另外注意 MTI 下的 Meta 继承规则又不一样:子类基本不继承父类 Meta,只有 orderingget_latest_by 会传递下去。
  • 误区:多表继承能让我「查父表就拿到所有子类对象」,很方便。 只对了一半。Place.objects.all() 确实能查到所有子类对应的行(因为它们在父表里都有记录),但返回的全是 Place 实例——你拿不到 serves_pizza,也不知道哪一条其实是 Restaurant。要判断类型只能逐个 hasattr(p, "restaurant") 或 try/except,而每一次这样的访问都是一条独立的 SQL——100 条数据就是 101 次查询,典型的 N+1。缓解手段有:select_related("restaurant", "museum")(但你必须预先知道有哪些子类,加新子类就得改查询)、自己维护一个 type 字段记录具体类型、或者上 django-polymorphic(它本质上就是自动帮你做这些,代价是每次查询会多发几条 SQL 来加载各子类的数据)。所以「多态查询」在 Django MTI 下从来不是免费的,要提前想清楚查询模式。
  • 误区:代理模型和自定义 Manager 是一回事,用哪个都行。 两者解决的问题不同。自定义 Manager/QuerySet 方法Order.objects.pending())解决的是「封装一段常用查询」——轻量、可链式组合、不影响任何其他东西。代理模型创造的是一个独立的「实体」:它有自己的类名、自己的 ContentType自己的一套权限add_pendingorderadd_order 是两个不同的权限,Django 2.2+ 会自动创建)、可以在 admin 里注册成独立的菜单项并配置完全不同的 list_display/list_filter/actions、还可以有自己的 get_absolute_url 和方法集。判断标准很实用:「它需要在 admin 里单独出现吗?需要独立的权限吗?需要一整套不同的行为吗?」——是就用代理模型,只是想少写一段 filter() 就用 QuerySet 方法。顺便一提,代理模型的权限独立性既是特性(可以让客服只能操作「待发货订单」而不能碰全部订单)也是坑(admin 里明明是同一张表,却要分别授权)。
  • 误区:MTI 里保存子类对象时,信号只会触发一次。 会触发两次——Restaurant.objects.create(...) 内部要先写父表 place 再写子表 restaurant,于是 post_save 会以 sender=Place 触发一次、以 sender=Restaurant 再触发一次。如果你的信号处理器不指定 sender(或者同时监听了父子两个模型),就会执行两遍——发两封邮件、写两条日志、扣两次库存。同样的问题也出现在 pre_savepost_delete 上。排查时的现象往往很迷惑:「我明明只保存了一次,为什么日志有两条?」解决办法是信号处理器里检查 sender 是不是你期望的那个具体模型,或者干脆只在最具体的子类上挂信号。顺带说,抽象基类则是另一个极端:抽象类不能作为 sender(它没有对应的模型类),所以要么挂在每个具体子类上,要么不指定 sender 然后在处理器里 isinstance 判断。
  • 追问:什么情况下 MTI 确实是最佳选择? 最有说服力的场景是**「父类需要被外键引用,而实际对象可以是任意子类」。比如 Order.payment = ForeignKey(Payment),而 PaymentAlipayPaymentWechatPaymentBankTransferPayment 三个子类,各自有完全不同的字段(支付宝的 trade_no、微信的 transaction_id、银行转账的 bank_name + receipt_image)。用 MTI 的话,外键干净地指向 Payment,数据库层面的引用完整性有保障,而且「列出某用户的所有支付记录」是一条简单查询。用组合的话,Order 就得有三个可空外键(或者用通用外键 GenericForeignKey,那会失去数据库级的外键约束)。次一级的理由是:子类字段很多且差异大(塞进一张宽表会有几十个空列)、需要在父类层面做统一的唯一约束。但即使在这些场景,也要提前接受它的代价:每次查询 JOIN、不能 bulk_create、信号触发两次、多态查询要额外处理。字段差异小于 5 个时,「单表 + type 字段 + CheckConstraint」几乎总是更好的选择**。
  • 追问:抽象基类里的外键为什么会报 related_name 冲突?怎么解决? 因为抽象基类的字段是复制到每个子类的。假设基类写了 author = ForeignKey(User, related_name="articles"),那么 ArticleComment 两个子类都会各自创建一个 related_name="articles" 的反向访问器——User.articles 到底该指向文章还是评论?Django 在系统检查阶段就会报 Reverse accessor clashes 错误。解决办法是用 Django 提供的占位符related_name="%(app_label)s_%(class)s_set"related_query_name="%(class)s"——%(class)s 会被替换成子类的小写类名、%(app_label)s 替换成 app 名,于是生成 myapp_article_setmyapp_comment_set,互不冲突。这个规则对 ForeignKeyOneToOneFieldManyToManyField 都适用。顺带一提,MTI 也可能遇到类似问题(如果多个子类继承同一个父类且父类有外键),但更常见的是抽象基类场景,因为抽象基类天生就是要被多个模型复用的。
  • 追问:怎么快速判断一个已有模型用的是哪种继承?Model._meta 的几个属性最直接。Model._meta.abstractTrue 说明是抽象类(它不会有表)。Model._meta.proxyTrue 说明是代理模型,同时 Model._meta.proxy_for_model 会告诉你代理的是谁。Model._meta.parents 是关键——它是一个 {父模型: parent_link 字段} 的字典,非空就说明是 MTI(抽象基类的子类这里是空的,因为字段是复制过来的、没有父模型关系)。还有 Model._meta.concrete_model(代理模型指向它代理的具体模型)和 Model._meta.db_table(代理模型的表名和父类相同)。更直观的验证方式是看 SQL:python manage.py sqlmigrate <app> 0001 能看到实际建了几张表;print(Model.objects.all().query) 能看到查询里有没有 INNER JOIN——如果一个你以为是抽象基类的模型,查询里出现了 JOIN,那就是忘写 abstract = True

八、加强记忆

Django 三种模型继承,区别全在「数据库里建几张表」① 抽象基类(abstract = True)——不建表,字段被复制进每个子类自己的表零运行时开销,是首选、覆盖 99% 的场景);② 多表继承 MTI(父类是普通模型)——父子各建一张表,Django 隐式添加 place_ptr = OneToOneField(parent_link=True, primary_key=True)子表的主键就是父表的主键③ 代理模型(proxy = True)——完全不建表,共用父表,只换一套 Python 行为。MTI 的隐藏成本很具体:创建插两张表、查询自动 INNER JOIN、更新和删除各两条 SQL、完全不支持 bulk_create(抛 ValueError,最大的问题是多态查询的 N+1——Place.objects.all() 返回的全是 Place 实例,判断是不是 Restaurant 只能 hasattr(p, "restaurant")每次触发一条 SQL;而且 MTI 下 create() 会触发两次 post_save(父一次、子一次),信号处理器不加判断就会执行两遍。MTI 唯一有说服力的场景是「父类要被外键引用,而实际对象可以是任意子类」Order.payment → Payment),其余情况应该用显式的 OneToOneField 组合(JOIN 可控、关系可选、bulk_create 可用、删除策略自由)或单表 + type 字段(字段差异 ≤5 个时更好,条件必填用 CheckConstraint 表达)。代理模型有三大用途:admin 多入口(同一张订单表变成「全部订单/待发货/退款」三个菜单)、给第三方模型加方法(不改人家源码也不动数据库)、换默认排序或 Manager;它不能加字段,但有独立的 ContentType 和独立的权限(Django 2.2+ 自动创建)。三个最容易踩的坑:① 忘写 abstract = True 就变成了 MTI(多一张表加隐式 JOIN,上线后再改要写数据迁移——所以写基类的第一件事就是加它);② 子类写了 class Meta 但没显式继承 class Meta(Base.Meta) 会让父类 Meta 全部丢失,而且 abstract 属性永远不被继承③ 基类里外键的 related_name 会在多个子类间冲突,必须用 %(class)s / %(app_label)s 占位符。排查时用 Model._meta.parents(空字典说明不是 MTI)print(qs.query) 看有没有意外的 JOIN。