导语 接着上一章的内容,业务里「史诗 / 问题 / 任务」都是工作项,代码用继承很自然,数据库却没有「类」的概念。本文基于 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 基表,性能下降,结构更规整

五、全文总结
-
TPH(默认推荐):单表存储整个继承体系,自带 Discriminator 鉴别列,配置简单、查询无 JOIN、性能最优,唯一缺点是存在少量空列,不影响实际使用。
-
TPT(按需选用):类型与表一一对应,结构贴合面向对象设计、干净无冗余,但多表 JOIN 会带来性能损耗。
-
重构核心:将臃肿单实体拆分为「抽象基类+派生子类」,用 EF 自动鉴别器替代手动类型字段,代码更规范、业务语义更清晰。
-
最终选型:90% 以上业务场景优先 TPH,结构美观需服从性能优先原则。