Skip to content

订单号重复了:雪花算法依赖的三件事 ​

订单表的唯一索引报了冲突:两笔不同的订单拿到了同一个订单号。生成订单号的代码用的是雪花算法,上线之后没有改过,单元测试里连续生成多少个都不重复。

雪花算法把一个 64 位整数分成三段:时间戳、机器号、序列号。它不访问任何外部服务,每个实例自己就能生成,所以快,也所以没有任何一个地方能替它检查「这个 ID 别人有没有用过」。唯一性完全建立在三个前提上,前提不成立时生成器照常返回,不报错。

本文把三个前提逐个打破,看各自重复多少。实验见文末。

64 位 ID 的布局(常见分法)041 位:距离纪元的毫秒数10 位:机器号12 位:序列号依赖的前提前提被打破时时间戳本机时钟不往回走,重启前后也一样回拨 5 ms:2000 个 ID 里重复 500 个机器号同一时刻每个实例各用一个号两个实例同号:同一毫秒的 100 个全部重复序列号同一毫秒内不超过 4096 个一毫秒 5000 个、序列号回绕:重复 904 个三段之间互不校验:机器号重复时,时间戳和序列号照常递增,没有任何一处会报错能兜住的只有下游:订单号列上的唯一索引
图 1 · 实测:ID 的唯一性来自三段拼在一起,每一段各依赖一个前提。时间戳段要求本机时钟不往回走,机器号段要求同一时刻没有两个实例用同一个号,序列号段要求同一毫秒内的生成量不超过 4096 个。三个前提分别被打破时,生成器本身不报错,重复的 ID 直接发了出去。

一、三段各管什么 ​

常见的分法是 41 位毫秒时间戳、10 位机器号、12 位序列号,最高位留 0 保证是正数:

java
long next() {
    long now = clock.getAsLong();
    seq = now == last ? (seq + 1) & MAX_SEQ : 0;   // 同一毫秒内序列号加一,换了毫秒就归零
    last = now;
    return (now - EPOCH) << 22 | worker << 12 | seq;
}
  • 同一个实例、同一毫秒内,靠序列号区分;
  • 同一个实例、不同毫秒,靠时间戳区分;
  • 不同实例,靠机器号区分。

上面这段是最简单的写法,没有任何保护。下面三节分别对应它的三个漏洞。

二、时钟往回走 ​

时间戳取自本机时钟。NTP 校时、虚拟机迁移、人工改时间都可能让它往回跳几毫秒到几秒。回跳之后,生成器会重新经过一段已经用过的时间,序列号又是从 0 开始:

text
生成 2000 个 ID,中途时钟回拨 5 毫秒:重复 500 个

实验里每毫秒生成 100 个,回拨的 5 毫秒正好对应 500 个重复。

通常的保护是记住上一次用过的时间戳,发现当前时间比它小就拒绝生成:

text
带回拨检查的生成器在时钟回拨后:抛出 IllegalStateException(clock moved backwards by 5 ms)

回拨很小时也可以选择等待,等时钟追上来再继续;回拨大时只能拒绝并告警。

这个保护在重启之后就没有了。 「上一次的时间戳」存在内存里,进程重启后从头开始。如果重启恰好发生在回拨前后,新进程看不到旧进程用过的时间:

text
带回拨检查的生成器重启(重启期间时钟回退 5 毫秒)后继续生成:重复 500 个
重启时恢复了上次持久化的时间戳:抛出 IllegalStateException(clock moved backwards by 4 ms)

要兜住这种情况,得把用过的最大时间戳定期写到本地文件或注册中心,启动时读回来比较。另一种做法是每次启动换一个新的机器号,这样即使时间重叠,机器号段也不同。

顺带一点:System.currentTimeMillis() 是墙上时钟,会被校时调整;System.nanoTime() 单调但没有绝对时间含义,不能直接用来生成时间戳。有的实现启动时取一次墙上时钟,之后用单调时钟累加,运行期间就不受回拨影响,但重启时的问题仍然存在。

三、两个实例用了同一个机器号 ​

机器号段的前提是「同一时刻,没有两个活着的实例用同一个号」。这件事算法本身不管,由部署方式保证。

text
两个实例都用机器号 5,同一毫秒各生成 100 个:重复 100 个

两个同号的实例只要在同一毫秒内都生成过 ID,序列号都从 0 开始,就是成批重复。流量越大,撞上的概率越接近必然。

机器号重复通常不是配置写错,而是分配方式本身不保证唯一:

取 IP 的后几位。 机器号 10 位,只能容纳 IP 的低 10 位。子网稍大一点就会重叠:

text
机器号取 IP 的低 10 位:10.0.0.5 得到 5,10.0.4.5 得到 5

对主机名或 Pod 名取哈希。 1024 个号看起来很多,但这是生日问题:

实例数至少两个实例同号的概率
104.3%
3034.9%
5070.4%
10099.3%

30 个实例的服务,每次全量发布有三分之一的概率出现一对同号的实例。

写在配置文件或镜像里。 扩容出来的副本拿到的是同一份配置。Kubernetes 的 Deployment 尤其如此:所有 Pod 的环境变量完全相同。

可靠的分配需要一个能做「占用」的地方:启动时到注册中心或数据库里申请一个没人用的号,带租约,实例下线或租约过期后才能被别人拿走;StatefulSet 的序号也可以直接用,前提是副本数不超过机器号的范围。不论用哪种,旧实例还没完全停下、新实例已经拿到同一个号的窗口都要考虑,租约的归属问题见分布式锁与并发控制实战。

四、一毫秒内生成得太多 ​

12 位序列号意味着一个实例一毫秒最多 4096 个 ID。超过时,最简单的写法是按位与,序列号回绕到 0:

text
同一毫秒内生成 5000 个,序列号直接回绕:重复 904 个

5000 − 4096 = 904,多出来的每一个都与这一毫秒开头的某个 ID 相同。

正确的做法是序列号用完时等到下一毫秒:

text
序列号用完时等到下一毫秒:重复 0 个,生成器等过的毫秒数 1

每毫秒 4096 个,折合每秒 409.6 万个,线上的正常流量到不了。会触到的是批量场景:一次导入几十万行数据、在循环里预生成一批 ID、压测时把时钟替换成了固定值。测试里用固定时钟时尤其要注意,时间不前进,序列号用完后「等下一毫秒」会一直等下去。

五、不重复之外的几个性质 ​

跨实例不严格有序。 ID 的高位是时间戳,所以大致按时间递增,但各实例的时钟并不一致:

text
实例 B 的时钟快 3 毫秒:B 先生成的 ID 比 A 两毫秒之后生成的 ID 更大 = true

「ID 大的一定后创建」只在同一个实例内成立。用 ID 做分页游标、按 ID 判断先后时,要接受毫秒级的乱序;需要严格顺序的场景得另找依据。

超过 2 的 53 次方。 纪元之后不到一个月,ID 就超出了双精度浮点数能精确表示的整数范围:

text
ID 超过 2^53 发生在纪元之后 24.9 天;第 200 天的一个 ID 72477573120020481 转成 double 再转回是 72477573120020480

JSON 里的数值到了 JavaScript 默认按双精度解析,末位被改掉后前端拿着一个不存在的订单号来查询。接口里把 ID 作为字符串返回。这个问题的原理见同一组数,换个顺序求和,结果就不一样了。

有使用年限。 41 位毫秒数是 69.7 年,从选定的纪元算起:

text
41 位毫秒时间戳可用 69.7 年;纪元取 2020-01-01 时用到 2089-09-06

纪元直接用 1970 年的话,已经用掉了五十多年。纪元定下来之后不能改,改了就会与历史 ID 重叠。

暴露信息。 时间戳段可以直接反解出创建时间,同一实例相邻两个 ID 的差能估出这段时间的生成量。订单号要给外部看又不想暴露这些信息时,对外用另一个无规律的编号,或者对 ID 做一次可逆的混淆。

六、检查清单 ​

  • 时钟回拨时拒绝或等待,不继续生成;并且有监控能看到这类拒绝。
  • 用过的最大时间戳在重启后还能拿到,或者每次启动换机器号。
  • 机器号通过带租约的占用来分配,不靠 IP 截取、名称哈希或静态配置。
  • 序列号用完时等到下一毫秒,不回绕。
  • 订单号列有唯一索引。前面几条是避免重复,这一条是重复发生时能被发现,而不是写出两笔同号的订单。
  • 接口里以字符串传递。

主键与订单号各自怎么选,见订单、库存与数据一致性。


配套实验

  • codesphere-labs/distributed/snowflake-id-pitfalls:时钟由测试代码给出的确定性实验(JDK 25.0.4.1):时钟回拨、重启后丢失上次时间戳、同一毫秒超量生成、两个实例同号时各自的重复数量,机器号按 IP 截取与按哈希分配的冲突,跨实例顺序、可用年限与 2^53(验证记录)

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权