跳到主要内容

继承

在业务模型中,我们经常遇到“某个类型是另一个类型的特殊形式”。例如,员工和客户都是人员;电子商品和实体商品都是商品。这在面向对象语言中通常通过类继承表达,但关系型数据库存储的是表、行和约束,并没有一套所有产品都通用的类继承机制。

在数据库设计中谈“继承”,通常包含两个不同问题:

  1. 如何用普通关系表表达父类型与子类型;
  2. 是否使用某个数据库提供的原生表继承功能。

这两者不能混为一谈。大多数项目使用普通表建模就足够了;PostgreSQL 等数据库的原生表继承属于产品特性,需要了解其边界后再选择。

示例:不同类型的员工​

假设系统中所有员工都有这些公共字段:

  • 员工编号;
  • 姓名;
  • 入职日期。

正式员工还有月薪,合同工还有时薪和合同结束日期。业务上可以说“正式员工是员工”“合同工也是员工”。接下来看看关系数据库中常见的三种表达方式。

方案一:单表继承​

单表继承把所有类型放在一张表中,用类型列进行区分:

CREATE TABLE employees (
id INTEGER PRIMARY KEY,
employee_type VARCHAR(20) NOT NULL,
name VARCHAR(50) NOT NULL,
hired_on DATE NOT NULL,
monthly_salary DECIMAL(12, 2),
hourly_rate DECIMAL(10, 2),
contract_end DATE,
CHECK (employee_type IN ('permanent', 'contract'))
);

查询所有员工非常简单:

SELECT id, employee_type, name, hired_on
FROM employees;

查询合同工也只需要过滤类型:

SELECT id, name, hourly_rate, contract_end
FROM employees
WHERE employee_type = 'contract';

优点​

  • 查询所有类型不需要连接或合并多张表;
  • 主键、唯一约束和外键容易统一维护;
  • 新增记录通常只涉及一次插入。

局限​

  • 不属于某些类型的列会出现大量 NULL;
  • 类型越多,表越宽,约束越难理解;
  • 需要额外约束保证字段组合正确。

例如,正式员工应填写月薪但不应填写时薪,可以增加 CHECK:

ALTER TABLE employees
ADD CONSTRAINT employees_type_fields_check
CHECK (
(
employee_type = 'permanent'
AND monthly_salary IS NOT NULL
AND hourly_rate IS NULL
AND contract_end IS NULL
)
OR
(
employee_type = 'contract'
AND monthly_salary IS NULL
AND hourly_rate IS NOT NULL
AND contract_end IS NOT NULL
)
);

类型较少、差异字段不多,而且经常统一查询时,单表方案通常最直接。

方案二:父表与子表一对一​

也可以把公共字段放在父表,每种子类型放在独立表中。子表主键同时作为指向父表的外键:

CREATE TABLE employees (
id INTEGER PRIMARY KEY,
name VARCHAR(50) NOT NULL,
hired_on DATE NOT NULL
);

CREATE TABLE permanent_employees (
employee_id INTEGER PRIMARY KEY,
monthly_salary DECIMAL(12, 2) NOT NULL,
FOREIGN KEY (employee_id) REFERENCES employees (id)
);

CREATE TABLE contract_employees (
employee_id INTEGER PRIMARY KEY,
hourly_rate DECIMAL(10, 2) NOT NULL,
contract_end DATE NOT NULL,
FOREIGN KEY (employee_id) REFERENCES employees (id)
);

查询合同工需要连接:

SELECT
e.id,
e.name,
e.hired_on,
c.hourly_rate,
c.contract_end
FROM employees AS e
JOIN contract_employees AS c
ON c.employee_id = e.id;

优点​

  • 公共字段只存一份;
  • 每个子表只包含适用于该类型的字段;
  • 可以用普通主键、外键和唯一约束维护关系;
  • 新增一种子类型时,不必给公共表增加许多可空列。

局限​

  • 读取完整子类型记录通常需要 JOIN;
  • 创建子类型记录需要同时写入父表和子表,通常应放在事务中;
  • 普通约束不能自动保证一个员工只出现在一种子表中,也不能保证每个员工必定属于某个子类型。

如果“必须且只能属于一种子类型”是重要规则,可以增加类型列并通过触发器等数据库能力维护,或让应用在事务中校验。具体方案要考虑并发写入,不能只做一次无锁查询后就假定规则成立。

这种设计常被称为类表继承、连接表继承或父子表映射。名称在 ORM 和资料中可能不同,重点是理解它由普通的一对一关系组成。

方案三:每个具体类型一张完整表​

还可以让每个具体类型独立保存公共字段:

CREATE TABLE permanent_employees (
id INTEGER PRIMARY KEY,
name VARCHAR(50) NOT NULL,
hired_on DATE NOT NULL,
monthly_salary DECIMAL(12, 2) NOT NULL
);

CREATE TABLE contract_employees (
id INTEGER PRIMARY KEY,
name VARCHAR(50) NOT NULL,
hired_on DATE NOT NULL,
hourly_rate DECIMAL(10, 2) NOT NULL,
contract_end DATE NOT NULL
);

查询所有员工时,需要合并结果:

SELECT id, name, hired_on, 'permanent' AS employee_type
FROM permanent_employees

UNION ALL

SELECT id, name, hired_on, 'contract' AS employee_type
FROM contract_employees;

优点​

  • 每张表只保存一种完整实体,没有无关的可空列;
  • 查询单个具体类型时不需要连接;
  • 不同类型可以有差异很大的结构和生命周期。

局限​

  • 公共字段定义重复,修改时要同步多张表;
  • 查询所有类型需要 UNION ALL;
  • 全局唯一的员工编号、邮箱等约束难以跨多张表维护;
  • 其他表很难用一个普通外键引用“任意类型的员工”。

当各具体类型很少被统一查询,也几乎不需要公共关系时,这种方案才更合适。

三种普通表方案如何选择​

关注点单表父表加子表每个具体类型一张表
查询全部类型最简单查询父表即可获得公共字段需要 UNION ALL
查询完整子类型直接过滤通常需要 JOIN直接查询
可空列可能较多较少较少
公共约束容易维护容易放在父表维护跨表维护困难
新增子类型可能要增加列新增子表新增完整表
适合场景类型少、差异小公共关系明确、差异较大类型高度独立

选择时应从查询方式、约束和数据生命周期出发,而不是只追求和程序中的类层次“一模一样”。程序对象可以通过映射层组装,不要求数据库结构逐字复制类结构。

PostgreSQL 的原生表继承​

PostgreSQL 提供 INHERITS 语法,可以让子表继承父表的列和部分约束。下面的内容是 PostgreSQL 特性,不是通用 SQL。

先创建父表:

CREATE TABLE employees (
id BIGINT NOT NULL,
name TEXT NOT NULL,
hired_on DATE NOT NULL
);

再创建两个继承表:

CREATE TABLE permanent_employees (
monthly_salary NUMERIC(12, 2) NOT NULL
) INHERITS (employees);

CREATE TABLE contract_employees (
hourly_rate NUMERIC(10, 2) NOT NULL,
contract_end DATE NOT NULL
) INHERITS (employees);

子表会拥有从父表继承的 id、name 和 hired_on 列,也可以增加自己的列:

INSERT INTO permanent_employees (
id,
name,
hired_on,
monthly_salary
)
VALUES (1, 'Alice', DATE '2026-01-10', 18000.00);

查询父表时默认包含子表​

在 PostgreSQL 中,查询父表默认也会扫描后代表中的行:

SELECT id, name, hired_on
FROM employees;

这条查询会返回直接存放在 employees 中的行,也会返回 permanent_employees 和 contract_employees 中的行。

如果只想查询父表自身,使用 ONLY:

SELECT id, name, hired_on
FROM ONLY employees;

修改和删除父表时也要特别留意是否会影响后代表。ONLY 可以明确把操作限制在父表自身:

DELETE FROM ONLY employees
WHERE id = 1;

子表中的专有列不能直接通过父表查询得到,因为父表并不知道所有子表各自增加了哪些列。

原生表继承的重要限制​

原生继承看起来很像面向对象继承,但约束行为并不等价。设计前至少要了解以下限制。

唯一性和主键不会自动覆盖整个继承层次​

父表上的主键或唯一约束不能自然保证所有子表之间全局唯一。不同子表可能出现相同的 id。

这会让“所有员工编号都唯一”变得难以保证,也影响其他表通过普通外键引用整个员工层次。

外键不会把整个继承树当成一张表​

外键引用父表时,不应假定子表中的行会自动作为有效引用目标;反方向的引用和级联也有类似限制。需要跨整个继承层次维护引用完整性时,原生继承往往不是合适方案。

并非所有约束和结构都会继承​

非空约束和部分 CHECK 约束可以随列定义继承,但索引、唯一约束、主键和外键等不会像类成员一样完整传播。修改父表结构时,也应查阅当前 PostgreSQL 版本文档确认对子表的影响。

可移植性较差​

INHERITS 是 PostgreSQL 特性。迁移到 MySQL、SQLite 或其他数据库时,需要重新设计表结构和查询。许多 ORM 对它的支持也不如普通主外键模型成熟。

表继承不是声明式分区的替代品​

过去有人使用表继承配合约束和触发器手工实现分区。现代 PostgreSQL 已经提供声明式分区,专门处理按范围、列表或哈希拆分大表的场景。

如果目标是:

  • 按日期拆分订单;
  • 按地区拆分日志;
  • 自动将新记录路由到正确分区;
  • 统一管理分区约束和维护操作;

通常应优先评估 PARTITION BY,而不是从表继承手工搭建分区。继承表达的是表之间的结构关系,分区表达的是一张逻辑表的数据如何拆分,两者目的不同。

什么时候考虑 PostgreSQL 表继承​

只有在下面条件大多成立时,才值得认真评估原生表继承:

  • 项目确定长期使用 PostgreSQL;
  • 确实需要查询父表时自动包含多种子表;
  • 可以接受主键、唯一性和外键在继承层次上的限制;
  • 团队清楚 ONLY、约束传播和 ORM 支持情况;
  • 声明式分区或普通父子表不能更清晰地解决问题。

对多数业务系统而言,单表方案或“父表加一对一子表”更容易维护,也更容易让数据库完整执行约束。

常见误区​

数据库表必须复制程序类结构​

程序模型负责表达行为和类型,数据库模型负责持久化、查询和约束。两者关注点不同。可以让多个程序子类映射到同一张表,也可以由多张表组装成一个对象。

父表加子表就自动保证类型互斥​

普通外键只保证子表记录有对应父记录,不能自动保证父记录只出现在一个子表中。若互斥性重要,需要设计额外约束或事务逻辑。

PostgreSQL INHERITS 会继承一切​

它主要继承列和部分约束,不会自动形成覆盖整个继承树的主键、唯一约束和外键语义。使用前必须逐项验证所需完整性规则。

为了避免 NULL 就拆成很多表​

少量可空列不一定是问题。拆表会增加连接、写入事务和约束复杂度。应根据类型数量、字段差异、查询方式和变化频率综合选择。

小结​

  • 关系数据库中的“继承”首先是数据建模问题,不等同于类继承。
  • 常见映射方式包括单表、父表加一对一子表,以及每个具体类型一张完整表。
  • 选择方案时要优先考虑查询、约束、写入事务和数据生命周期。
  • PostgreSQL INHERITS 能让子表继承列并让父表查询默认包含子表,但它不是通用 SQL。
  • 原生表继承的主键、唯一约束和外键存在重要限制;数据分区通常应使用声明式分区。