系统设计 09:ID 与索引——唯一、顺序和不可猜不是同一件事
“用自增 ID 就够了”可能只是回答了如何定位一行。它不自动保证公开链接难以枚举,也不保证事务按 ID 顺序完成。系统设计里应把内部主键、外部可见编码、幂等键和列表排序键分开;每个键的唯一范围和失败处理都不同。
沿用按所有者迁移的分享服务:创建分享、用链接取分享、按所有者与创建时间翻列表;撤销后禁止公开返回目标。本文只讨论单个权威 PostgreSQL 写入域里的 ID 与索引;不声称单序列已经解决跨分片、跨地域发号或安全授权。外部别名随机性与密码学熵是待另行验证的设计要求。
先写清四把不同的钥匙
shares(id BIGINT PRIMARY KEY, public_code TEXT UNIQUE NOT NULL, owner_id, created_at, state, target):内部 id 定位一行;public_code 放在 GET /s/{public_code} 链接中,服务端读到后仍检查权限与撤销;创建接口 POST /shares 接收 (owner_id, idempotency_key),该组合另有唯一约束并与 share_id 在同一事务提交;列表 GET /users/{owner_id}/shares?after=... 用 (owner_id, created_at DESC, id DESC) 做过滤、排序和下一页锚点。把公开链接当授权凭证、把内部序列当不可猜口令,或用只有 created_at 的排序键,都会越过先前篇章已说明的边界。
练习目标:公开代码在本写入域内唯一,遇碰撞要明确重试/冲突响应;同一幂等键不可创建第二条记录;合法按码读取的服务端 p99 暂定小于 150 ms,但本篇没有压测。公开代码可以尝试随机生成,再由数据库唯一约束承担最后一道碰撞检测;仅因样本看起来杂乱,不能宣称不可预测,更不等于无需授权。另一候选是直接公开递增数字:省去别名索引,但若不希望他人枚举相邻链接,就不能把“序列唯一”说成“保密”。
独立教学输入:每天 400 万读、20 万写,读写比 20:1;峰值按均值八倍,约 370.37 次读/秒和 18.52 次写/秒。假设每次读响应 1 KiB,读出口有效载荷约 0.362 MiB/s、3.03 Mbit/s。每条元数据 800 B,内部主键索引预算 64 B、公开代码唯一索引 80 B、所有者列表索引 200 B,每天 20 万条、保存 365 天、三份:200000 × (800+64+80+200) × 365 × 3 = 250.536 GB。每天读写共 420 万条日志、每条 200 B、保留 7 天一份:5.88 GB,总约 256.416 GB;练习存储单价 0.02 货币单位/(GB·月) 得约 5.13 货币单位/月,不含备份、网络、计算或真实索引开销。若公开代码更长导致唯一索引平均多 16 B/行,这些假设下三份一年又多约 3.504 GB;要为额外索引付写放大成本,而不是对所有访问模式盲目建索引。
数据库能证实哪些承诺
PostgreSQL 16 的 序列函数文档明确说明 nextval 分配值的原子性及回滚不会收回已使用序列值;故顺序发号不意味着无空洞,更不能仅凭 ID 推断事务提交时间。PostgreSQL 唯一索引文档说明主键和唯一约束由唯一索引实现。本文不把文档引用当本机实测:使用 PostgreSQL 16.15 私有实例做了两件事。
examples/system-design/labs/09/ids.sh 在事务中取到序列值 100 再回滚,下一次 nextval 给 101;这不是“丢了编号对应的分享”。两条真实独立会话几乎同时插入相同的 public_code='short-abc':第一会话插入后保持事务四秒,第二会话等待锁;脚本查询 pg_stat_activity 看到等待,第一会话提交后第二会话被唯一约束拒绝,表里只有一行。正常运行退出 0,--strict-contiguous 故意把序列有空洞判为错误并退出 2,原始记录见 examples/system-design/evidence/09/ids.md。本机未验证公开随机码的熵、发号的跨节点规模或线上并发 p99。
1 | |
flowchart LR
C[创建者] -->|POST + 幂等键| A[应用]
A -->|先检查同键映射,生成候选公开码| D[(PostgreSQL 权威库)]
D -->|事务插入主键 / 唯一公开码 / 请求映射| A
A -->|已提交的 share_id 和公开码| C
R[读者] -->|GET 公开码| A
A -->|按唯一索引查码并查撤销状态| D
A -->|授权后返回目标或拒绝| R
并发冲突需要可观察的响应语义。两次不同创建恰好生成相同公开码时,应在限定次数内换一个候选再试,耗尽预算后返回失败并记录冲突;两次同幂等键则应该读同一映射并返回原有结果,不是通过换码生成第二份分享。唯一约束在数据库里兜底,但不负责定义“碰撞是新请求还是同一请求重试”;这由幂等映射事务与请求语义决定。
sequenceDiagram
participant X as 会话一
participant Y as 会话二
participant D as PostgreSQL
X->>D: 事务插入 code=short-abc
D-->>X: 等待提交前持有唯一索引冲突相关锁
Y->>D: 同时插入 code=short-abc
Note over Y,D: 第二会话在锁上等待,而非产生两条成功记录
X->>D: 提交
D-->>X: 成功
D-->>Y: 唯一约束错误,表中仍只有一行
Note over X,Y: 若是同幂等键重试,应查旧映射而非随机再生成
初版不需要独立的发号服务:权威写库的主键与唯一约束已经覆盖局部唯一性,另为公开访问设置代码与索引。确实跨分片写入后才重新规定唯一作用域和 ID 路由;可能用带分片信息的内部 ID 或全局分配策略,但均不能替代权限核对。恢复时审计“确认成功”的幂等映射和分享是否一起提交,而不是检查 ID 有没有跳号。面试先问 ID 暴露给谁、唯一范围在哪、冲突和回滚怎么办,再谈发号方案,能避免把不同问题包装成一项技术选型。
参考资料
- PostgreSQL 16:Sequence Manipulation Functions:序列原子取值与回滚空洞。
- PostgreSQL 16:Unique Indexes:主键与唯一约束的索引边界。实测版本/配置见
examples/system-design/evidence/09/ids.md。





