TypeORM实体继承:单表、类表和具体表继承的选择与实现
TypeORM 支持三种实体继承模式,名字听着抽象,但对应的是数据库设计里真实的问题:不同类型的数据,是放一张表还是分多张表?放一张表会有大量 NULL 列,分多张表 JOIN 查询变慢。这篇文章把三种模式的实现方式、优缺点、以及什么时候选哪种,都讲清楚。
三种模式对比
| 模式 | 表结构 | 查询性能 | NULL 列 | 适用场景 |
|---|---|---|---|---|
| 单表继承(STI) | 一张表,鉴别列区分类型 | 最好(无 JOIN) | 多 | 子类字段差异小 |
| 类表继承(CTI) | 父类一张 + 每个子类一张 | 中(需 JOIN) | 少 | 子类字段差异大,但有关联查询需求 |
| 具体表继承(CTI-var) | 每个子类独立一张表 | 差(UNION 查所有类型) | 无 | 子类完全独立,很少跨类型查询 |
单表继承(Single Table Inheritance)
所有子类存在同一张表里,用鉴别列(discriminator column)标识类型。子类独有的列在不对应的行里为 NULL。
实现
typescriptimport { Entity, PrimaryGeneratedColumn, Column, ChildEntity, DiscriminatorValue } from 'typeorm'; @Entity() @DiscriminatorColumn({ name: 'type' }) export class Content { @PrimaryGeneratedColumn() id: number; @Column() title: string; @Column({ type: 'text' }) body: string; } @ChildEntity() @DiscriminatorValue('article') export class Article extends Content { @Column({ nullable: true }) // 必须设 nullable: true author: string; @Column({ nullable: true }) category: string; } @ChildEntity() @DiscriminatorValue('video') export class Video extends Content { @Column({ nullable: true }) url: string; @Column({ nullable: true }) duration: number; }
生成的表结构:
shellcontent ┌────┬───────┬───────────┬────────┬──────┬──────────┬─────┬──────────┐ │ id │ type │ title │ body │ url │ duration │ author │ category │ ├────┼───────┼───────────┼────────┼──────┼──────────┼─────┼──────────┤ │ 1 │ article│ ... │ ... │ NULL │ NULL │ Alice│ tech │ │ 2 │ video │ ... │ ... │ a.mp4│ 600 │ NULL │ NULL │ └────┴───────┴───────────┴────────┴──────┴──────────┴─────┴──────────┘
查询方式
typescript// 查所有内容(不管类型) const all = await dataSource.getRepository(Content).find(); // 只查文章(TypeORM 自动加 WHERE type = 'article') const articles = await dataSource.getRepository(Article).find(); // 按 type 过滤 const videos = await dataSource.getRepository(Content) .find({ where: { type: 'video' } });
NULL 列问题
单表继承最大的问题:子类独有字段在不对应的行里全是 NULL。3 个子类各 5 个独有字段,表里就有 15 个可能为 NULL 的列。如果子类差异大,一张表 20+ 列一半是 NULL,可读性和索引效率都差。
适合场景:子类字段少、差异小。比如 User 分 Admin/Member,独有字段各 2-3 个。
类表继承(Class Table Inheritance)
父类一张表,每个子类各一张表,子类表通过外键关联父类表。没有 NULL 列问题,但查询需要 JOIN。
实现
typescript@Entity() @TableInheritance({ column: { type: 'varchar', name: 'type' } }) export class Person { @PrimaryGeneratedColumn() id: number; @Column() name: string; @Column() email: string; } @ChildEntity() export class Employee extends Person { @Column() position: string; @Column({ type: 'decimal', precision: 10, scale: 2 }) salary: number; } @ChildEntity() export class Customer extends Person { @Column() companyName: string; @Column({ default: 0 }) loyaltyPoints: number; }
生成的表结构:
shellperson employee customer ┌────┬───────┬──────┬───────────┐ ┌────┬──────────┬────────┐ ┌────┬─────────────┬───────────────┐ │ id │ type │ name │ email │ │ id │ position │ salary │ │ id │ companyName │ loyaltyPoints │ ├────┼───────┼──────┼───────────┤ ├────┼──────────┼────────┤ ├────┼─────────────┼───────────────┤ │ 1 │ employee│ Alice│ a@t.com│ │ 1 │ Engineer │ 80000 │ │ │ │ │ │ 2 │ customer│ Bob │ b@t.com│ │ │ │ │ │ 2 │ Acme Inc │ 100 │ └────┴───────┴──────┴───────────┘ └────┴──────────┴────────┘ └────┴─────────────┴───────────────┘
person.id 和 employee.id/customer.id 是同一个值——子类表的主键同时也是父类表的外键。
查询方式
typescript// 查所有人(TypeORM 自动 JOIN) const all = await dataSource.getRepository(Person).find(); // 只查员工(自动 JOIN person + employee) const employees = await dataSource.getRepository(Employee).find(); // 保存 const emp = new Employee(); emp.name = 'Alice'; emp.email = 'alice@test.com'; emp.position = 'Engineer'; emp.salary = 80000; await dataSource.getRepository(Employee).save(emp); // 自动向 person 和 employee 两张表插入数据
JOIN 的性能代价
每次查子类都要 JOIN 父类表。数据量大时 JOIN 比单表查询慢。但如果你的查询经常只查父类字段(如按 name 搜索),不需要 JOIN,直接查 person 表即可。
适合场景:子类字段差异大,且经常需要跨类型查询公共字段。
具体表继承(Concrete Table Inheritance)
每个子类一张独立的表,父类不建表。公共字段在每个子类表里重复。TypeORM 用 @Column() 在抽象类里定义,子类继承后各自建表。
实现
typescriptexport abstract class Payment { @PrimaryGeneratedColumn() id: number; @Column() amount: number; @Column() currency: string; @Column() createdAt: Date; } @Entity() export class CreditCardPayment extends Payment { @Column() cardNumber: string; @Column() expiryDate: string; } @Entity() export class BankTransferPayment extends Payment { @Column() bankName: string; @Column() accountNumber: string; }
生成的表结构:
shellcredit_card_payment bank_transfer_payment ┌────┬────────┬──────────┬──────┬───────────┬────────────┐ ┌────┬────────┬──────────┬──────┬──────────┬───────────────┐ │ id │ amount │ currency │ date │ cardNumber│ expiryDate│ │ id │ amount │ currency │ date │ bankName │ accountNumber │ └────┴────────┴──────────┴──────┴───────────┴────────────┘ └────┴────────┴──────────┴──────┴──────────┴───────────────┘
公共字段 amount、currency、createdAt 在每张表里都有。没有父类表,没有 JOIN,也没有 NULL 列。
查询所有支付类型
具体表继承最大的问题:查"所有类型的支付"需要 UNION:
typescript// TypeORM 不直接支持跨子类的 UNION 查询 // 需要手动查每张表再合并 const [creditCards, bankTransfers] = await Promise.all([ dataSource.getRepository(CreditCardPayment).find(), dataSource.getRepository(BankTransferPayment).find(), ]); const allPayments = [...creditCards, ...bankTransfers] .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
没有统一的 Repository 可以查所有子类——因为根本没有父类表。
适合场景:子类完全独立,几乎不需要跨类型查询。比如支付方式、通知渠道。
选择决策
shell子类独有字段多吗? ├─ 少(每个子类 2-3 个独有字段)→ 单表继承(STI) └─ 多(每个子类 5+ 个独有字段)→ 经常跨类型查询吗? ├─ 是 → 类表继承(CTI) └─ 否 → 具体表继承
迁移时的注意事项
- 单表继承加新子类:加列就行,
ALTER TABLE ADD COLUMN,不用建新表 - 类表继承加新子类:新建一张子类表,父类表不变
- 具体表继承加新子类:新建一张独立表
单表继承加子类最灵活——只加列,不动已有数据。类表和具体表继承加子类需要新表,但不会影响已有表结构。
如果未来子类数量不确定或可能频繁增加,优先选单表继承。