EF Core 两种继承映射:每层次一张表(TPH)与每类型一张表(TPT)

导语 接着上一章的内容,业务里「史诗 / 问题 / 任务」都是工作项,代码用继承很自然,数据库却没有「类」的概念。本文基于 MyBords 看板项目,讲清 EF Core 如何将 C# 继承落地为数据库表结构,深度对比 TPH / TPT 核心区别、选型场景,并提供可直接复用的项目重构实战代码。

在面向对象开发中,继承是梳理共性字段 + 差异化字段的最优方案。以我们的 MyBords 工作项看板项目为例:

  • 所有工作项(史诗、问题、任务)都包含:区域、迭代路径、优先级等公共字段

  • 史诗(Epic)独有:起止日期

  • 问题(Issue)独有:预估工作量

  • 任务(Task)独有:活动类型、剩余工作量

C# 中可以通过「基类+派生类」优雅实现复用,但关系型数据库只有表、字段、外键,没有面向对象的「继承、类层次」概念。

EF Core 继承映射的核心作用:打通 C# 类继承体系与数据库表结构,自动将面向对象的层次关系,翻译成数据库可识别的表结构。

本文重点拆解企业开发中最常用的两种映射策略:

策略 全称 核心概述
TPH Table Per Hierarchy(每层次一张表) 整个继承树共用一张表,通过鉴别列区分不同子类类型(EF Core 默认方案)
TPT Table Per Type(每类型一张表) 基类一张表、每个派生类独立一张表,通过主键外键关联拼接完整数据

> 补充:还有 TPC(每具体类型一张表),日常开发极少使用,本文不做展开。


一、核心认知:TPH 与 TPT 表结构差异

为方便零基础理解,我们以通用模型举例:抽象基类 BaseType,包含两个派生类 ChildA、ChildB,各自拥有独有扩展属性。

1.1 TPH:每层次一张表(EF 默认)

TPH 是 EF Core 默认开启的继承映射策略,无需手动配置。

核心特性
  • 整个继承体系只生成一张数据库表

  • 单表整合:基类公共属性 + 所有派生类独有属性

  • 所有子类数据共享同一套主键

  • EF 自动生成隐藏列 Discriminator(鉴别器) ,用于标记当前行属于哪个子类,该字段非空、唯一必填

通俗理解

一张「超级宽表」,用 Discriminator 类型标签区分数据类型,ORM 读取时自动根据标签还原为对应的 C# 子类对象。

TPH 空值核心规则(新手高频踩坑点)
属性归属 数据库可空规则 详细说明
基类公共属性 遵循 C# 类型规则 普通值类型(int/decimal)非空,可空类型(int?)允许空
派生类独有属性 强制允许 NULL 非当前子类的行,该字段无任何值,哪怕 C# 是值类型,数据库也必须可空

这也是 TPH 表存在大量空列的核心原因,属于设计特性,而非 Bug。

1.2 TPT:每类型一张表(手动配置)

TPT 需要手动通过 Fluent API 指定表名,不会默认生效。

核心特性
  • 基类独立生成一张表,仅存储公共属性

  • 每个派生类独立生成一张表,仅存储自身独有扩展属性

  • 派生表主键与基表主键完全一致,同时作为外键关联基表,形成 1:1 关系

  • 查询子类完整数据时,数据库必须通过 JOIN 多表关联 拼接数据

通俗理解

表结构完全贴合 C# 继承层次,结构干净无冗余,但牺牲了查询性能,多表 JOIN 是主要性能开销。


二、技术选型:TPH 和 TPT 怎么选?

很多新手会误以为「TPT 表结构更干净,所以更好」,实际上选型不能只看表结构美观度,核心看业务场景和性能。

2.1 性能核心结论

  • TPH 性能更优:单表查询,无 JOIN 开销,读写效率高、内存占用更低

  • TPT 性能偏弱:子类查询必须多表 JOIN,是数据库性能瓶颈高发点

补充:现代 SQL Server 对空列的存储优化非常成熟,TPH 的空列几乎不会带来性能和存储压力,「空列多=卡慢」是错误直觉。

2.2 微软官方基准测试结论

微软官方曾做过标准化测试:7种子类类型,每种5000条测试数据,合计35000行数据。

测试结果:TPH 数据加载速度、内存分配均优于 TPT,批量查询、全量遍历场景优势明显。

2.3 最终选型对照表(可直接落地)

优先选择 TPH(默认) 优先选择 TPT
业务读多写少、频繁查询基类集合 子类字段差异极大,TPH 单表过宽、冗余严重
追求极简配置、高性能、低开销 团队有严格的表结构规范,要求类型与表一一对应
继承层次浅、子类独有字段少 可接受 JOIN 性能损耗,换取结构整洁度

实战黄金准则:开发默认无脑用 TPH,只有出现明确的表结构治理问题,且测试验证性能可接受后,再切换 TPT。


三、实战落地:MyBords 项目 TPH 重构

我们以看板项目的工作项模型为例,完成「单一胖实体」到「抽象基类+多子类继承」的重构,全程基于 TPH 模式。

3.1 重构前:臃肿的单一实体(痛点明显)

重构前所有类型的字段全部塞进 WorkItem 类,通过自定义 Type 字段区分类型,代码臃肿、语义混乱、无法约束字段归属。

cs 复制代码
public class WorkItem
{
    public int Id { get; set; }
    public string Area { get; set; }
    public string IterationPath { get; set; }
    public int Priority { get; set; } = 0;

    // Epic 独有字段
    public DateTime? StartDate { get; set; }
    public DateTime? EndDate { get; set; }

    // Issue 独有字段
    public decimal Efford { get; set; }

    // Task 独有字段
    public string Activity { get; set; }
    public decimal RemainingWork { get; set; }

    // 手动维护类型,极易出错
    public string Type { get; set; } 

    // 导航属性省略
}

3.2 重构后:抽象基类 + 三子类(标准继承)

业务中不存在「无类型的纯工作项」,因此将 WorkItem 定义为抽象类,禁止直接实例化,仅作为父类被继承。

cs 复制代码
// 抽象基类:所有工作项的公共父类
public abstract class WorkItem
{
    public int Id { get; set; }
    public string Area { get; set; }
    public string IterationPath { get; set; }
    public int Priority { get; set; } = 0;

    // 公共导航属性
    public List<Comment> Comments { get; set; } = new();
    public User Author { get; set; }
    public Guid AuthorId { get; set; }
    public List<Tag> Tags { get; set; }
    public Status Status { get; set; }
    public int StatusId { get; set; }
}

// 史诗工作项:独有起止时间
public class Epic : WorkItem
{
    public DateTime? StartDate { get; set; }
    public DateTime? EndDate { get; set; }
}

// 问题工作项:独有预估工作量
public class Issue : WorkItem
{
    public decimal Efford { get; set; }
}

// 任务工作项:独有活动类型、剩余工作量
public class Task : WorkItem
{
    public string Activity { get; set; }
    public decimal RemainingWork { get; set; }
}

重构优势:彻底删除手动Type 字段,由 EF Core 自带的 Discriminator 自动区分类型,语义清晰、无冗余。

3.3 DbContext 注册实体

同时注册基类和所有子类,TPH 模式下最终只会生成一张 WorkItems 表。

cs 复制代码
public DbSet<WorkItem> WorkItems { get; set; }
public DbSet<Issue> Issues { get; set; }
public DbSet<Epic> Epics { get; set; }
public DbSet<Task> Tasks { get; set; }

使用说明:

  • WorkItems:查询所有类型的工作项(全量数据)

  • Epics/Issues/Tasks:自动过滤对应子类数据,等价于类型筛选视图

3.4 完整 Fluent API 配置

cs 复制代码
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // Epic 实体配置
    modelBuilder.Entity<Epic>()
        .Property(x => x.EndDate)
        .HasPrecision(3);

    // Task 实体配置
    modelBuilder.Entity<Task>(eb =>
    {
        eb.Property(x => x.RemainingWork).HasPrecision(14, 2);
        eb.Property(x => x.Activity).HasMaxLength(200);
    });

    // Issue 实体配置
    modelBuilder.Entity<Issue>(eb =>
    {
        eb.Property(x => x.Efford).HasPrecision(5, 2);
    });

    // 基类 WorkItem 公共关系配置
    modelBuilder.Entity<WorkItem>(eb =>
    {
        // 关联状态表 一对多
        eb.HasOne(x => x.Status)
          .WithMany(x => x.WorkItems)
          .HasForeignKey(x => x.StatusId);

        eb.Property(x => x.Area).HasColumnType("nvarchar(100)");

        // 关联评论 一对多
        eb.HasMany(x => x.Comments)
          .WithOne(x => x.WorkItem)
          .HasForeignKey(x => x.WorkItemId);

        // 关联作者 一对多
        eb.HasOne(x => x.Author)
          .WithMany(x => x.WorkItems)
          .HasForeignKey(x => x.AuthorId);

        // 关联标签 多对多
        eb.HasMany(x => x.Tags)
          .WithMany(x => x.WorkItems)
          .UsingEntity<WorkItemTag>(
            w => w.HasOne(wt => wt.Tag)
                .WithMany()
                .HasForeignKey(wt => wt.TagId),
            w => w.HasOne(wt => wt.WorkItem)
                .WithMany()
                .HasForeignKey(wt => wt.WorkItemId),
            w =>
            {
                w.HasKey(wt => new { wt.TagId, wt.WorkItemId });
                w.Property(wt => wt.PublicationDate)
                 .HasDefaultValueSql("getutcdate()");
            });
    });

    // 其他实体配置省略...
}

3.5 迁移后数据库效果

执行迁移更新数据库后,最终效果:

  • 仅生成一张 WorkItems 数据表

  • 表中包含:公共字段 + Epic/Issue/Task 所有独有字段

  • 自动生成 Discriminator 鉴别列,存储子类名称区分类型

  • 子类独有字段全部自动允许 NULL,符合 TPH 规则


四、TPT 模式简单适配对照

如果业务需要切换为 TPT 多表模式,只需简单配置指定每个实体对应独立数据表即可,无需修改实体类结构。

cs 复制代码
// 基类单独建表
modelBuilder.Entity<WorkItem>().ToTable("WorkItems");
// 各子类独立建表
modelBuilder.Entity<Epic>().ToTable("Epics");
modelBuilder.Entity<Issue>().ToTable("Issues");
modelBuilder.Entity<Task>().ToTable("Tasks");

配置后特性:

  • WorkItems:仅存储公共字段

  • 各子类表:仅存储独有字段,主键关联基表

  • 查询子类数据必须 JOIN 基表,性能下降,结构更规整


五、全文总结

  1. TPH(默认推荐):单表存储整个继承体系,自带 Discriminator 鉴别列,配置简单、查询无 JOIN、性能最优,唯一缺点是存在少量空列,不影响实际使用。

  2. TPT(按需选用):类型与表一一对应,结构贴合面向对象设计、干净无冗余,但多表 JOIN 会带来性能损耗。

  3. 重构核心:将臃肿单实体拆分为「抽象基类+派生子类」,用 EF 自动鉴别器替代手动类型字段,代码更规范、业务语义更清晰。

  4. 最终选型:90% 以上业务场景优先 TPH,结构美观需服从性能优先原则。


上一章节:从零理解 EF Core:用 Minimal API 给「工作项看板」建模

相关推荐
小马同学-2 小时前
Redis消息队列与客户端编程
数据库·redis
for_ever_love__2 小时前
MySQL 高频面试题 30 问:索引、事务、锁、日志、主从与分库分表(MySQL 8.0 版)
java·数据库·spring boot·mysql·spring cloud·面试·总结
染指11102 小时前
140.Agent-多Agent框架-Agent执行Skills流程
数据库·人工智能·设计模式·langchain·agents
fengkai45452 小时前
十二、Redis -2
运维·数据库·redis·容器
余槐i3 小时前
200万行表加索引锁死写入:PostgreSQL CONCURRENTLY 的三类等待与重建路径
数据库·postgresql·性能优化·索引·explain
小羊没烦恼!3 小时前
jQuery1.5的改进细节
java·服务器·开发语言·前端·c#
北龙云海3 小时前
AI驱动数据库运维变革:北龙云海智能巡检平台实践与展望
运维·数据库·人工智能