# 关系与引用完整性

LLMS 索引： [llms.txt](/llms.txt)

---

外键不是为了让 ER 图好看，而是让“这条引用指向真实对象”在并发与所有写入入口下持续成立。建模还要回答可选性、基数、父子生命周期和删除后果；只写两列同名 ID 并没有建立关系。

## 3.3.1 一对一、一对多与多对多 {#item-3-3-1}

关系基数要落成列、`NOT NULL`、`UNIQUE` 和 foreign key 的组合。

### 一对多：外键放在“多”侧

一个 customer 可以有零到多个 order，每个 order 必须属于一个 customer：

```sql
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：

```sql
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` 不是只有两列的机械桥表，因为这段关系还有自己的事实：

```text
line_no
quantity
purchase-time SKU/name
purchase-time unit_price
```

它是有属性的关联实体，主键为 `(order_id, line_no)`，另用 `product_id` 指向当前商品身份。

如果只是“用户收藏商品”，关联表可能是：

```sql
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 外键动作与生命周期 {#item-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 聚合边界与跨表不变量 {#item-3-3-3}

聚合边界回答“哪些事实必须在一个命令和事务里一起保持一致”。这里不要求套用某种领域驱动设计术语，而是给事务边界一个业务理由。

### order 与 line

下单命令至少涉及：

1. 创建 order 头；
2. 创建一到多条 line；
3. 固化商品标识、名称和单价快照；
4. 计算请求 fingerprint；
5. 将 order 置为已接受初始状态。

这些动作应在同一事务内完成。外键保证 line 不会指向不存在的 order，但它不能保证每个 order 至少有一条 line。可行策略是：

- 先以 `draft` 状态创建不完整 order；
- 插入 line；
- 在同一事务中验证至少一行；
- 只有验证通过才转为 `placed`；
- 对外查询不把 draft 当已接受订单。

状态值和转换机制在后续闭合。

### payment 是相邻边界

支付 provider 是外部系统，网络调用不能加入 PostgreSQL 本地事务。订单与支付记录通过 `order_id` 关联，但“外部扣款 + 本地状态”需要幂等、重试和对账，而不是保持一个数据库事务数秒等待第三方。

本地可以原子记录一次 provider 响应并更新订单状态；若提交结果未知，依赖 provider reference 与幂等键恢复。资金真相还要与 provider 对账。

### 跨表不变量要有检测查询

即使暂时没有强制机制，也要能发现违反：

```sql
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 自动创建；
- 跨表不变量有检测查询、强制层和外部协调边界。

## 参考资料

- [PostgreSQL 18：外键](https://www.postgresql.org/docs/18/ddl-constraints.html#DDL-CONSTRAINTS-FK)
- [PostgreSQL 18：外键动作](https://www.postgresql.org/docs/18/ddl-constraints.html#DDL-CONSTRAINTS-FK)
- [PostgreSQL 18：约束目录](https://www.postgresql.org/docs/18/catalog-pg-constraint.html)

---

[上一节：标识、主键与业务键](../02/) · [返回本章目录](../) · [下一节：模式、所有权与对象边界](../04/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
