三年前我们的订单 ID 用的是雪花算法(Snowflake),跑了两年一直没问题。直到业务量冲到日增 2 亿条,机器扩容、容器频繁重建,时钟回拨开始密集出现,订单 ID 出现重复,对账直接崩了。那次之后我们系统性对比了雪花、号段、Leaf 三种方案,最后切到了 Leaf-segment。这篇把三种方案的原理、代码、各自的坑讲清楚,并附上我们选型时的真实数据。
为什么不能用数据库自增
先说为什么需要分布式 ID:分库分表后,单库自增 ID 会撞车(两个库都从 1 开始),而且自增暴露了业务量(竞争对手能靠订单号猜你一天卖多少)。分布式 ID 一般要求:全局唯一、趋势递增(B+Tree 索引友好)、高性能、最好带时间信息。
// 不推荐:分库分表后自增 ID 直接撞车 @TableId(type = IdType.AUTO) // 1. 单库自增,分到库2又从1开始 private Long id; // 库1 插入 id=1,库2 也插入 id=1 -> 主键冲突或跨库乱序逐行:第 1 行IdType.AUTO依赖数据库自增,分库后每个库各自计数,库间必然重复,且无法跨库排序。所以必须上分布式 ID 生成器,下面三种是主流。
雪花算法:64 位里塞进时间、机器、序号
雪花把 64 位 long 拆成:1 位符号(恒 0)+ 41 位时间戳 + 10 位机器 ID + 12 位序列号。同一毫秒内靠序列号区分,不同毫秒靠时间戳递增。
// 雪花算法核心(简化版,便于理解) public synchronized long nextId() { long timestamp = System.currentTimeMillis(); // 1. 当前毫秒 if (timestamp < lastTimestamp) { // 2. 时钟回拨 -> 直接抛异常 throw new RuntimeException("时钟回拨,拒绝生成"); } if (timestamp == lastTimestamp) { sequence = (sequence + 1) & 4095; // 3. 同一毫秒,序列号+1(12位上限4095) if (sequence == 0) { // 4. 本毫秒用尽,等下一毫秒 timestamp = waitNextMillis(lastTimestamp); } } else { sequence = 0; // 5. 新毫秒,序列号归零 } lastTimestamp = timestamp; return ((timestamp - EPOCH) << 22) // 6. 时间戳左移22位(10+12) | (workerId << 12) // 7. 机器ID左移12位 | sequence; // 8. 拼上序列号 }逐行:第 2 行处理时钟回拨——这是雪花最大的雷,下面专门讲;第 3 行同一毫秒内序列号递增,最多 4096 个/毫秒;第 6-8 行把三段按位拼成 64 位 long。单机能做到约 400 万 ID/秒(4096 × 1000),性能足够。但它依赖机器时钟单调,而时钟回拨在容器化环境里太常见了。
我们那天翻车的具体场景:K8s 节点重启后,NTP 把系统时间往回拨了 3 秒,多个 Pod 的 workerId 又因为配置失误重复,结果生成了和 3 秒前完全相同的 ID,两笔订单主键冲突,下游对账直接报错停服。时钟回拨 + workerId 重复,是雪花的两大杀手。
号段模式:一次取一段,本地慢慢发
号段模式(segment)的思路是:不在每次生成时访问 DB,而是从 DB 一次性申请一段区间(比如 [1, 1000]),在本地内存里挨个发,发完了再去 DB 取下一段。DB 压力从"每次一条"降到"每 1000 条一次"。
// 号段模式核心逻辑(简化) class Segment { private long cur; // 1. 当前已发到哪 private long max; // 2. 本段上限(从 DB 申请的 maxId) private long step = 1000; // 3. 每次申请 1000 个 } public synchronized long nextId() { if (cur >= max) { // 4. 本段用尽,去 DB 取下一段 // UPDATE id_segment SET max_id = max_id + step WHERE biz = 'order' // 拿到新区间 [oldMax, newMax] cur = dbOldMax + 1; max = dbNewMax; } return cur++; // 5. 本地自增返回,无 DB 交互 }逐行:第 4 行号段用尽才去 DB 申请下一段,平时nextId()只是内存自增,性能极高、不依赖时钟;第 5 行返回并自增。号段的优势是不怕时钟回拨(ID 来自 DB 自增区间),缺点是一旦 DB 挂了、当前段又正好发完,就生成不了——可用性受 DB 约束。另外号段有"号浪费":服务重启没发完的号段就丢了,但这是可接受的代价。
Leaf:把两种方案都做成服务
美团开源的 Leaf 把上面两种做成可切换的服务。我们用的是Leaf-segment,它在号段基础上加了"双 buffer"——当前段用到一定比例(比如 10%)就异步去预取下一个段,避免"段用尽才去 DB"那一下卡顿。
// Leaf-segment 双 buffer(逻辑还原) class SegmentBuffer { Segment[] segments = new Segment[2]; // 1. 两段缓冲 int currentPos = 0; // 2. 当前在用哪段 boolean nextReady = false; // 3. 下一段是否已预取好 } public long nextId() { Segment cur = segments[currentPos]; if (cur.cur >= cur.max) { // 4. 当前段用完,切到下一段 if (nextReady) { // 5. 下一段已就绪,直接切换 currentPos ^= 1; nextReady = false; asyncLoadNext(); // 6. 异步预取下下一段 } else { Thread.sleep(1); // 7. 还没就绪,短暂等待(极少触发) } } return segments[currentPos].nextId(); }逐行:第 3 行nextReady标记下一段是否备好;第 5 行当前段用尽且下一段已就绪就无缝切换;第 6 行切换后立刻异步去取新的下一段,保证"永远有一段在内存候着"。这把号段的"段用尽卡顿"彻底抹平了。我们压测时单实例 Leaf-segment 轻松到 5 万 ID/秒,且 DB 每秒只被访问几次。
Leaf 还提供Leaf-snowflake,用 ZooKeeper/雪花 workerId 分配 + 时钟回拨时的"借号"策略缓解雪花痛点(回拨一小段时间内用扩展位发号、记录回拨时长告警)。但 workerId 仍需外部协调,我们没选它。
三种方案横向对比
| 维度 | 雪花 Snowflake | 号段 Segment | Leaf-segment |
|---|---|---|---|
| 趋势递增 | 是(按毫秒) | 是 | 是 |
| 依赖时钟 | 强依赖(回拨致命) | 不依赖 | 不依赖 |
| 性能 | 极高(纯本地) | 极高(本地+偶尔DB) | 极高(双 buffer 平滑) |
| 可用性依赖 | 仅需本地时钟 | 依赖 DB 段未耗尽 | 依赖 DB 段未耗尽 |
| 部署复杂度 | 低(嵌应用) | 中(需 DB 表) | 中高(独立服务 + DB) |
| 主要风险 | 时钟回拨、workerId 冲突 | DB 挂+段耗尽、号浪费 | DB 挂+段耗尽、号浪费 |
一句话:雪花最轻量但最怕时钟回拨,号段/Leaf 不怕时钟但依赖 DB 且不轻量。如果你的机器时钟可信、部署稳定,雪花够用;一旦上容器、频繁扩缩容,雪花的风险会非线性放大。
我的取舍:日增 2 亿,我们选 Leaf-segment
我的观点很直接:容器化、高并发、对 ID 重复零容忍的业务,别用裸雪花。我们当初坚持用雪花是"嫌 Leaf 要起服务、多一个依赖",结果时钟回拨那次停服 2 小时,损失比多运维一个 Leaf 服务大得多。切到 Leaf-segment 后,我们做了三件事:
- 独立部署 Leaf 集群(至少 2 个节点 + DB 主从),ID 生成不再和业务的容器生命周期绑定,容器重建不影响发号;
- DB 段表单独库,不和业务库混,避免业务库抖动拖垮发号;
- 监控号段消耗速率,在段快耗尽前告警,杜绝"段耗尽 + DB 抖动"双重打击。
如果业务量小、部署简单、机器时钟稳定(物理机、有 NTP 防护),我仍然推荐雪花——它零依赖、性能好、调试直观。但凡上云、上容器、日增量过千万,我建议直接上 Leaf-segment,把时钟风险从根上移出 ID 生成链路。
补充一句:Leaf-segment 的段步长(step)也别拍脑袋,步长太小 DB 访问频繁,太大重启丢号多。我们按"单节点峰值 QPS × 60 秒"估算,线上 step 设为 50 万,DB 每分钟才被访问一次,单节点重启最多浪费 50 万号,在日增 2 亿的量级下完全可接受。另外 Leaf 的 DB 段表建议用独立的 biz_tag 区分不同业务线,避免一个业务把段用爆影响其他业务的发号。
思考题
假设你的 workerId 用"IP 后 10 位取模 1024"生成,容器重启后 IP 变了导致 workerId 和上一次重复,同时发生时钟回拨。这时雪花算法会分别触发什么问题?如果用 Leaf-segment,这两类问题还存在吗?
写在最后
雪花、号段、Leaf 不是谁"更好",是适用面不同。雪花轻量但怕时钟回拨和 workerId 冲突;号段/Leaf 用"内存发号 + DB 批量取段"绕开时钟问题,代价是多一个服务依赖。我们日增 2 亿的表最终选 Leaf-segment,是因为容器化让雪花的风险变得不可控。选 ID 方案先问自己:我的部署环境时钟可信吗?ID 重复我能承受吗?答案决定选型,而不是哪个"听起来更先进"。