Snowflake 雪花算法 ID 重复排查:workerId 配错,两台机器生成了一样的 ID

8 阅读11分钟

老炮踩坑录 · F14 · 翻车现场系列

· 基于「企业融合评估平台」真实源码

· 完整复盘雪花算法 workerId 撞车事故

· 关键词:ID 重复 · workerId 撞车 · 唯一性崩塌 · 动态分配

· 上一篇:雪花算法看起来简单,但41位时间戳里藏了三个坑,我全踩了一遍

引子:上篇里我说了句"迟早有人忘记改",结果真发生了

上一篇拆解雪花算法源码的时候,我指着 application.yml 里这行配置说了一句话:

"凡是靠人记着改的配置,迟早有人忘记改。"

当时我觉得这就是个"理论风险"——毕竟雪花算法的 workerId 是 5 位,最多 32 个值,项目里就一两台机器,撞上的概率能有多大?

结果真撞了。

而且撞的方式比我想象的更隐蔽:不是两台机器同时启动,而是一台机器重启之后,ID 开始和另一台机器重复。结果我排查了整整两天,最后发现原因简单到让人想笑——运维拷配置文件的时候,忘了改 workId。

今天这篇,我把这个坑的前因后果完整讲一遍:怎么发现的、怎么排查的、怎么修改的。

事故现场:数据库里出现了重复的主键

现象

某天的早上,运维在群里丢了一条消息:

"线上有个表报 DuplicateKeyException,主键冲突了。"

我的第一反应是:不可能。

这个项目用的雪花算法生成 ID,64 位 long,理论上全局唯一、趋势递增。主键冲突?除非数据库自增主键和雪花 ID 混用了。

但是查了一下,这张表的主键确实是雪花算法生成的 long 型 ID。

确认问题

于是我连上数据库,跑了一条 SQL语句:

SELECT id, COUNT(*) as cnt 
FROM apply_info 
GROUP BY id 
HAVING cnt > 1;

结果出来了——真的有重复 ID,而且不止一条:

+------------------+-----+---------------------+
| id               | cnt | first_seen          |
+------------------+-----+---------------------+
| 1198377740902400 |   2 | 2022-07-15 09:23:11 |
| 1198401256783872 |   2 | 2022-07-15 15:47:33 |
| 1198523904521216 |   2 | 2022-04-16 08:12:05 |
| 1198687432196096 |   2 | 2022-03-17 14:38:22 |
| 1199012847632384 |   2 | 2022-01-19 10:05:47 |
+------------------+-----+---------------------+
5 rows in set (0.03 sec)

更诡异的是,这些重复 ID 不是连续的,而是分散在不同时间段。如果是时钟回拨导致的重复,ID 应该是集中在某个时间段的。但这次的重复 ID 时间跨度很大,说明两台机器在持续地生成相同的 ID。


排查过程:从怀疑到确认

第一步:排除时钟回拨

上篇里讲过,雪花算法有一个时钟回拨检测:

if (timestamp < lastTimestamp) {
    throw new RuntimeException(
        String.format("Clock moved backwards. Refusing to generate id for %d milliseconds",
            lastTimestamp - timestamp));
}

如果时钟回拨,直接抛异常,不会生成 ID。所以时钟回拨不会导致重复 ID,只会导致服务不可用。

因此排除这种情况。

第二步:检查 workerId 配置

雪花算法的 "全局唯一性" 保证,是建立在每台机器的 workerId 不同这个前提之上的。

如果两台机器的 workerId 相同,那么它们在同一毫秒内、用相同的序列号,就会生成完全一样的 ID。

因此去查两台机器的 application.yml:

机器 A:

pubframe:
  id:
    workId: 10
    centerId: 20

机器 B:

pubframe:
  id:
    workId: 10    # 和机器 A 一样!
    centerId: 20  # 也一样!

找到问题了。

第三步:还原事故现场

于是问了下做运维的同事,把整个过程还原下:

  1. 项目最初只有一台机器(机器 A),workId: 10
  2. 后来要扩容,运维直接拷了机器 A 的配置文件到机器 B
  3. 还改了数据库地址、改了端口,但就是忘了改 workId了
  4. 机器 B 上线后,两台机器用相同的 workId=10, centerId=20 运行
  5. 同一毫秒内,两台机器各自从 sequence=0 开始递增,生成的 ID 完全一样

用上一篇文章里的公式推算一下:

机器 A 在 T 毫秒生成:((T - twepoch) << 22) | (20 << 17) | (10 << 12) | seq
机器 B 在 T 毫秒生成:((T - twepoch) << 22) | (20 << 17) | (10 << 12) | seq
                      ↑ 完全一样

workerId 和 centerId 都相同,时间戳相同,序列号从 0 开始递增——生成的 ID 100% 重复。

代码级推演:为什么一定会重复?

最终生成 ID 的就 nextId() 里这一行:

return ((timestamp - twepoch) << 22) 
        | (datacenterId << 17)
        | (workerId << 12) 
        | sequence;

4 个输入变量:timestamp、datacenterId、workerId、sequence。只要这 4 个值相同,输出一定相同——这是纯数学计算,没有任何随机性。

假设两台机器都是 workerId=10, datacenterId=20,在 T = 1705276800000 这个毫秒同时收到请求。

初始状态:

变量机器 A机器 B
sequence00
lastTimestamp-1-1
workerId1010
datacenterId2020

sequence 和 lastTimestamp 是实例变量,每台机器各自维护,互不影响。

执行流程对比:

步骤代码机器 A机器 B相同?
① 获取时间戳long timestamp = timeGen()TT✅
时钟回拨检查timestamp < lastTimestamp → T < -1?否否✅
③ 同毫秒判断lastTimestamp == timestamp → -1 == T?否否✅
④ 走 else 分支sequence = 0L00✅
⑤ 记录时间戳lastTimestamp = timestampTT✅
⑥ 拼装 ID公式计算见下方见下方✅

每一步的输入和分支走向都完全一样,所以输出必然一样。

代入公式:

机器 A:
  (T - 1420041600000) << 22  =  285235200000 << 22  =  1198377738240000
  (20 << 17)                 =  2621440
  (10 << 12)                 =  40960
  sequence = 0               =  0
  ─────────────────────────────────────────────
  按位或拼装                  =  1198377740902400

机器 B:
  输入完全一样 → 结果完全一样  =  1198377740902400  ← 重复!

为什么序列号不会"错开"?

你可能会想:机器 A 先拿到 sequence=0,机器 B 后拿到 sequence=1,不就错开了吗?

不会。 因为 sequence 只在同一毫秒内多次调用时才递增:

if (lastTimestamp == timestamp) {
    sequence = (sequence + 1) & sequenceMask;  // 同一毫秒才 +1
} else {
    sequence = 0L;  // 新毫秒,重置为 0
}

两台机器都是"新毫秒的第一次调用",lastTimestamp(-1) != T,都走 else 分支,sequence 都被重置为 0。

调用次序机器 A 的 sequence机器 B 的 sequenceID 是否重复?
A 第 1 次 vs B 第 1 次00重复
A 第 2 次 vs B 第 1 次10不重复
A 第 2 次 vs B 第 2 次11重复

只要两台机器在同一个毫秒内的"第 N 次调用"对上,ID 就重复。请求量越大,撞上的概率越高。

一句话总结:

雪花算法的 ID = f(timestamp, datacenterId, workerId, sequence),是一个纯确定性函数。两台机器 workerId 和 datacenterId 相同,同一毫秒内 sequence 都从 0 开始递增——相同的输入必然产生相同的输出,没有任何随机因子可以"错开。


为什么这个问题这么隐蔽?

不是每次都会重复

雪花算法的序列号是每毫秒重置的。两台机器只有在同一毫秒内、序列号也相同时才会生成重复 ID。

如果两台机器的请求量不大,同一毫秒内都在生成 ID 的概率其实不高。这就是为什么:

  • 机器 B 上线后,不是立刻出现重复 ID
  • 重复 ID 是分散在不同时间段的
  • 问题潜伏了很久才被发现

构造函数只校验了范围,没校验唯一性

public SnowflakeIdGenerator(long workerId, long datacenterId) {
    if (workerId > maxWorkerId || workerId < 0) {
        throw new IllegalArgumentException(
            String.format("worker Id can't be greater than %d or less than 0", maxWorkerId));
    }
    // ...
}

构造函数只检查了 workerId 是否在 0~31 范围内,没有检查是否和其他机器重复。

这就像你考驾照,只检查了年龄是否够 18 岁,没检查是不是已经有人用了同一个驾照号。


怎么改?

临时方案:手动改配置

最直接的办法:把机器 B 的 workId 改成 11。

# 机器 B 的 application.yml
pubframe:
  id:
    workId: 11    # 改成 11,和机器 A 的 10 不同
    centerId: 20

改完重启,重复 ID 的问题立刻消失。

但这个方案治标不治本——下次再加一台机器,运维可能又忘了改。

根本方案:动态分配 workerId

凡是靠人记着改的配置,迟早有人忘记改。 正确的做法是让 workerId 自动分配。

方案一:ZooKeeper 临时节点

@Bean
public SnowflakeIdGenerator snowflakeIdWorker(ZooKeeper zk) throws Exception {
    // 在 ZK 上创建一个临时顺序节点
    String path = zk.create("/snowflake/worker-", 
        new byte[0], 
        ZooDefs.Ids.OPEN_ACL_UNSAFE, 
        CreateMode.EPHEMERAL_SEQUENTIAL);
    
    // 从节点名里提取序号作为 workerId
    String seqStr = path.substring(path.lastIndexOf("-") + 1);
    long workerId = Long.parseLong(seqStr) % 32;  // 取模保证在 0~31 范围内
    
    log.info("Auto-assigned workerId: {} from ZK path: {}", workerId, path);
    return new SnowflakeIdGenerator(workerId, 0);
}

原理:每个实例启动时,在 ZK 上注册一个临时节点。ZK 保证节点名全局唯一,从中提取序号作为 workerId。实例宕机后临时节点自动删除,workerId 释放。

优势:

  • 全自动,不需要人改配置
  • 实例重启后自动重新分配
  • ZK 保证全局唯一

方案二:基于 IP 地址计算

@Bean
public SnowflakeIdGenerator snowflakeIdGenerator() throws UnknownHostException {
    InetAddress addr = InetAddress.getLocalHost();
    byte[] ip = addr.getAddress();
    
    // 取 IP 地址的第三段和第四段,各取低 5 位
    long workerId = ip[2] & 0x1F;      // 0~31
    long datacenterId = ip[3] & 0x1F;  // 0~31
    
    log.info("Auto-assigned workerId: {}, datacenterId: {} from IP: {}", 
        workerId, datacenterId, addr.getHostAddress());
    return new SnowflakeIdGenerator(workerId, datacenterId);
}

原理:不同机器的 IP 地址不同,从 IP 地址里提取 workerId 和 datacenterId。

优势:

  • 不需要额外的基础设施(ZK/Redis)
  • IP 地址天然唯一
  • 实现简单

风险:

  • 如果 IP 地址的第三段和第四段的高位相同,低 5 位可能撞车
  • 容器环境下 IP 可能复用

方案三:Redis 自增

@Bean
public SnowflakeIdGenerator snowflakeIdWorker(RedisTemplate<String, Object> redis) {
    // 用 Redis 的 INCR 命令获取全局唯一的 workerId
    long workerId = redis.opsForValue().increment("snowflake:workerId:counter") % 32;
    
    // 用 Redis SETNX 做幂等,防止重启后重复分配
    String key = "snowflake:workerId:" + workerId;
    Boolean acquired = redis.opsForValue().setIfAbsent(key, "locked", 1, TimeUnit.HOURS);
    if (!acquired) {
        throw new IllegalStateException("workerId " + workerId + " already in use");
    }
    
    log.info("Auto-assigned workerId: {} from Redis", workerId);
    return new SnowflakeIdGenerator(workerId, 0);
}

原理:用 Redis 的 INCR 命令获取全局递增的序号,取模后作为 workerId。用 SETNX 做幂等校验,防止重复分配。


这个坑给我的教训

配置即代码,但配置不是人脑

workId: 10 写在 yml 里,看起来是个配置,但它本质上是分布式系统的身份标识。

身份标识不应该由人来分配,应该由系统自动分配。就像你不会让人工给每台服务器手动分配 MAC 地址一样。

唯一性需要系统级保证

雪花算法的"全局唯一性"是一个系统级承诺,不能只靠算法本身保证。

  • 算法层面:位结构设计保证理论上不重复
  • 配置层面:workerId 分配保证实际上不重复
  • 监控层面:启动时校验 + 运行时告警保证出了问题能发现

这个项目只做到了第一层,第二层和第三层都是空的。

生产环境 Checklist

部署雪花算法时,逐项检查:

检查项本项目状态正确做法
workerId 是否全局唯一❌ yml 写死ZK/Redis 自动分配
启动时是否校验 workerId 冲突❌ 无SETNX / 临时节点
是否有 workerId 监控告警无启动日志 + Prometheus
数据库主键是否有唯一索引兜底✅ 有保持,最后一道防线

最后一道防线:即使生成端真的出了重复,数据库表上的唯一索引仍然能挡住它。重复插入会报错而不是静默覆盖,把损失控制在"报错重试"而不是"数据错乱"。

老炮点评

这个坑的本质不是技术问题,是工程习惯问题。

  • 拷配置文件忘记改 → 习惯问题
  • 没有启动校验 → 设计问题
  • 没有监控告警 → 运维问题

技术上的解决方案很简单:动态分配 workerId。但真正要解决的是 "靠人记着的事,迟早会忘" 这个工程铁律。

凡是靠人记着改的配置,迟早有人忘记改。 这句话我已经说了两遍,我认为值得说第三遍。


下期预告:《AI 三分钟重构1600行Controller,方案看起来很专业,但我一条都没敢用》

在第二篇中把项目跑起来了,我第一件事就是把那个1600行的Controller扔给了AI,让它出重构方案。 仅仅三分钟,它列出了6条建议,看起来头头是道。其中一条建议,一看就知道如果真按它说的做,生产环境非出大事不可。

下期讲人机对决的完整过程。AI能重构老项目吗?我的答案可能和你想的不一样。

我是老炮,18 年 Java 老兵,仍在一线。关注「Java老炮踩坑录」,不错过每一篇真实案例,少踩坑。