主键又冲突了?分布式 ID 实测:UUID 页分裂之痛、雪花时钟回拨 1 万次碰撞、号段模式 20 次往返扛 20 万 ID
适用读者:系统从单机走向多实例部署,开始纠结"主键用什么"的后端工程师
技术栈:Python 纯标准库实测;原理与语言无关,MySQL / PostgreSQL / 各种 NoSQL 通用
阅读时间:约 22 分钟 | 难度:中级 | 收获:四类方案的可运行实现 + 时钟回拨实测数据 + 选型决策树
前言:一次"双机房各发各的 ID"事故
系统做水平拆分那天,订单服务的两个实例部署在不同机房。开发者很自然地说:主键用数据库自增呗。然后两个实例连的是各自机房的数据库——
两边都从 1 开始发号。
数据同步时主键冲突,同步任务红了一整晚。事后复盘会上有人提出来用 UUID,立刻有人反对:UUID 无序,InnoDB 主键插入性能会崩。又有人提议雪花算法,马上有人问:
机器时钟回拨了怎么办?
三个方案,三个都有人反对,但没人能说清反对的理由到底成不成立。
这篇文章把四类主流方案(数据库自增、UUID、号段模式、雪花算法)
全部实现一遍,并把争议最大的两个点真的测出来:
- UUID 的"无序之痛"到底痛在哪、痛多少?
- 雪花算法遇到时钟回拨,不做防护会发生什么?标准防护又是用什么代价换的?
目录
- 先想清楚:你要 ID 干什么
- 数据库自增:好用,但到不了分布式
- UUID:全局唯一,却和 B+ 树八字不合
- 号段模式:用一次往返换一万次发号
- 雪花算法:位分配的艺术
- 实测一:时钟回拨不做防护,200 次回拨撞出 1 万条
- 实测二:三种方案的吞吐对比
- 选型决策树 + 上线 Checklist
- 高频 FAQ
1. 先想清楚:你要 ID 干什么
"分布式 ID"的需求清单,先分清哪些是硬性、哪些是软性:
关键认知:
没有方案能同时满分。自增有序但依赖中心、UUID 无序、雪花有序但怕时钟回拨、号段折中但要多一次预取。工程选型的本质是:
为你的业务找出唯一一条硬性需求,然后看哪个方案在这条上不出错。
比如订单 ID 的硬性需求是"绝不重复且不能泄露量级",内部流水表的硬性需求可能是"有序 + 紧凑",监控打点可能唯一的需求就是"别重复"——三者的正确答案完全不同。
2. 数据库自增:好用,但到不了分布式
单库时代
AUTO_INCREMENT 是完美的:有序、紧凑、零心智负担。上分布式后它死在两点:
- 发号能力 = 数据库写入能力。每次插入才发号,高峰期数据库本身成为瓶颈;
- 多库必然冲突。分库分表后,两个库的自增序列互相不知道对方的存在。
常见的"分库步长法"(库 1 发 1、6、11…,库 2 发 2、7、12…)能续命,但有两个暗坑:
- 扩容即重新规划步长。从 2 库扩到 3 库,步长要改,历史区间要重新切分,极其痛苦;
- 区间天然无序。库 1 的 1 和库 2 的 2 哪个先发生?无从知晓。这对"按 ID 排序"是致命的。
所以自增的定位很明确:
单库、写并发不高、确定不分库的场景,继续用;一旦有分库的预期,一开始就别选它。
3. UUID:全局唯一,却和 B+ 树八字不合
UUIDv4 完全随机生成,128 bit,冲突概率在工程意义上为零(生成 10 亿个,冲突概率约十亿分之一)。它解决了"多机房各发各的"问题——因为根本不需要协商。
代价在数据库里。InnoDB 的主键是聚簇索引,数据按主键物理有序存储。随机主键的插入点均匀分布在整个 B+ 树上:
- 页分裂:每次插入可能落在已满的页,触发分裂 + 数据搬移;
- 缓冲池污染:插入点散布导致工作集远大于热点页,命中率骤降;
- 主键更宽:36 字符(或 16 字节二进制)对比 8 字节 BIGINT,所有二级索引的叶子节点都要跟着膨胀。
一个常被忽略的事实:二级索引膨胀是乘法效应。表上 5 个二级索引,每个都要存一份主键值——主键宽 4 倍,5 个索引跟着宽 4 倍。这也是为什么"UUID 作主键"和"UUID 作唯一索引列"的性能画像完全不同。
如果一定要用 UUID,按序生成是解药:
UUIDv7(时间戳前缀,2024 年成为 RFC 9562 标准)让 UUID 具备了趋势递增性,页分裂问题大幅缓解。新系统优先考虑 v7 而不是 v4。
4. 号段模式:用一次往返换一万次发号
号段(Segment)模式的思路是:
批量预取。不逐个向数据库要 ID,而是一次取一批:
-- 发号服务/应用本地维护
UPDATE alloc SET max_id = max_id + step, updated_at = NOW()
WHERE biz_tag = 'order';
-- 读回新的 max_id,本地缓存在内存里
-- [max_id - step + 1, max_id] 这一段全部在内存中发放
本地发放是纯内存操作,数据库压力被压缩到
每 1 万次发号才一次往返。以订单号段 step=10000 为例:10 万 TPS 的发号需求,数据库只需承受 10 次/秒的更新。
工程上的必配项是
双 buffer:单 buffer 的问题是号段用完那一刻才去取下一批,取号请求卡在数据库上时,发号会出现毛刺。双 buffer 在当前号段消耗到 10%~20% 时就异步预取下一批,切换无感。
它的短板也很直白:
- ID 不是严格递增的,是"号段内递增、号段间可能乱序"——多实例各自持号段时,实例 A 的号段可能晚于实例 B 发放却数值更小;
- 服务重启会浪费半段(可用"用掉超过 90% 才持久化进度"缓解);
- 需要一张发号表和发号服务的运维。
它最适合的场景:
对严格有序没要求、但要求数值紧凑短小、且吞吐高的内部 ID——典型如各种流水号。
5. 雪花算法:位分配的艺术
Snowflake 用一个 64-bit 整数编码了"时间 + 机器 + 序列":
| 1 bit 保留 | 41 bit 时间戳 | 10 bit 机器 ID | 12 bit 序列 |
三段位数的含义值得逐个算清楚:
- 41 bit 毫秒时间戳:
2^41 / 1000 / 86400 / 365 ≈ 69 年——从自定义纪元起用 69 年;
- 10 bit 机器 ID:1024 个实例;这 10 bit 还常被再拆成"5 bit 机房 + 5 bit 机器";
- 12 bit 序列:同一毫秒、同一机器最多发 4096 个 ID——单实例发号上限 409.6 万/秒,绝大多数业务一辈子碰不到。
它的优势是致命的:
纯内存位运算生成,趋势递增,无中心依赖。
它的命门也在这 41 bit 上:
时间戳取自本地时钟,而本地时钟不是可信的。NTP 校时会往回调,虚拟机迁移会漂移,手动改时间的事故历史上真发生过。时钟一旦回拨,"用时间 + 序列"生成的新 ID 就可能与回拨前发过的 ID 完全相同。
6. 实测一:时钟回拨不做防护,200 次回拨撞出 1 万条
雪花算法最著名的争议点值得用数据说话。我写了一个模拟器(完整代码在仓库
tools/bench_distributed_id.py,虚拟时钟 + 固定随机种子,结果可复现):模拟生成 10 万个 ID,期间注入
200 次 NTP 式校时,每次把时钟往回拨 50ms——
无防护的碰撞数完全符合推导:
回拨多少毫秒,就撞多少条。而且注意这 1 万条不是均匀散开的——碰撞集中在每次回拨后的 50ms 内,
恰好对应回拨前那段时间产生的订单。换成业务语言:回拨一次,那个时间窗的订单主键全部冲突。
守护版的代价同样值得看清:
- 碰撞归零,但整个回拨窗口内发号被暂停(自旋等待时钟追上来)——回拨 50ms,业务最多等 50ms,通常可接受;
- 更隐蔽的一点:守护版发的 ID 时间戳会"透支未来"。实测中守护版为了保持严格递增,时间戳一路领先真实时钟,且不会自己恢复——回拨太狠(如几十分钟)时,应该直接报警拒绝服务,而不是自旋。
所以生产级雪花算法的时钟回拨策略是一个三级火箭:
- 回拨 < 100ms:自旋等待(等待成本低);
- 回拨较大:拒绝发号 + 告警(宁可业务报错,不可悄悄发错 ID);
- 兜底:使用扩展位——把机器 ID 里留几位作"回拨代",回拨一次就翻一代,绕开已用过的时间区间(美团 Leaf 的做法)。
7. 实测二:三种方案的吞吐对比
同一台机器、纯 Python 单线程(Python 慢是出了名的,但
相对差距有参考价值),各生成 20 万个 ID:
三个数字三个结论:
- 雪花比 UUIDv4 快 3 倍,且这还只是 Python 里的差距——Go/C 里雪花的位运算是纳秒级,而 UUID 的随机数生成仍然是主要成本。有序 + 更快,这是"能用雪花就别用 UUIDv4"的底气;
- 号段模式的数字要这么读:20 万 ID 只花了 20 次数据库往返,按每次 1ms 算,理论天花板 1000 万/秒——它的吞吐上限由你设定的 step 决定,想多快就多快,代价是发号服务多一次内存占用和重启浪费;
- 单实例 40 万/秒以下的业务,三个方案的吞吐都过剩。瓶颈通常在别处(数据库写入),选型时别为吞吐付费,为正确性约束付费。
8. 选型决策树 + 上线 Checklist
按顺序问自己四个问题:
- 分库分表在可预见未来吗? 不在 → 数据库自增,收工;
- ID 要暴露给外部(订单号/支付单号)? 要 → 不要用连续自增 ID(泄露量级 + 可遍历),用号段/雪花生成后加一层业务前缀和校验位,或者干脆独立设计业务单号;
- 内部主键,要严格趋势递增、不想要额外组件? → 雪花算法(记得时钟回拨三级防护),或 UUIDv7;
- 要求数值短小、可解释、吞吐可控,能接受多一个发号服务? → 号段模式(Leaf-segment)。
上线 Checklist
- [ ] 机器 ID 分配机制明确:静态配置、ZK/K8s 分配还是数据库注册,重复的机器 ID = 雪花直接退化成碰撞机器
- [ ] 雪花:时钟回拨三级防护齐全,"回拨大"的阈值定好并接告警
- [ ] 自定义纪元(epoch)写进代码常量并注释,全团队统一
- [ ] UUIDv4 作主键的表,评估二级索引膨胀和页分裂;新表优先 UUIDv7
- [ ] 号段模式:双 buffer + step 按业务峰值 10 分钟消耗量配置
- [ ] 对外暴露的 ID 不连续、不可遍历、不带时间信息(或确认能接受)
- [ ] 发号服务的可用性级别与业务对齐:雪花无中心依赖,号段服务挂了业务要能本地续命
高频 FAQ
Q:雪花算法 69 年用完了怎么办? 自定义纪元选近一点的日期(如系统上线日),69 年从那天起算。真到期前,ID 迁移是一个早已被规划过的大工程,不是算法问题。
Q:容器环境机器 ID 怎么分? Pod IP 会变,静态配置不现实。用 K8s StatefulSet 的序号,或启动时向数据库/Redis 注册租约(类似 Leaf 的 workerID 分配),租约过期自动释放。
Q:什么时候该用 UUIDv7 而不是雪花? 已有系统里 ID 以字符串 UUID 形式流转(大量 API、第三方对接),改造成 64-bit 整数代价太大时。v7 = UUID 格式的兼容性 + 趋势递增的索引友好,是 UUID 阵营的正确答案。全新系统且全链路可控,雪花更紧凑。
Q:单机部署的小系统还需要这些吗? 不需要。数据库自增是最正确的答案——不要为一个不存在的分布式问题引入分布式组件。
写在最后
回到开头那场双机房事故。最终方案:订单表主键换成雪花算法,两个机房各自独立发号,互不依赖;对外暴露的订单号用号段模式生成的短流水号加校验位。上线三年,零冲突,时钟回拨触发过两次告警,都被守护逻辑拦下,业务无感。
分布式 ID 这件事的教训是:
它不是"选一个最先进的算法",而是"认清你的唯一硬性约束,然后为那个约束选最简单的方案"。大多数系统的答案朴素得惊人——不分库就用自增,分库且全链路可控就用雪花,其余情况号段模式兜底。
如果这篇文章帮你理清了选型思路,欢迎在评论区聊聊你们系统的 ID 方案。