后端系统工程 20:发号服务的唯一性与失败模式
系列导航:上一篇 19 延迟任务的领取与取消 | 本篇 20 | 下一篇 21 有限能力的数据库路由代理
“生成一个唯一 ID”这句话省略了三个限定:在多大范围内唯一,在哪些故障之后仍然唯一,除了唯一还要求什么。一张订单表的主键只需在该表内不重复;跨系统传递的标识要在所有系统中不重复;作为分片键或排序键时,还会被要求大致按时间递增。不同方案对这些问题的回答不同,它们的失败模式也不同:数据库号段在进程崩溃后留下空洞,时间型标识在时钟回退时可能重复,节点号方案在两个进程误用同一节点号时必然重复。
本篇实现一个基于 PostgreSQL 的号段发号器,作为主方案验证并发取号、进程重启、数据库故障下的唯一性;再实现 UUIDv4、UUIDv7 和 Snowflake 式 64 位标识作为契约对照,用受控时钟测试时钟回退,用真实多进程测试节点号冲突。代码在 examples/20-id-service/,输出在 evidence/20/output.txt。
需求契约
号段服务的使用方只调用一个操作:为某个业务标签取下一个号。服务承诺:
- 同一业务标签下发出的号全局唯一,这一点在发号进程崩溃重启、数据库崩溃恢复之后依然成立。
- 同一线程连续取得的号严格递增。
- 取不到号时明确报错,不降级为本地自造号。
服务不承诺无空洞:进程退出时内存中尚未发出的号会被丢弃,永远不会再发。不承诺跨进程的全局递增顺序:两个进程同时持有不同号段,交替发出的号在时间上交错。也不承诺数据库不可用期间持续可用:只能发完内存中已经领到的号。
规模假设是教学取值:4 个发号进程、每进程 4 个线程,共发出 40 万个号;号段步长 10 到 1000,预取阈值 0.2 到 0.5;数据库故障用例步长 500。这些数字让空洞和重叠能在秒级显现,不是推荐配置。
号段:把协调摊到一段号上
最直接的唯一号来源是数据库序列,但每次取号都要访问数据库。号段方案让每次数据库访问分配一整段连续的号(例如 1000 个),由进程在内存中逐个发出,数据库只记录每个标签已分配到的最大值:
1 | |
图 20-1:号段发号的状态归属(持久状态只有 max_id)
flowchart LR
subgraph PG["PostgreSQL b2_20(持久)"]
S[(id_segment<br/>biz_tag → max_id, step)]
L[(segment_log<br/>分配日志,供核查)]
end
subgraph P1["发号进程 A(内存,崩溃即丢)"]
C1[当前段 1001–2000<br/>游标 1376]
N1[预取段 3001–4000]
end
subgraph P2["发号进程 B(内存)"]
C2[当前段 2001–3000]
end
C1 -. 剩余低于阈值时异步取下一段 .-> S
C2 -. UPDATE … RETURNING .-> S
S --> L
分配必须是一条原子语句。PostgreSQL 的 UPDATE … RETURNING 基于实际更新的行计算返回值(PostgreSQL 16 UPDATE);在 READ COMMITTED 下,并发更新同一行的语句会等待前一个事务结束,然后对该行的新版本执行自己的操作(PostgreSQL 16 §13.2.1)。因此 max_id = max_id + step 总是在最新值上加步长:
1 | |
返回的闭区间 [max_id − step + 1, max_id] 归调用进程独占。分配日志与分配在同一语句中写入,用于事后核查号段是否重叠。未知标签时 UPDATE 命中 0 行,实现把它转为 SQLSTATE 02000 的错误,segment_unknown_tag_rejected 检查了这条拒绝路径。
对照:先读后写
一种常见的错误写法分两步:先 SELECT max_id, step,在应用中算出新值,再 UPDATE … SET max_id = 新值。第二步写回的是一个绝对值,与读取之间没有行锁,两个连接可能读到同一个旧值,算出同一个新值,各自认为拿到了同一段号。
1 | |
8 个连接各分配 200 次、步长 100。先读后写的写法产生 1204 个与其他段重叠的号段,最终 max_id 只有 39600,而串行执行时应为 160000,也就是说大量分配互相覆盖。改用单条 UPDATE … RETURNING 后没有重叠,max_id 恰好等于 1600 × 100。
正常路径与双缓冲
segment_four_processes_unique 启动 4 个 JVM 发号进程,每进程 4 个线程,总共发出 40 万个号,全部插入一张以号为主键的表:
1 | |
主键插入零冲突,404 个号段两两不重叠,每个线程看到的号严格递增。分配了 404000 个号、发出 400000 个,差额 4000 是进程正常退出时内存中未发完的号,它们被直接丢弃。
预取的目的是让数据库往返不落在取号的关键路径上:当前段剩余比例低于阈值时,后台线程去取下一段,当前段用完后直接切换。segment_boundary_prefetch_step_change 在运行中把步长从 10 改为 100,号段长度依次为 10、10、10、100、100,发出的 200 个号从 1 到 200 连续,预取 4 次,只有第一次取号是同步等待。步长的修改从下一次分配开始生效,已经分配出去的段不受影响。
崩溃、重启与数据库故障
进程崩溃时丢失的只是内存中的段,数据库中的 max_id 已经越过这些段,后续分配不会再碰到它们。restart_kill9_discards_segment 让进程 A 在发号途中被 kill -9:
1 | |
A 已经领到三个段(含预取段),实际发出到 1750。新进程 B 从 3001 开始,没有任何号不大于 A 的最后一段终点;1751 到 3000 共 1250 个号成为空洞。空洞大小上限大约是“当前段剩余 + 预取段”,步长越大,崩溃一次丢弃的号越多。这是号段方案用来换取低数据库访问频率的代价,契约里写明不承诺无空洞,就是为了接受这个代价。
数据库本身故障时,发号器能继续发完内存里的号,然后必须报错。db_outage_buffer_then_fail_then_recover 使用本章独立的 PostgreSQL 实例(共享实例不能停止),在 1510ms 时对数据库执行 kill -9:
1 | |
故障后发号器又从缓冲中发出 250 个号,283ms 后第一次报错,此后的请求全部得到明确错误,没有自造号。数据库重启后恢复发号,新号从 1501 开始,大于故障前发出的全部号,全程没有重复。这里有一个依赖值得写明:数据库崩溃恢复后 max_id 不回退,前提是分配语句的提交已经持久化。本实例使用默认的 fsync = on 与同步提交,而 kill -9 不丢失操作系统页缓存,所以这个用例不能证明掉电场景下的行为。
数据库序列为什么不是无空洞计数器
PostgreSQL 序列常被当作发号器的基础,它的契约与号段类似,也不承诺无空洞。文档写明 nextval 取得的值在事务回滚后不会回收,事务中止或数据库崩溃都会造成空洞(PostgreSQL 16 §9.17);CACHE 大于 1 时只能假定各会话取得的值互不相同,不能假定按顺序生成,会话结束时未用的缓存值丢失(PostgreSQL 16 CREATE SEQUENCE)。两个用例分别观察到这两点:
1 | |
CACHE 20 下,两个会话交替取号,按调用顺序得到 1、21、2、22、3、23;会话结束后,新会话从 41 开始,4 到 20、24 到 40 永远不会出现。号段方案本质上就是应用层的“大缓存序列”,空洞和跨进程乱序是同一类性质。
不依赖数据库的两类对照
UUIDv4 与 UUIDv7
UUID 不需要中心协调。RFC 9562 规定 UUIDv4 可以随机生成全部 122 个可变位(RFC 9562 §5.4);UUIDv7 把 Unix 毫秒时间戳放在最高 48 位,其余 74 位(除版本与变体位)填随机数,并可以用计数器保证同一毫秒内单调(§5.7、§6.2)。本章的 v7 实现采用 §6.2 的 Method 1:时间戳之后的 12 位作为计数器,每个新毫秒用随机数初始化低 11 位,最高位置 0 作为回绕保护;同一毫秒内计数器加一,用尽时按 RFC 允许的做法把时间戳前推 1ms。
1 | |
100 万个 v7 严格递增,单毫秒内最多生成 2301 个,计数器回绕 5 次,回绕时时间戳前推后仍然单调。按 PostgreSQL uuid 类型排序,2 万个 v7 与生成顺序完全一致,v4 几乎完全不一致。RFC 9562 §6.11 说明 v6、v7 被设计为可以直接按原始字节排序;数据库索引的局部性因此受益,但本章没有测量由此带来的性能差异。实现中有一个细节:java.util.UUID.compareTo 按有符号 long 比较,与按无符号字节比较的数据库排序不一致,检查单调性时必须改用无符号比较。
时钟回退时,v7 的做法是沿用上一个时间戳继续递增计数器,RFC 9562 §6.2 的单调性检查一节允许这样处理。uuid_v7_clock_rollback_model 在受控时钟下回退 5000ms,输出仍严格递增,回退期间嵌入的时间戳冻结在回退前的值,时钟追上后恢复。代价是这段时间内标识携带的时间不再反映生成时刻。
随机部分也决定了另一个契约差异:RFC 9562 §8 要求实现不应假设 UUID 难以猜测,任何安全相关的用途应使用 v4。v7 的高位暴露了生成时刻,号段方案的号连续可猜,两者都不适合作为不可猜测的凭证。
Snowflake 式标识与节点号
另一类方案把 64 位整数切成“毫秒时间戳 + 节点号 + 序列”。Twitter 最初公开的实现仓库在本环境无法访问,这里只采用通用结构,位宽是本章的取值:41 位时间戳、10 位节点号、12 位序列。它的唯一性同时依赖两件事:同一节点上时间戳不回退,不同节点的节点号不同。
第一个依赖由生成器自己检查:时钟回退不超过 maxWaitMs 时等待追上,超过时抛出异常拒绝生成。
1 | |
去掉回退检查后,仅 20ms 的回退就在 600 个标识中产生 100 个重复:时钟回到已经用过的毫秒,序列从 0 重新开始,生成的值与之前完全相同。RFC 9562 §6.1 对 UUID 提出了同样的要求:生成过快时可以报错或停顿,但不得明知故犯地返回重复值。同一毫秒 4096 个序列用尽时,生成器等待下一毫秒。
第二个依赖需要协调。本章用 PostgreSQL 表 node_lease 分配节点号:进程用 FOR UPDATE SKIP LOCKED 挑一个租约已过期的节点号,写入持有者、期限并令 epoch 加一,此后定期续租。RFC 9562 §6.4 把节点号的创建与协商明确排除在规范之外,这正是要由部署方自己解决的部分。
1 | |
4 个进程领到 4 个不同节点号,40 万个标识无重复。两个进程被手工配置为同一节点号并同时起跑时,40 万个标识中有 10712 个重复,具体数量取决于两者的时钟对齐程度,每次运行都不同,但只要同时运行就会出现。
租约过期后节点号会被别人领走,原持有者如果只是被挂起,恢复后仍可能继续使用旧节点号。实现让进程用 System.nanoTime 计算本地期限:从发出续租请求之前的时刻起算 ttl − margin,保证本地先于数据库认为租约过期;每次生成前检查本地期限,过期就抛出异常。snowflake_lease_pause_stops_old_holder 让持有节点号 0 的进程 A 被 SIGSTOP 约 4 秒(租约 2 秒),进程 B 以 epoch 2 领到同一节点号;A 恢复后第一次生成就因本地租约过期而停止,恢复后写出的行数为 0,10 万多个标识没有重复。
这个保护有一个缺口,本章没有关闭:进程在“检查本地期限通过”与“读时钟、生成标识”之间被挂起,恢复后仍可能用旧节点号生成一个标识。完全关闭这个窗口,需要使用标识的一侧校验 epoch,也就是后续第 26 章的资源端栅栏,本批不实现。号段方案没有这个问题,它唯一的协调点就是数据库的那一行。
契约对照
| 维度 | 数据库号段 | UUIDv4 | UUIDv7 | Snowflake 式 |
|---|---|---|---|---|
| 唯一性范围 | 同一业务标签,依赖单行原子更新 | 全局,依赖随机源(概率性) | 全局,时间 + 计数 + 随机(概率性) | 节点号不重复且时钟不回退时全局唯一 |
| 单调性 | 单线程严格递增;跨进程不保证 | 无 | 单生成器严格递增;跨生成器按毫秒近似有序 | 单节点递增;跨节点按毫秒近似有序 |
| 空洞 | 有(崩溃丢段、退出丢余量) | 不适用 | 不适用 | 有(未用的序列值) |
| 可猜测性 | 连续,可猜 | 不应假设难猜,但安全用途推荐使用 | 暴露时间 | 暴露时间与节点 |
| 长度 | 64 位整数 | 128 位 | 128 位 | 64 位整数 |
| 运行依赖 | 数据库(缓冲用完后不可用) | 随机数源 | 时钟、随机数源 | 时钟、节点号协调 |
| 主要失败模式 | 先读后写导致号段重叠 | 随机源缺陷 | 时钟回退(实现可冻结时间戳) | 时钟回退、节点号冲突、挂起的旧持有者 |
表中关于 UUID 碰撞的部分来自 RFC 9562 的位布局说明,几十万规模的实验不足以对碰撞概率作出任何结论。
用例与证据
| 用例 | 类型 | 观察值(evidence/20/output.txt) |
|---|---|---|
segment_four_processes_unique |
正常,多进程 | 40 万号主键零冲突,404 段无重叠 |
segment_boundary_prefetch_step_change / segment_unknown_tag_rejected |
边界 | 步长变更后号连续;未知标签 02000 |
restart_kill9_discards_segment |
崩溃恢复 | B 从 3001 开始,空洞 1250 |
db_outage_buffer_then_fail_then_recover |
数据库故障 | 缓冲 250 个后报错;恢复后从 1501 起,无重复 |
negative_select_then_update_overlap / 对照 |
负向对照 | 1204 段重叠;原子写法 0 |
sequence_* |
对照观察 | 回滚留空洞;CACHE 乱序与丢失 |
uuid_* |
对照方案 | v7 百万个严格递增;排序与生成顺序一致 |
snowflake_rollback_wait_or_reject / 无检查对照 |
时钟回退(模型) | 小回退等待、大回退拒绝;无检查时 100 个重复 |
snowflake_leased_nodes_unique / 同节点对照 |
多进程 | 无重复;同节点 10712 个重复 |
snowflake_lease_pause_stops_old_holder |
挂起恢复 | 旧持有者恢复后停止,无重复 |
入口为 examples/20-id-service/run.sh。本机 /dev/shm 只有 64MB,独立数据库实例由 run.sh 自行初始化并设置 dynamic_shared_memory_type = mmap,端口使用本章端口段内的 47201,详见 evidence/20/LIMITS.md。
结论边界
实验支持这些结论:单条 UPDATE … RETURNING 的号段分配在多进程并发、发号进程 kill -9、数据库进程 kill -9 之后都不复用号;先读后写的分配会重叠;UUIDv7 的 Method 1 实现在同毫秒和时钟回退时保持单生成器单调;Snowflake 式方案的唯一性取决于回退检查和节点号协调,两者缺一就会重复。
实验没有覆盖号段服务或数据库的多副本与主备切换,也没有在真实机器上观察 NTP 调整引起的时钟回退,回退只在受控时钟模型中测试。实验没有测量吞吐,文中没有性能数字。
号段服务把“唯一”交给了数据库中的一行。数据一旦分布到多个库,SQL 本身也需要先找到正确的库,并且不能在事务中途换到另一个库。下一篇 21 有限能力的数据库路由代理 讨论这样一个路由层能承诺什么,以及它必须拒绝什么。






