订单号重复了:雪花算法依赖的三件事
订单表的唯一索引报了冲突:两笔不同的订单拿到了同一个订单号。生成订单号的代码用的是雪花算法,上线之后没有改过,单元测试里连续生成多少个都不重复。
雪花算法把一个 64 位整数分成三段:时间戳、机器号、序列号。它不访问任何外部服务,每个实例自己就能生成,所以快,也所以没有任何一个地方能替它检查「这个 ID 别人有没有用过」。唯一性完全建立在三个前提上,前提不成立时生成器照常返回,不报错。
本文把三个前提逐个打破,看各自重复多少。实验见文末。
一、三段各管什么
常见的分法是 41 位毫秒时间戳、10 位机器号、12 位序列号,最高位留 0 保证是正数:
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 开始:
生成 2000 个 ID,中途时钟回拨 5 毫秒:重复 500 个实验里每毫秒生成 100 个,回拨的 5 毫秒正好对应 500 个重复。
通常的保护是记住上一次用过的时间戳,发现当前时间比它小就拒绝生成:
带回拨检查的生成器在时钟回拨后:抛出 IllegalStateException(clock moved backwards by 5 ms)回拨很小时也可以选择等待,等时钟追上来再继续;回拨大时只能拒绝并告警。
这个保护在重启之后就没有了。 「上一次的时间戳」存在内存里,进程重启后从头开始。如果重启恰好发生在回拨前后,新进程看不到旧进程用过的时间:
带回拨检查的生成器重启(重启期间时钟回退 5 毫秒)后继续生成:重复 500 个
重启时恢复了上次持久化的时间戳:抛出 IllegalStateException(clock moved backwards by 4 ms)要兜住这种情况,得把用过的最大时间戳定期写到本地文件或注册中心,启动时读回来比较。另一种做法是每次启动换一个新的机器号,这样即使时间重叠,机器号段也不同。
顺带一点:System.currentTimeMillis() 是墙上时钟,会被校时调整;System.nanoTime() 单调但没有绝对时间含义,不能直接用来生成时间戳。有的实现启动时取一次墙上时钟,之后用单调时钟累加,运行期间就不受回拨影响,但重启时的问题仍然存在。
三、两个实例用了同一个机器号
机器号段的前提是「同一时刻,没有两个活着的实例用同一个号」。这件事算法本身不管,由部署方式保证。
两个实例都用机器号 5,同一毫秒各生成 100 个:重复 100 个两个同号的实例只要在同一毫秒内都生成过 ID,序列号都从 0 开始,就是成批重复。流量越大,撞上的概率越接近必然。
机器号重复通常不是配置写错,而是分配方式本身不保证唯一:
取 IP 的后几位。 机器号 10 位,只能容纳 IP 的低 10 位。子网稍大一点就会重叠:
机器号取 IP 的低 10 位:10.0.0.5 得到 5,10.0.4.5 得到 5对主机名或 Pod 名取哈希。 1024 个号看起来很多,但这是生日问题:
| 实例数 | 至少两个实例同号的概率 |
|---|---|
| 10 | 4.3% |
| 30 | 34.9% |
| 50 | 70.4% |
| 100 | 99.3% |
30 个实例的服务,每次全量发布有三分之一的概率出现一对同号的实例。
写在配置文件或镜像里。 扩容出来的副本拿到的是同一份配置。Kubernetes 的 Deployment 尤其如此:所有 Pod 的环境变量完全相同。
可靠的分配需要一个能做「占用」的地方:启动时到注册中心或数据库里申请一个没人用的号,带租约,实例下线或租约过期后才能被别人拿走;StatefulSet 的序号也可以直接用,前提是副本数不超过机器号的范围。不论用哪种,旧实例还没完全停下、新实例已经拿到同一个号的窗口都要考虑,租约的归属问题见分布式锁与并发控制实战。
四、一毫秒内生成得太多
12 位序列号意味着一个实例一毫秒最多 4096 个 ID。超过时,最简单的写法是按位与,序列号回绕到 0:
同一毫秒内生成 5000 个,序列号直接回绕:重复 904 个5000 − 4096 = 904,多出来的每一个都与这一毫秒开头的某个 ID 相同。
正确的做法是序列号用完时等到下一毫秒:
序列号用完时等到下一毫秒:重复 0 个,生成器等过的毫秒数 1每毫秒 4096 个,折合每秒 409.6 万个,线上的正常流量到不了。会触到的是批量场景:一次导入几十万行数据、在循环里预生成一批 ID、压测时把时钟替换成了固定值。测试里用固定时钟时尤其要注意,时间不前进,序列号用完后「等下一毫秒」会一直等下去。
五、不重复之外的几个性质
跨实例不严格有序。 ID 的高位是时间戳,所以大致按时间递增,但各实例的时钟并不一致:
实例 B 的时钟快 3 毫秒:B 先生成的 ID 比 A 两毫秒之后生成的 ID 更大 = true「ID 大的一定后创建」只在同一个实例内成立。用 ID 做分页游标、按 ID 判断先后时,要接受毫秒级的乱序;需要严格顺序的场景得另找依据。
超过 2 的 53 次方。 纪元之后不到一个月,ID 就超出了双精度浮点数能精确表示的整数范围:
ID 超过 2^53 发生在纪元之后 24.9 天;第 200 天的一个 ID 72477573120020481 转成 double 再转回是 72477573120020480JSON 里的数值到了 JavaScript 默认按双精度解析,末位被改掉后前端拿着一个不存在的订单号来查询。接口里把 ID 作为字符串返回。这个问题的原理见同一组数,换个顺序求和,结果就不一样了。
有使用年限。 41 位毫秒数是 69.7 年,从选定的纪元算起:
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(验证记录)
参考资料
- Twitter Engineering:Announcing Snowflake
- twitter-archive/snowflake(原始实现,含时钟回拨时拒绝生成的处理)
- Java SE 25 API:System.nanoTime
- RFC 9562:Universally Unique IDentifiers (UUIDs)(UUIDv7:另一种时间有序的标识,以随机位代替机器号)
- Kubernetes 文档:StatefulSet 的稳定序号