sn: 19
batch: 4
round: 9
topic: 分布式 ID 生成方案对比:雪花、号段、Leaf
订单号重复这事,多数人第一反应是"不可能,雪花算法很成熟"。但我们真出过:机房迁移后,两台机器的 workerId 被配成了同一个值(模板化的启动配置漏改一个数字),当天下午 3 点起所有秒变成同一毫秒序列的两个节点,生成了 3000 多条重复订单号,下游以订单号为唯一键的支付回调全部错乱。排查两小时,修复一分钟——但那两小时的回溯代价,逼我把分布式 ID 的每个坑位都重新过了一遍。
这篇文章不背概念,把我实际在用的三套方案(雪花、号段、美团 Leaf)的取舍标准和踩坑清单摊开讲。
雪花算法:64 个 bit 里的三层防冲突
标准雪花结构是 1 位符号 + 41 位毫秒时间戳 + 10 位机器 ID + 12 位序列号,单机每毫秒可发 4096 个。它的可靠性建立在两个前提上:机器 ID 全局唯一、时钟不回拨。两个前提在生产环境都不天然成立。
先看机器 ID 的获取方式对比:
| 获取方式 | 可靠性 | 运维成本 | 我的使用建议 |
|---|---|---|---|
| 配置文件手写 | 低,容易复制粘贴出错 | 低 | 小团队 <5 台机器 |
| ZooKeeper 顺序节点 | 高,天然去重 | 中,多一个强依赖 | 已有 ZK 的架构 |
| 数据库分配表 | 高,可审计 | 中 | 大多数业务的稳妥选择 |
| Redis 自增 | 高 | 中 | 已有 Redis 且可容忍偶发不可用 |
| K8s StatefulSet 序号 | 高 | 低 | 云原生部署首选 |
我们那次事故后改成了数据库分配表方案,机器启动时插入一行获取 workerId,带租约心跳,超时自动回收。核心表结构加一段领取逻辑:
@Transactional(rollbackFor = Exception.class) public long acquireWorkerId(String ip) { // 1. 先查这个 ip 是否已领取过(处理重启场景) WorkerIdLease exist = leaseMapper.selectByIp(ip); if (exist != null && !isExpired(exist)) { // 2. 未过期的租约直接续期复用,workerId 保持稳定 leaseMapper.renew(exist.getId()); return exist.getWorkerId(); } // 3. 查找一个当前未被有效租约占用的 workerId WorkerIdLease free = leaseMapper.selectFreeWorkerId(); if (free == null) { throw new IllegalStateException("workerId 已耗尽,1024 个全被占用"); } // 4. 抢占式更新:把 workerId 分配给当前 ip,乐观锁防并发分配 int updated = leaseMapper.assign(free.getWorkerId(), ip, now(), now() + LEASE_MS); if (updated == 0) { // 5. 分配失败说明并发抢占,重试 throw new RetryableException("workerId 并发冲突,请重试"); } return free.getWorkerId(); }逐行说:第 1-2 行处理重启复用——同 ip 重启时优先拿回原来的 workerId,避免 id 空洞和下游缓存失效;第 3 行 selectFreeWorkerId 的 SQL 是"租约过期时间 < 当前时间"的记录,过期租约自动回收;第 4 行用乐观锁更新(where 条件里带原持有者),两个节点同时领同一个 id 时只有一个成功;第 5 行的冲突重试要有限次,避免死循环。这套机制上线后,workerId 冲突的事故再没发生过——它把"人肉保证唯一"变成了"系统强制唯一"。
时钟回拨:不只是"等一等"那么简单
时钟回拨的经典处理是"回拨小于阈值就自旋等待",但这个方案只对秒级以内的 NTP 微调有效。我们遇到过一次运维误操作把某台机器时间改了 3 分钟(同步配置时手滑),重启后雪花 ID 的时间戳部分落后于历史生成值,继续生成会和 3 分钟前的 ID 重复。
更稳的处理组合:启动时把当前时间戳与最近一次生成的 ID 时间戳比对,如果当前更小,说明回拨,直接拒绝启动并告警;运行期检测到小幅回拨(<2 秒),自旋等待 + 扩展位方案(把序列号位借几位存回拨次数);大幅回拨直接停服。美团 Leaf 的snowflake模式用的是 ZooKeeper 存每台机器的启动时间戳,启动时校验,思路类似。
号段模式与 Leaf 的双缓冲:高并发下的正确姿势
雪花依赖时钟,号段模式完全绕开:每次从 DB 拉一段 id 区间缓存在内存里用完再拉。但朴素号段有个致命问题——DB 更新号段时的行锁独占,QPS 一高,拉号段的请求排队,表现为"每消耗完一批 id 就抖一下"。美团 Leaf 的双缓冲(double buffer)就是治这个的:
public class DoubleBufferIdGenerator { private volatile Segment current; // 1. 当前正在消耗的号段 private volatile Segment next; // 2. 预加载的下一个号段 private final AtomicBoolean loading = new AtomicBoolean(false); public long nextId() { while (true) { Segment seg = current; long id = seg.next(); if (id > 0) { // 3. 当前号段还有余量,直接消费 maybeLoadNext(seg); return id; } // 4. 当前号段耗尽,切换到预加载好的 next if (next != null) { current = next; next = null; continue; } // 5. next 也没了(冷启动),同步拉取 current = fetchFromDb(); } } private void maybeLoadNext(Segment seg) { // 6. 消耗超过 10% 时触发异步预加载,同时只允许一个线程在拉 if (seg.remainRatio() < 0.9 && loading.compareAndSet(false, true)) { executor.execute(() -> { try { next = fetchFromDb(); } finally { loading.set(false); } }); } } }逐行拆:第 1-2 行两个缓冲区用 volatile 保证可见性,切换是无锁的原子赋值;第 6 行是双缓冲的精髓——不等号段用完才拉,而是消耗到 10% 剩余就开始异步拉下一批,把 DB 取号段的延迟藏到业务无感的位置;compareAndSet保证并发下只有一个线程触发预加载,其他线程照常消费当前号段,零阻塞。
Leaf 还有个值得学的细节:号段长度动态调整。流量低时号段短(减少重启浪费),流量高时号段长(减少 DB 压力),根据最近一次号段消耗时长自适应。
三方案怎么选:我的决策树
| 维度 | 雪花 | 号段/Leaf | 数据库自增 |
|---|---|---|---|
| 趋势递增 | 是(受时钟影响) | 是(严格递增) | 是 |
| 单机吞吐 | 400 万+/秒 | 受号段长度影响,可达十万+/秒 | 低 |
| 依赖 | 时钟 + workerId 分配 | DB(双缓冲后压力小) | 单点 DB |
| ID 信息泄露 | 低(无规律可猜部分) | 中(可猜测号段间隔) | 高 |
| 典型故障 | 时钟回拨、workerId 冲突 | DB 抖动、重启丢号段 | DB 瓶颈 |
我的实际选择:对外暴露的订单号、支付单号用 Leaf 号段(严格递增利于 DB 排序,且不依赖时钟);内部 traceId、日志 ID 用雪花(吞吐高,不参与唯一键约束);两者都不直接当主键裸奔——主键用自增,业务 ID 单独一列加唯一索引,这样换 ID 方案时不用动表结构。
运行期的时钟回拨检测:别指望 NTP 替你兜底
启动校验之外,运行期也要持续检测。我们的雪花生成器里加了这段防御:
public synchronized long nextId() { long now = System.currentTimeMillis(); if (now < lastTimestamp) { long offset = lastTimestamp - now; if (offset <= 2000) { // 1. 小幅回拨(≤2 秒):自旋等到追上上次时间戳 // NTP 的正常微调幅度在这个范围内,等待代价可接受 try { wait(offset << 1); // 等待 2 倍回拨时长 } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException("时钟回拨等待被中断", e); } now = System.currentTimeMillis(); if (now < lastTimestamp) { // 2. 等完还没追上,说明不是 NTP 微调,拒绝生成 throw new ClockBackwardException(offset); } } else { // 3. 大幅回拨:直接熔断 ID 生成,告警 + 拒绝服务 // 宁可上游拿到异常触发降级,也不能吐重复 ID alertAndFuse(offset); throw new ClockBackwardException(offset); } } // 4. 同一毫秒内序列号打满,自旋到下一毫秒 if (now == lastTimestamp) { sequence = (sequence + 1) & SEQUENCE_MASK; if (sequence == 0) { now = tilNextMillis(lastTimestamp); } } else { sequence = 0; } lastTimestamp = now; // 5. 组装 64 位 ID:时间戳左移 22 位 | 机器 ID 左移 12 位 | 序列号 return ((now - EPOCH) << TIMESTAMP_SHIFT) | (workerId << WORKER_ID_SHIFT) | sequence; }逐行拆决策依据:第 1 行的 2 秒阈值来自 NTP 步进微调的典型幅度,等待 4 秒内是业务可容忍的;第 3 行的大幅回拨熔断是我们的立场选择——重复 ID 进入支付链路的代价是资金错乱,服务短暂不可用反而是更便宜的故障;第 5 行的 EPOCH 要用自定义基准(比如服务上线日),不要用 Twitter 默认的 2010 年,41 位时间戳的余量是从 EPOCH 起算的,起点越晚未来可用年限越长。
另外补一个运维细节:所有实例的 ID 生成器接 Prometheus 指标(回拨次数、序列号打满频率、单毫秒峰值),序列号打满说明单机容量逼近 4096/毫秒上限,该分片了——容量水位靠指标预警,不靠事故发现。
思考题
双缓冲号段模式下,应用进程在"当前号段用完、next 尚未拉回"的瞬间崩溃重启,会浪费最多一个号段的 id(假设号段 1 万个)。如果业务对 id 连续性有要求(比如发票号),你会怎么改造这个方案?