雪花、号段、Leaf 三种分布式 ID:我们日增 2 亿数据的表,最终换掉了雪花

发布时间:2026/7/29 15:38:20
雪花、号段、Leaf 三种分布式 ID:我们日增 2 亿数据的表,最终换掉了雪花 三年前我们的订单 ID 用的是雪花算法Snowflake跑了两年一直没问题。直到业务量冲到日增 2 亿条机器扩容、容器频繁重建时钟回拨开始密集出现订单 ID 出现重复对账直接崩了。那次之后我们系统性对比了雪花、号段、Leaf 三种方案最后切到了 Leaf-segment。这篇把三种方案的原理、代码、各自的坑讲清楚并附上我们选型时的真实数据。为什么不能用数据库自增先说为什么需要分布式 ID分库分表后单库自增 ID 会撞车两个库都从 1 开始而且自增暴露了业务量竞争对手能靠订单号猜你一天卖多少。分布式 ID 一般要求全局唯一、趋势递增BTree 索引友好、高性能、最好带时间信息。// 不推荐分库分表后自增 ID 直接撞车 TableId(type IdType.AUTO) // 1. 单库自增分到库2又从1开始 private Long id; // 库1 插入 id1库2 也插入 id1 - 主键冲突或跨库乱序逐行第 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. 同一毫秒序列号112位上限4095 if (sequence 0) { // 4. 本毫秒用尽等下一毫秒 timestamp waitNextMillis(lastTimestamp); } } else { sequence 0; // 5. 新毫秒序列号归零 } lastTimestamp timestamp; return ((timestamp - EPOCH) 22) // 6. 时间戳左移22位1012 | (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号段 SegmentLeaf-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 重复我能承受吗答案决定选型而不是哪个听起来更先进。

相关新闻