跳至内容

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 和数据仓库引用,就变成传播最广的设计决定之一。评审至少看:

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

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

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

关系传播图:

父关系内部主键业务/外部键子关系保存什么
customercustomer_idcustomer_ref、emailorder 保存 customer_id,另保存下单 email 快照
productproduct_idSKUline 保存 product_id 与 SKU/name 快照
sales_orderorder_idorder_noline/payment 保存 order_id
paymentpayment_idprovider 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_idpayment.trace_id 用于把数据库事实关联到日志、消息和调用链。同一个 trace 可能创建 order、多个 line 和 payment,因此它们通常不是唯一键,也不决定重试结果。

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

保留期与删除

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

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

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

本节验收

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

参考资料


上一节:从业务语言提取数据库事实 · 返回本章目录 · 下一节:关系与引用完整性 · 查看全书目录 · 查看索引中心

最后更新于