3.2 标识、主键与业务键
“这个对象叫什么”没有一个通用答案。一行可能同时需要数据库内部引用、业务沟通、外部系统对账、请求去重和链路追踪标识。把它们都塞进一个 id,会让任何一次格式或业务规则变化沿所有外键扩散。
3.2.1 自然键、代理键与外部标识
三类键各有职责:
| 类型 | 定义 | pg36_shop 例子 | 设计问题 |
|---|---|---|---|
| 自然/业务键 | 业务已经赋予的唯一事实 | SKU、order_no、customer_ref | 作用域、稳定性、大小写、回收规则 |
| 代理键 | 数据库模型人为引入的内部行身份 | customer_id、product_id、order_id | 类型、生成位置、生命周期 |
| 外部标识 | 另一个权威系统赋予 | provider_payment_ref | 必须连同提供方/租户保存作用域 |
使用代理主键不意味着可以丢掉业务唯一约束:
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 同时保存:
product_id -> 当前商品实体
sku_snapshot -> 下单时业务快照商品改 SKU 不应重写历史订单。两个字段字面上曾经相同,语义和生命周期不同。
外部标识必须带命名空间
支付提供方的 pay-ref-1001 只在 provider 自己的命名空间内有意义:
UNIQUE (provider, provider_payment_ref)若系统是多租户,还可能需要 (tenant_id, provider, provider_payment_ref)。不要根据测试数据“看起来全局唯一”省掉作用域;权威方没有承诺的唯一性不是事实。
外部标识也不是认证凭据。能够猜到 order_no、bigint ID 或 provider reference,不应赋予读取和修改权限。
3.2.2 主键稳定性、键宽度与传播范围
主键一旦被外键、消息、缓存、URL 和数据仓库引用,就变成传播最广的设计决定之一。评审至少看:
- 稳定性:业务是否会要求修改它?
- 作用域:数据库、租户、服务还是全球唯一?
- 宽度:每个引用、索引和 join 要携带多少字节?
- 生成:数据库、应用还是外部权威负责?
- 顺序性:是否暴露规模,是否影响写入局部性?
- 展示:客户支持与 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 使用:
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 幂等键、去重键与审计标识
网络超时后,客户端不知道服务器是否提交,最安全的重试依赖幂等协议,而不是“希望第一次没成功”。幂等键表达:
在约定作用域和保留期内,同一个 key 代表同一个逻辑命令。
v0 对下单使用:
UNIQUE (customer_id, request_key)对支付使用:
UNIQUE (provider, idempotency_key)作用域不同,因为两类命令的权威和重试边界不同。
唯一键只挡重复,不验证同一请求
如果攻击者或客户端错误地用相同 key 发送不同商品列表,UNIQUE 只会告诉你已有一行。它不会判断新旧 payload 是否等价。因此 v0 还保存 request_fingerprint:
key 相同 + fingerprint 相同 -> 返回既有结果
key 相同 + fingerprint 不同 -> 明确冲突,不能当成功
key 不同 -> 尝试新命令典型流程:
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。
参考资料
上一节:从业务语言提取数据库事实 · 返回本章目录 · 下一节:关系与引用完整性 · 查看全书目录 · 查看索引中心