分布式 ID 生成方案详解:UUID、Snowflake、号段模式、Leaf 与 Tinyid 对比
订单号、支付流水、分库分表主键都建立在同一个前提上:任何节点在任何时刻生成的 ID 都不能撞车。本文从单机自增失效的根源讲起,拆解 UUID、数据库、Redis、Snowflake 以及 Leaf、Tinyid 的原理、代价与选型边界。
它解决什么问题
单机 MySQL 时代,AUTO_INCREMENT 主键由 InnoDB 内部计数器保证唯一且递增。一旦数据量涨到必须分库分表,每个分片各自维护自增序列,分片 A 的第 100 万行和分片 B 的第 100 万行主键相同,全局唯一性立刻失效。分布式 ID 要回答的第一个问题就是:在没有全局锁的前提下,多个节点并发生成 ID 如何保证不冲突。
第二个问题更隐蔽:为什么还要求”趋势递增”。InnoDB 的主键是聚簇索引(Clustered Index),表数据按主键顺序组织在 B+ 树叶子页上。主键递增时,新行总是追加到最右侧叶子页,页写满才顺序分裂一次,页填充率高;主键随机时,插入位置散布整棵树,目标页往往不在缓冲池中要先读盘,插入又触发中间位置的页分裂(Page Split),旧页搬走一半数据,留下碎片。高写入场景下,随机主键带来的写放大和 I/O 抖动会直接拖垮吞吐。所以分布式 ID 的工程目标是:全局唯一、趋势递增、高吞吐、高可用,外加可选的安全性与可读性。
核心方案与原理
UUID
UUID(Universally Unique Identifier,通用唯一标识符)是 128 bit 标识符,标准写法 36 个字符(含连字符)。JDK 的 UUID.randomUUID() 生成 v4 版本,122 bit 随机位,靠随机数空间足够大来实现”概率唯一”。它完全本地生成、零协调、接入成本最低,缺点也明确:无序、偏长(字符串 36 字符,二进制 16 字节)、无业务含义。无序直接撞上前面说的聚簇索引问题,所以 UUID v4 很少用作高写入表主键,更多用于日志 TraceId、临时凭证等不要求排序的场景;需要时序可改用 RFC 9562 定义的 UUID v7,但它仍是 128 bit。
数据库自增
思路是把发号职责收敛到一台数据库。Flickr 的 Ticket Server 是经典做法:建一张只含自增列和占位列的表,每次发号执行 REPLACE INTO 再取 LAST_INSERT_ID():
CREATE TABLE `ticket_64` ( |
REPLACE INTO ticket_64 (stub) VALUES ('a'); |
REPLACE INTO 撞上唯一键时先删后插,保证表里永远只有一行而 id 持续增大。方案实现简单、ID 严格递增,但每取一个号都要一次数据库往返,吞吐受限于单库,数据库本身也是单点。Flickr 当年用多台 Ticket Server 按奇偶分段缓解可用性,本质是用容量规划换容错。
数据库号段模式
号段模式(Segment)把”每取一个号访问一次 DB”改成”每取一批号访问一次 DB”。发号方用一条带乐观锁的 UPDATE 预领一段区间:
UPDATE id_segment |
更新成功后,(旧 max_id, 旧 max_id + step] 这一段归当前实例在内存里慢慢分发,DB 访问频率降到原来的 1/step,需要检查受影响行数,为 0 说明号段被别的实例抢走,要重读重试。代价是实例宕机时未发完的号段直接作废——会跳号但不重复,多数业务可以接受。号段模式 ID 趋势递增、不依赖时钟,是目前最主流的有状态方案。
Redis INCR
Redis 的 INCR 是原子操作,单 key 天然严格递增,性能极高:
INCR order:id:20240601 |
接入简单,性能也足够。风险点在一致性而非性能:RDB/AOF 持久化存在延迟,进程崩溃后计数可能回退;主从异步复制下发生故障切换,新主的计数值可能落后于旧主。两种情况都会重复发号。不能容忍重复的主键不适合裸用,必须配合落库唯一约束兜底,或只用于流水号这类可幂等重试的场景。
Snowflake 雪花算法
Snowflake 是 Twitter 提出的算法,把 64 bit 长整型切成四段,本地拼装、无网络调用:
0 | 0000000000 0000000000 0000000000 0000000000 0 | 00000 00000 | 0000 0000 0000 |
- 符号位(1 bit):固定为 0,保证 ID 为正数。
- 时间戳(41 bit):相对自定义起点(Epoch)的毫秒差,2^41 毫秒约 69 年。起点一般设为系统上线时间而非 1970 年,以充分利用这 69 年。
- 机器位(10 bit):习惯拆成 5 bit 机房 + 5 bit 机器,单集群最多 1024 个节点,也可按实际规模重新划分。
- 序列号(12 bit):同一毫秒内的自增计数,单节点每毫秒上限 4096 个 ID,超出则自旋等下一毫秒。
时间戳在高位,所以 ID 整体趋势递增;机器位和序列号保证同毫秒并发不冲突。它的两个命门是机器位分配和时钟回拨,后者单独成节讨论。机器位在物理机时代可写死配置,容器环境下 Pod 重启即漂移,通常要借助 ZooKeeper 或数据库在启动时注册申领,并持久化到本地防止换号撞车。
美团 Leaf
Leaf 同时提供两种模式。Leaf-segment 在号段模式上加了双 Buffer 预加载:内存中持有两个号段,当前段消耗到阈值后,异步线程提前向数据库申领下一段并切换,把”耗尽瞬间同步等 DB”的延迟毛刺抹平,TP999 更稳定。Leaf-snowflake 借助 ZooKeeper 处理 Snowflake 的两个痛点:启动时在 ZK 创建顺序节点获得 workerId 并写入本地缓存;运行中校验本机时钟,明显回拨则拒绝发号并告警,小幅回拨等待追回;ZK 临时不可用时靠本地缓存的 workerId 继续工作,属弱依赖。
滴滴 Tinyid
Tinyid 基于号段模式,架构分为 tinyid-server(HTTP 服务)和 tinyid-client(SDK)。公开资料披露的优化有三处:双号段缓存,当前段用到一定程度即异步加载下一个,取号线程不阻塞在 DB 上;支持多数据库部署提高可用性,前提是各库号段范围互不重叠;client 端本地缓存号段,业务进程绝大多数时候纯内存取号,省掉 HTTP 往返。
深入机制:Snowflake 的时钟回拨
Snowflake 唯一性的前提是”时间戳不回退 + 机器位不重复”。机器位靠分配机制保证,时间戳却掌握在操作系统手里。回拨来源很现实:NTP 以 step 模式校时直接跳变、虚拟机挂起恢复、容器迁移、人工改时间。一旦当前系统时间小于上次发号时间,同机器位同毫秒的序列号就会重放,产生重复 ID。
常见解法与代价:
| 解法 | 思路 | 代价 |
|---|---|---|
| 等待追回 | 回拨幅度在阈值内(如几十毫秒)时自旋或睡眠,等时钟追过上次发号时间 | 回拨大时长时间阻塞,发号暂停 |
| 直接抛错 | 检测到回拨即拒绝发号并告警 | 保证唯一但牺牲可用性,需上游重试 |
| 扩展位 | 从序列号或时间位划出几 bit 作回拨计数,回拨一次加一 | 缩减并发容量或使用年限,实现复杂 |
| 历史时钟发号 | 回拨期间继续用上次发号时间递增序列,即”借用未来时间” | ID 时间短暂超前物理时间;重启后若时钟未追回且未持久化该值,仍可能重复 |
| 切换机器位 | 回拨时换一个空闲 workerId 重新注册 | 依赖注册中心租约机制,要处理脑裂与旧实例恢复 |
工程上常组合使用:小幅回拨等待,超阈值抛错告警,同时把上次发号时间周期持久化供重启校验。选哪种组合,本质是在可用性与实现复杂度之间取点。
工程实践
- 订单号:除了唯一和递增,还关心业务规则与安全。纯自增订单号会让外部按差值推算日单量,常见做法是对外订单号拼接日期、业务线与随机段,内部主键另外用 Snowflake,两套 ID 解耦。
- 分库分表主键:Snowflake 或号段都合适。进阶做法是在 ID 低位嵌入分片基因(如用户 ID 取模结果),路由时直接从 ID 解析出目标分片;但要为二次扩容预留位数。
- 链路追踪 ID:只要唯一、不要递增,UUID 或简化版随机 ID 即可,本地生成成本越低越好。
- 性能与可用性:发号量大的系统宜独立部署发号服务,或用内嵌 SDK 加号段缓冲,避免每号一次远程调用;上线前压测峰值 QPS、时钟回拨、DB/Redis 故障切换三类场景;落库侧永远保留唯一索引兜底。
对比与权衡
| 方案 | 全局唯一 | 趋势递增 | 可读性 | 长度/存储 | 性能 | 外部依赖 | 时钟敏感 | 实现复杂度 |
|---|---|---|---|---|---|---|---|---|
| UUID v4 | 概率唯一 | 否 | 差 | 128 bit / 36 字符 | 高(本地) | 无 | 否 | 极低 |
| 数据库自增 | 是 | 严格递增 | 好 | 64 bit | 低(每号一次 DB) | 数据库 | 否 | 低 |
| 号段模式 | 是 | 趋势递增(会跳号) | 好 | 64 bit | 高 | 数据库 | 否 | 中 |
| Redis INCR | 运行期间是 | 严格递增 | 好 | 64 bit | 很高 | Redis | 否 | 低 |
| Snowflake | 是 | 趋势递增 | 中(可反解时间) | 64 bit | 高(本地) | workerId 分配机制 | 是 | 中高 |
| Leaf | 是 | 趋势递增 | 中 | 64 bit | 高 | MySQL 或 ZK | segment 否 / snowflake 是 | 中(有现成实现) |
分场景建议:
- 日志 TraceId、临时凭证:UUID,图省事即可。
- 中小规模、并发不高:数据库自增或小步长号段,运维成本最低。
- 高并发订单、支付流水:Snowflake 或 Leaf;已有 ZooKeeper 体系用 Leaf-snowflake 顺路,不想碰时钟问题就用 Leaf-segment 更稳。
- 已有 Redis 且场景可幂等兜底:INCR 最轻量,但别用在不能重复的金融主键上。
- 分库分表且希望 ID 携带路由信息:改造 Snowflake 嵌入分片基因,或号段配合独立路由字段。
面试视角
为什么分布式 ID 要趋势递增? 核心在 InnoDB 聚簇索引的物理结构:主键即数据组织顺序,递增主键让插入恒定追加到最右页;随机主键使插入点散布全树,引发随机读页、频繁页分裂和碎片。趋势递增不等于严格连续,跳号不影响索引友好性。
Snowflake 时钟回拨怎么解决? 先答根因:NTP 跳变、虚拟机挂起恢复等使系统时间小于上次发号时间,同毫秒序列号重放。再给方案:小回拨等待追回,大回拨抛错告警;进阶可借位记录回拨次数,或用上次发号时间继续发号;重启要持久化该时间戳做启动校验。提到 Leaf 借 ZooKeeper 校验时间、登记 workerId 更加分。
号段模式宕机会丢号吗? 会。实例领走的号段缓存在内存,宕机后未发出的号直接作废,表现为跳号。跳号不破坏唯一性,也不影响 B+ 树写入,多数业务可接受;绝对不要回收重发已领走的号段,那才会产生重复。
UUID 为什么不适合做 InnoDB 主键? 两条:一是无序,随机插入引发页分裂、碎片和写放大;二是偏长,128 bit 主键会出现在每个二级索引的叶子节点上,索引体积膨胀,缓冲池命中率下降。确实要用时也应以 BINARY(16) 存储,或改用时间有序的 UUID v7。
小结
- 分布式 ID 的核心矛盾:无全局锁下同时拿到唯一性、有序性、吞吐和可用性,每个方案都在这几项之间取舍。
- InnoDB 聚簇索引决定了”趋势递增”几乎是数据库主键的硬需求,UUID v4 这类随机 ID 天生不适合高写入表主键。
- 有状态方案(自增、号段、Redis)胜在简单直观,瓶颈与单点在外部存储;无状态的 Snowflake 胜在本地生成,代价是时钟回拨与 workerId 治理。
- Leaf、Tinyid 的共性优化是双 Buffer 异步预加载,把存储延迟从取号关键路径上摘掉;Leaf-snowflake 另用 ZooKeeper 接管 workerId 与时钟校验。
- 无论选哪种,落库唯一约束兜底、上线前对时钟回拨和故障切换做演练,是生产环境的及格线。

