K8s 滚动发布撞出重复 ID:雪花 workerId 冲突那天,订单表主键报了 3000 次 Duplicate
title: K8s 滚动发布撞出重复 ID:雪花 workerId 冲突那天,订单表主键报了 3000 次 Duplicate
topic: 分布式 ID 生成方案对比:雪花、号段、Leaf
batch: 4
round: 3
我们用雪花算法(Snowflake)生成订单号两年没出过问题,直到服务迁到 K8s 并开启滚动发布。某次发布后 10 分钟,订单表突然狂报 Duplicate entry for key 'PRIMARY',一查是不同 Pod 生成了完全相同的 ID。根因很扎心:我们用「IP 末位字节」当 workerId,而 K8s Pod 的 IP 在滚动期间短暂复用,新旧两个 Pod 拿到了同一个 workerId,时间戳和序列号又刚好撞上,ID 就重了。
这篇文章把雪花算法的位结构拆开,讲清楚 workerId 为什么是命门,再看工业级方案(号段、Leaf)是怎么绕过这个坑的。
雪花的 64 位长什么样
先上核心生成器,看清每一段位代表什么:
public class Snowflake {
private final long workerId;
private final long epoch = 1609459200000L; // 2021-01-01 自定义起点
private long lastTimestamp = -1L;
private long sequence = 0L;
public synchronized long nextId() {
long ts = System.currentTimeMillis();
if (ts < lastTimestamp) {
throw new RuntimeException("时钟回拨,拒绝生成"); // 时间不能倒流
}
if (ts == lastTimestamp) {
sequence = (sequence + 1) & 0xFFF; // 同毫秒内,序列 +1
if (sequence == 0) {
ts = waitNextMillis(lastTimestamp); // 本毫秒序列用尽,等下一毫秒
}
} else {
sequence = 0L; // 跨毫秒,序列归零
}
lastTimestamp = ts;
// 拼装:41 位时间戳 + 10 位 workerId + 12 位序列
return ((ts - epoch) << 22) | (workerId << 12) | sequence;
}
}
逐行解释:
- 第 11~12 行 如果当前毫秒和上次相同,序列号 +1 并掩码 0xFFF(12 位,最大值 4095)。同一毫秒最多能发 4096 个 ID,超了就等下一毫秒——这是单 worker 的吞吐上限。
- 第 18 行 真正的拼装:(ts - epoch) << 22 把相对时间戳左移到高 41 位,workerId << 12 放到中间 10 位,sequence 占低 12 位。三个字段任意两个不同就能保证 ID 唯一。
- 关键点:唯一性依赖「时间戳、workerId、序列」三者至少其一不同。workerId 相同的两个实例,只要在同一毫秒、同一序列号,就会生成完全相同的 ID——这正是我们的故障模型。
事故现场:workerId 从哪来
我们最初分配 workerId 的方式简单粗暴——取本机 IP 的最后一个字节:
public long resolveWorkerId() {
try {
String ip = InetAddress.getLocalHost().getHostAddress(); // 如 10.244.3.17
int lastOctet = Integer.parseInt(ip.split("\\.")[3]); // 取 17
return lastOctet % 1024; // 雪花 workerId 上限 1024
} catch (Exception e) {
return 0; // 取不到就退化成 0,多个实例一起退化 → 全撞
}
}
逐行解释:
- 第 3 行 getHostAddress 拿本机 IP。K8s 里 Pod IP 是 10.244.x.y 这种,滚动发布时旧 Pod 还没完全销毁、新 Pod 已被调度,短暂窗口内可能复用同一个 y。
- 第 4 行 取末位字节 17,第 5 行 % 1024 当作 workerId。
- 第 7 行 异常时退化成 0——更糟的是,一旦 DNS 解析抖动,所有实例都拿 0,workerId 全军覆没。
- 故障链条:旧 Pod(workerId=17)还在处理尾量请求,新 Pod 因 IP 复用也算出 workerId=17,两者时间戳接近、序列号从 0 起步,生成的 ID 必然重叠。订单表主键冲突就是这么来的。
解法一:用稳定分配中心发 workerId
workerId 必须「全局唯一且稳定」,不能靠 IP 推断。我们用 Redis 的 INCR 在启动时申请一个递增的 workerId,并写回 Pod 标识做核对:
public long allocateWorkerId(String podName) {
// 用 Redis 原子自增分配 0~1023 的 workerId,重启/扩容都从这里领号
Long id = redisTemplate.opsForValue().increment("snowflake:workerid:counter");
long workerId = id % 1024;
// 把 podName 绑到 workerId,便于排查冲突
redisTemplate.opsForValue().set("snowflake:worker:" + workerId, podName, 1, TimeUnit.HOURS);
return workerId;
}
逐行解释:
- 第 3 行 increment 是原子自增,每启动一个实例就领一个全新的号,从根本上避免「两个实例算同一个 workerId」。
- 第 4 行 % 1024 保证不越界(雪花 10 位 workerId 最多 1024 个)。
- 第 6 行 把 podName 写到 workerId 对应的 key 上,1 小时过期。这样如果真出现两个 Pod 抢到同一 workerId,我们能从 Redis 里翻出另一边的名字,定位冲突来源。
- 不足:Redis 不可用时会领不到号。生产上我们加了本地文件缓存上次领到的 workerId 作为降级,并打告警,绝不退化成 0。
解法二:Leaf 号段模式,彻底不要 workerId
workerId 之所以麻烦,是因为它把「实例身份」绑进了 ID。美团 Leaf 的号段(segment)模式换了个思路:ID 由数据库号段统一发,应用只管「取一段号本地自增」,完全不依赖机器位。
// Leaf 号段核心:内存里维护一段 [current, max] 可发 ID,用尽前异步拉下一段
public class SegmentBuffer {
private volatile long current; // 当前已发到的位置
private volatile long max; // 本段上限
private volatile boolean loadingNext = false;
public long nextId() {
if (current >= max && !loadingNext) {
loadingNext = true;
// 双 buffer:去 DB 申请下一段 [max, max+step],不阻塞当前发放
max = fetchNextSegmentFromDb(max); // 把 DB 里的 max 抬高 step
loadingNext = false;
}
return current++; // 纯内存自增,无锁、无时钟依赖
}
}
逐行解释:
- 第 9 行 当 current >= max(当前段快发完),触发去数据库拉下一段。Leaf 用「双 buffer」:一边发当前段,一边异步预取下一段,避免每次耗尽都卡一次 DB 往返。
- 第 14 行 current++ 是纯内存自增,没有雪花的时间戳、workerId、序列号概念,也就不存在 workerId 冲突和时钟回拨这两个天坑。
- 代价:ID 是递增趋势的(不是严格连续,段间有空洞),且 DB 是发号瓶颈(用双 buffer + 合理 step 缓解)。我们后来把下单链路的 ID 从雪花切到 Leaf 号段,发布再也没撞过重复。
三种方案怎么选
| 维度 | 雪花 Snowflake | 号段(DB 自增段) | Leaf(号段增强) |
|---|---|---|---|
| 依赖 | 机器时钟 + workerId | 数据库 | 数据库(双 buffer) |
| 重复风险点 | workerId 冲突 / 时钟回拨 | DB 发号单点 | DB 发号单点 |
| ID 特征 | 趋势递增、含时间 | 趋势递增、有空洞 | 趋势递增、有空洞 |
| 适合场景 | 容器化前、实例稳定 | 简单业务 | 高并发、频繁扩缩容 |
我们的结论:容器化、频繁扩缩容的环境下,雪花要把 workerId 交给稳定分配中心,否则早晚撞车;如果不想伺候 workerId 和时钟,直接上号段类方案最省心。
复盘的真实数字
- 故障影响:滚动发布 10 分钟内,订单表主键冲突
Duplicate报错 3127 次,约 0.7% 的下单失败。 - 根因:12 个 Pod 中 3 个在滚动窗口内复用了相同 IP 末位,workerId 重合,生成 ID 重叠。
- 修复后:Redis 中心化分配 workerId,发布期间全集群 workerId 零重复;后续下单链路切 Leaf 号段,连续 3 个月零主键冲突。
- 吞吐对比:雪花单机约 40 万 ID/秒(受序列 4096/ms 限制),Leaf 号段内存自增约 500 万 ID/秒,且不受时钟影响。
我自己的取舍判断
我不再建议新项目用「裸雪花 + IP 算 workerId」这种组合。它只在「物理机/实例 IP 稳定」的年代好用,到了 K8s 弹性环境就是定时炸弹。两条路二选一:要么雪花但 workerId 走中心化分配(ZK/Redis/数据库),要么直接用号段类方案把 workerId 这个变量彻底删掉。
说实话,很多人迷恋雪花「无中心、高性能」,却忽略了它的两个隐藏约束——workerId 全局不重、机器时钟不回拨——这俩在云原生环境下恰恰最难保证。ID 生成器是底层命脉,宁可牺牲一点性能换「绝不重复」,也比半夜被主键冲突叫醒强。
思考题
你现在的 ID 生成器,workerId 是从哪来的?如果明天凌晨集群滚动发布,会不会有两个实例悄悄共享了同一个 workerId?欢迎在评论区聊聊你的发号方案。
更多推荐

所有评论(0)