# 标识、主键与业务键

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

---

“这个对象叫什么”没有一个通用答案。一行可能同时需要数据库内部引用、业务沟通、外部系统对账、请求去重和链路追踪标识。把它们都塞进一个 `id`，会让任何一次格式或业务规则变化沿所有外键扩散。

## 3.2.1 自然键、代理键与外部标识 {#item-3-2-1}

三类键各有职责：

| 类型 | 定义 | `pg36_shop` 例子 | 设计问题 |
|---|---|---|---|
| 自然/业务键 | 业务已经赋予的唯一事实 | SKU、order_no、customer_ref | 作用域、稳定性、大小写、回收规则 |
| 代理键 | 数据库模型人为引入的内部行身份 | customer_id、product_id、order_id | 类型、生成位置、生命周期 |
| 外部标识 | 另一个权威系统赋予 | provider_payment_ref | 必须连同提供方/租户保存作用域 |

使用代理主键不意味着可以丢掉业务唯一约束：

```sql
CREATE TABLE shop.product (
    product_id bigint PRIMARY KEY,
    sku text NOT NULL UNIQUE,
    ...
);
```

`product_id` 让内部外键紧凑、稳定；`sku` 的唯一约束防止数据库保存两个业务上同一商品。若只保留代理键，两行不同 ID、同一 SKU 会同时“技术合法”。

### 哪些自然属性不适合做主键

客户 email 在 v0 中唯一，但仍不作为主键：

- 用户可能改邮箱；
- 大小写与规范化规则尚未决定；
- 邮箱可能由身份系统合并或重新分配；
- 它包含个人信息，会传播进子表、日志和缓存；
- 所有引用表都被迫携带一个较宽可变字符串。

因此使用 `customer_id` 作内部身份、`customer_ref` 作业务引用、`email` 作当前可联系属性并暂时唯一。ch04 会审查文本比较与大小写语义。

SKU 也可能变更，但订单行需要“购买时看到的 SKU”。v0 同时保存：

```text
product_id      -> 当前商品实体
sku_snapshot    -> 下单时业务快照
```

商品改 SKU 不应重写历史订单。两个字段字面上曾经相同，语义和生命周期不同。

### 外部标识必须带命名空间

支付提供方的 `pay-ref-1001` 只在 provider 自己的命名空间内有意义：

```sql
UNIQUE (provider, provider_payment_ref)
```

若系统是多租户，还可能需要 `(tenant_id, provider, provider_payment_ref)`。不要根据测试数据“看起来全局唯一”省掉作用域；权威方没有承诺的唯一性不是事实。

外部标识也不是认证凭据。能够猜到 order_no、bigint ID 或 provider reference，不应赋予读取和修改权限。

## 3.2.2 主键稳定性、键宽度与传播范围 {#item-3-2-2}

主键一旦被外键、消息、缓存、URL 和数据仓库引用，就变成传播最广的设计决定之一。评审至少看：

1. **稳定性**：业务是否会要求修改它？
2. **作用域**：数据库、租户、服务还是全球唯一？
3. **宽度**：每个引用、索引和 join 要携带多少字节？
4. **生成**：数据库、应用还是外部权威负责？
5. **顺序性**：是否暴露规模，是否影响写入局部性？
6. **展示**：客户支持与 API 是否需要可读标识？

v0 选择 `bigint` 只是为了让关系可运行，值由 seed 显式提供。ch04 会在 identity、UUID 与应用生成之间做正式决定。

### 不把可变业务键传播到所有关系

关系传播图：

| 父关系 | 内部主键 | 业务/外部键 | 子关系保存什么 |
|---|---|---|---|
| customer | `customer_id` | `customer_ref`、email | order 保存 `customer_id`，另保存下单 email 快照 |
| product | `product_id` | SKU | line 保存 `product_id` 与 SKU/name 快照 |
| sales_order | `order_id` | `order_no` | line/payment 保存 `order_id` |
| payment | `payment_id` | provider reference | 对账通过 provider + reference 查找 |

内部关系使用稳定代理键，边界接口仍可用 order_no、customer_ref 等业务标识。这样修改展示格式不会要求重写每个外键。

### 复合主键何时合理

`sales_order_item` 使用：

```sql
PRIMARY KEY (order_id, line_no)
```

line 的身份只在一张订单内成立，没有脱离 order 的独立生命周期。复合键准确表达“订单中的第 N 行”，并让重复 line_no 直接失败。

若未来 line 需要在多个系统独立引用、跨订单移动或拥有大量子关系，可以再评估独立 `order_item_id`；不要因为所有表模板都含 `id` 就提前添加。

复合键也有传播成本。子表若引用 order item，必须携带两列，唯一索引和 join 也更宽。模型应在语义准确与操作成本之间明确取舍。

### 主键更新通常意味着身份混乱

v0 外键显式使用 `ON UPDATE RESTRICT`。内部主键原则上不可变；如果业务要求“把 customer_id 从 1 改为 2”，更可能是在合并实体，需要迁移引用、冲突决策、审计和补偿，而不是普通级联更新。

`ON UPDATE CASCADE` 是可用机制，但不应替代身份语义。一个能级联修改的键仍会影响锁、索引、复制和外部消费者。

键宽度与写入局部性的物理代价在 ch04/ch09 量化，本节先冻结职责。

## 3.2.3 幂等键、去重键与审计标识 {#item-3-2-3}

网络超时后，客户端不知道服务器是否提交，最安全的重试依赖幂等协议，而不是“希望第一次没成功”。幂等键表达：

> 在约定作用域和保留期内，同一个 key 代表同一个逻辑命令。

v0 对下单使用：

```sql
UNIQUE (customer_id, request_key)
```

对支付使用：

```sql
UNIQUE (provider, idempotency_key)
```

作用域不同，因为两类命令的权威和重试边界不同。

### 唯一键只挡重复，不验证同一请求

如果攻击者或客户端错误地用相同 key 发送不同商品列表，`UNIQUE` 只会告诉你已有一行。它不会判断新旧 payload 是否等价。因此 v0 还保存 `request_fingerprint`：

```text
key 相同 + fingerprint 相同 -> 返回既有结果
key 相同 + fingerprint 不同 -> 明确冲突，不能当成功
key 不同                     -> 尝试新命令
```

典型流程：

```sql
INSERT INTO shop.sales_order (...)
VALUES (...)
ON CONFLICT (customer_id, request_key) DO NOTHING
RETURNING order_id, request_fingerprint;
```

若没有返回行，在同一事务的后续语句读取既有记录并比较 fingerprint。并发语义、锁等待和 `ON CONFLICT` 快照细节会在 ch10 展开；本节只确定协议事实。

fingerprint 的序列化必须规范：字段顺序、编码、NULL、数值与 JSON 规范化都要固定。直接 `md5(raw_http_body)` 可能让语义相同但格式不同的请求被判为冲突，也可能遗漏不应忽略的字段。本章 hash 只是确定性样例。

### 去重键与幂等键

“去重”常从已有数据推测两条记录像不像，例如相同 email 与时间窗口；“幂等”是调用方和服务事先约定同一命令身份。前者可能需要概率与人工判断，后者应有确定作用域和唯一约束。不要把模糊相似度当支付幂等。

### trace ID 不应唯一

`created_by_trace_id` 和 `payment.trace_id` 用于把数据库事实关联到日志、消息和调用链。同一个 trace 可能创建 order、多个 line 和 payment，因此它们通常不是唯一键，也不决定重试结果。

审计标识还不能替代审计内容。至少要知道动作、主体、时间、目标和结果；单独一串 trace ID 只有在外部日志仍可用时才有意义。

### 保留期与删除

幂等键如果被删除并重用，迟到重试可能创建第二笔业务。设计时必须定义：

- key 由谁生成；
- 在什么作用域唯一；
- 保留多久；
- 过期后迟到请求怎样处理；
- 数据归档/分区是否仍保留去重索引；
- 跨地域或多主写入怎样协调。

本章不删除订单和支付，因此 key 与事实同生命周期。

### 本节验收

- 能为每个标识写出权威、作用域、稳定性、生成方与是否公开；
- 代理主键与业务唯一约束同时存在；
- 外部标识包含 provider/tenant 等真实命名空间；
- 幂等唯一键有 fingerprint 冲突语义；
- trace ID 不被误设为主键或唯一键；
- `bigint` 只是 v0 选择，生成策略明确留给 ch04。

## 参考资料

- [PostgreSQL 18：主键与唯一约束](https://www.postgresql.org/docs/18/ddl-constraints.html#DDL-CONSTRAINTS-PRIMARY-KEYS)
- [PostgreSQL 18：INSERT 与 ON CONFLICT](https://www.postgresql.org/docs/18/sql-insert.html)
- [PostgreSQL 18：标识列](https://www.postgresql.org/docs/18/ddl-identity-columns.html)

---

[上一节：从业务语言提取数据库事实](../01/) · [返回本章目录](../) · [下一节：关系与引用完整性](../03/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
