3.3 关系与引用完整性
外键不是为了让 ER 图好看,而是让“这条引用指向真实对象”在并发与所有写入入口下持续成立。建模还要回答可选性、基数、父子生命周期和删除后果;只写两列同名 ID 并没有建立关系。
3.3.1 一对一、一对多与多对多
关系基数要落成列、NOT NULL、UNIQUE 和 foreign key 的组合。
一对多:外键放在“多”侧
一个 customer 可以有零到多个 order,每个 order 必须属于一个 customer:
customer(customer_id PRIMARY KEY)
sales_order(
order_id PRIMARY KEY,
customer_id NOT NULL
REFERENCES customer(customer_id)
)NOT NULL 关闭“订单暂时没有客户”的可能,FK 关闭“客户 ID 不存在”的可能。父侧仍可以暂时没有订单;普通 FK 不保证至少存在一个子行。
一对一:外键再加唯一
假设每个 customer 至多有一份独立 profile:
CREATE TABLE customer_profile (
customer_id bigint PRIMARY KEY
REFERENCES shop.customer(customer_id),
...
);让 FK 本身成为子表主键即可保证每个 customer 最多一行 profile。若 profile 与 customer 总是同时创建、同生命周期且没有独立权限/更新原因,拆表反而可能增加 join 与一致性成本;“一对一”不是自动拆表指令。
多对多:关联关系也是事实
订单与商品看似多对多:一个 order 有多个 product,一个 product 出现在多个 order。sales_order_item 不是只有两列的机械桥表,因为这段关系还有自己的事实:
line_no
quantity
purchase-time SKU/name
purchase-time unit_price它是有属性的关联实体,主键为 (order_id, line_no),另用 product_id 指向当前商品身份。
如果只是“用户收藏商品”,关联表可能是:
PRIMARY KEY (customer_id, product_id)一旦需要收藏时间、来源、排序或状态,这些也是关系本身的属性。
可选性要说出业务含义
可空 FK 表示“关系可能不存在或未知”,两者不是同一语义。例如 payment 可以没有 provider reference 吗?
- 若记录代表已发送给 provider 的尝试,reference 应
NOT NULL; - 若要先创建本地 pending 请求,再异步获得 reference,需要单独状态和可空时点;
- 若 provider 永远不返回 reference,应使用另一稳定外部键,而不是把 NULL 解释成所有情况。
v0 选择 provider reference NOT NULL,把“尚未调用 provider”的命令放在 payment 行创建之前。这是范围选择,不是通用支付设计。
3.3.2 外键动作与生命周期
ON DELETE/ON UPDATE 不是语法偏好,而是父事实改变时子事实怎样存活。
| 动作 | 父行删除时 | 适用直觉 | 风险 |
|---|---|---|---|
NO ACTION | 默认在约束检查时拒绝 | 引用必须先处理;可与 deferred constraint 配合 | 名称易被误读为“什么都不做” |
RESTRICT | 立即拒绝相关删除 | 父子身份都应保留 | 清理必须显式按顺序 |
CASCADE | 自动删除子行 | 子行完全是父的组成部分 | 一次误删放大成整棵树 |
SET NULL | 清空可空 FK | 子事实可独立存活且“原父已无”有意义 | 丢失直接引用,列必须允许 NULL |
SET DEFAULT | 写入默认值 | 存在真实“默认父”且 FK 仍成立 | 默认哨兵行常掩盖业务错误 |
NO ACTION 是默认值,在可延迟约束中可以等到稍后检查;RESTRICT 不允许把该引用动作延后。v0 约束都是非 deferrable,本章仍显式写 RESTRICT,让生命周期决定可见。
v0 的动作说明
| 父 → 子 | 动作 | 业务理由 |
|---|---|---|
| customer → sales_order | ON DELETE RESTRICT | 历史订单不能因档案删除消失 |
| product → sales_order_item | ON DELETE RESTRICT | 订单保留对原商品身份的引用 |
| sales_order → sales_order_item | ON DELETE CASCADE | line 没有脱离 order 的独立身份 |
| sales_order → payment | ON DELETE RESTRICT | 支付尝试是审计/对账事实,不随订单静默删除 |
这里存在一个值得审查的张力:order line 级联、payment 限制,意味着有 payment 的 order 不能删除;没有 payment 的 order 删除会带走 line。若业务要求订单一经接受永不物理删除,可以把 order 删除权限整体收紧,而不依赖 FK 动作区分。
CASCADE 不能代替授权和保留政策。对根表执行一条 DELETE 前,仍要预览作用域、锁和子行数量。
更新动作
v0 使用 ON UPDATE RESTRICT,因为内部身份不应作为普通业务修改。业务键如 email、SKU、order_no 不承担外键,因此可以在各自规则下演进而不级联所有关系;历史快照保持原值。
FK 的物理边界
PostgreSQL 会为主键和 unique constraint 创建唯一 B-tree 索引,但不会自动为外键的引用列创建索引。删除或更新父行时,数据库需要在子表检查引用;大表缺少合适索引会产生昂贵扫描与锁等待。
本章只冻结逻辑 FK。ch09 会根据查询和父表变更路径设计引用侧索引;生产建模评审不能永远把它留空。
外键也会参与并发锁定。插入子行和删除父行竞争时,正确性由 PostgreSQL 保证,但延迟、死锁顺序和批量操作仍需设计。
3.3.3 聚合边界与跨表不变量
聚合边界回答“哪些事实必须在一个命令和事务里一起保持一致”。这里不要求套用某种领域驱动设计术语,而是给事务边界一个业务理由。
order 与 line
下单命令至少涉及:
- 创建 order 头;
- 创建一到多条 line;
- 固化商品标识、名称和单价快照;
- 计算请求 fingerprint;
- 将 order 置为已接受初始状态。
这些动作应在同一事务内完成。外键保证 line 不会指向不存在的 order,但它不能保证每个 order 至少有一条 line。可行策略是:
- 先以
draft状态创建不完整 order; - 插入 line;
- 在同一事务中验证至少一行;
- 只有验证通过才转为
placed; - 对外查询不把 draft 当已接受订单。
状态值和转换机制在后续闭合。
payment 是相邻边界
支付 provider 是外部系统,网络调用不能加入 PostgreSQL 本地事务。订单与支付记录通过 order_id 关联,但“外部扣款 + 本地状态”需要幂等、重试和对账,而不是保持一个数据库事务数秒等待第三方。
本地可以原子记录一次 provider 响应并更新订单状态;若提交结果未知,依赖 provider reference 与幂等键恢复。资金真相还要与 provider 对账。
跨表不变量要有检测查询
即使暂时没有强制机制,也要能发现违反:
SELECT
order_id,
order_status,
item_count,
item_subtotal,
captured_amount
FROM shop_api.order_summary
WHERE (order_status = 'paid' AND item_count = 0)
OR (order_status = 'paid' AND captured_amount <> item_subtotal)
ORDER BY order_id;基线应返回零行。review.sql 在事务内插入一个没有 line/payment 的 paid order,上述查询就能发现它,然后回滚。
检测查询不是强制约束。它缩短发现时间,却仍允许错误状态短暂或永久存在。对“绝不能提交”的规则,应设计原子命令、锁与约束/触发器;对外部最终一致规则,则定义容忍窗口、告警和补偿。
不把总额重复写进 order v0
item_subtotal 可由 line 快照的 unit_price * quantity 推导。若 v0 又在 order 保存 total,就产生两个可独立更新的事实。没有性能证据和同步责任前,view 按需计算。
未来若订单金额是法律/支付契约中的独立快照,可能需要保存经明确舍入、币种和折扣规则计算的 accepted total。那时它不是随便的缓存,而是新的权威事实;ch04 的金额决策必须先完成。
聚合评审表
| 规则 | 单表约束 | 本地事务 | 外部协调 |
|---|---|---|---|
| quantity > 0 | 是 | 不需要额外 | 否 |
| line 引用真实 product | FK | 不需要额外 | 否 |
| order 至少一行后才 placed | 否 | 是 | 否 |
| paid 金额等于 accepted total | 否 | 是 | provider 对账 |
| provider 实际扣款一次 | 否 | 本地只能记账 | 是 |
本节验收
- 每条关系都写出基数、可选性和父子生命周期;
- 一对一用 unique FK 表达,而不是双方互相引用;
- 多对多关联的自身属性没有塞回任一父表;
- 每个 FK 动作都有业务理由;
- 知道引用侧索引不会由 FK 自动创建;
- 跨表不变量有检测查询、强制层和外部协调边界。
参考资料
上一节:标识、主键与业务键 · 返回本章目录 · 下一节:模式、所有权与对象边界 · 查看全书目录 · 查看索引中心