ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

workerId 配重复导致 3000 条重复订单号:分布式 ID 的时钟回拨与号段双缓冲,我用过血换来的配置清单

workerId 配重复导致 3000 条重复订单号:分布式 ID 的时钟回拨与号段双缓冲,我用过血换来的配置清单 sn: 19batch: 4round: 9topic: 分布式 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双缓冲后压力小单点 DBID 信息泄露低无规律可猜部分中可猜测号段间隔高典型故障时钟回拨、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 连续性有要求比如发票号你会怎么改造这个方案
返回列表