雪花算法遇上时钟回拨:重复 ID 是怎么产生的,以及三种兜底方案的代价
雪花算法遇上时钟回拨:重复 ID 是怎么产生的,以及三种兜底方案的代价
Snowflake 类算法把 64 位整数拆成四段:1 位符号、41 位毫秒时间戳、10 位机器号、12 位序列号。唯一性依赖一个隐含前提——本机时钟单调递增。这个前提在启用 NTP 校时的机器上并不总成立。
重复 ID 的产生路径
生成的伪代码只有三行关键逻辑:
long now = System.currentTimeMillis();
if (now < lastTimestamp) { /* 时钟回拨,怎么办 */ }
if (now == lastTimestamp) { sequence = (sequence + 1) & 0xFFF; }
else { sequence = 0; }
lastTimestamp = now;
时钟回拨时,now 落到上一次时间戳之前。如果代码没有拦这一支,now != lastTimestamp 成立,序列号被重置为 0,机器号又不变,于是 (时间戳, 机器号, 序列号) 三元组与回拨前某个已发出的 ID 完全重合。
复现不需要改代码:把系统时钟往回拨几秒,再连续生成一批 ID。
sudo date -s "$(date -d '-5 seconds')"
# 生成 10000 个 ID 后
python3 - <<'EOF'
import collections, subprocess
ids = [line.strip() for line in subprocess.run(
['java', '-cp', 'target/classes', 'IdGen', '10000'],
capture_output=True, text=True).stdout.splitlines()]
dup = [k for k, v in collections.Counter(ids).items() if v > 1]
print('total=%d dup=%d' % (len(ids), len(dup)))
EOF
回拨 5 秒、每毫秒生成 1 个的场景下,dup 会在回拨窗口内稳定出现。把回拨量减到 200 毫秒,重复数量线性下降但不为零——只要窗口覆盖到已发出的时间戳区间,就一定有碰撞。
三种兜底方案
方案一:自旋等待。 检测到 now < lastTimestamp 就睡到 lastTimestamp + 1,再继续生成。实现最短,代价是这段时间内所有请求被阻塞。回拨 5 秒意味着 5 秒的写入停顿,对高 QPS 服务等于一次小型故障。
方案二:直接抛异常。 让上游感知时钟异常并重试,ID 生成器保持纯净。代价把选择权交给了调用方:写订单失败、消息重投、用户看到 500。若回拨由 NTP 抖动引起且幅度在几十毫秒内,抛异常属于过度反应。
方案三:借用扩展位。 把 10 位机器号拆成「5 位机器号 + 5 位回拨代数」,每次检测到回拨就把代数加一。时钟退后的时间戳重新可用,唯一性由代数位保证。代价是机器号可用范围从 1024 降到 32,且代数溢出后需要人工介入或换时间戳纪元。
工程上的取舍通常是混合:回拨幅度小于阈值(常见 5~100 毫秒)走自旋,超过阈值走代数位,代数位也用尽才抛异常。阈值不是拍出来的,要按监控数据定。
需要监控什么
三个指标比方案本身更重要:
| 指标 | 采样方式 | 用途 |
|---|---|---|
| 回拨次数 | 每次 now < lastTimestamp 计数 |
判断是否在持续发生 |
| 回拨幅度直方图 | 记录 lastTimestamp - now 的分桶 |
定自旋阈值 |
| 序列号耗尽率 | sequence == 0xFFF 的毫秒占比 |
判断 QPS 是否接近 4096/ms 上限 |
回拨幅度数据一般呈现双峰:几十毫秒的 NTP 微调,和几秒以上的运维误操作。前者适合自旋,后者必须走代数位或告警,用同一套阈值处理两者只会两头不讨好。
一个容易忽略的边界
sequence 在同一毫秒内自增满 4095 后,正确做法是自旋到下一毫秒而不是回绕。回绕会让 (时间戳, 机器号, 序列号) 在同一毫秒内重复。判断方式:压测时把单毫秒 QPS 打到 5000 以上,检查该毫秒产出的 ID 数量是否超过 4096,超过即说明回绕没拦住。
另外,机器号如果来自配置文件而非注册中心,扩容时人工分配重复的概率远高于时钟回拨。ID 重复的排查顺序应该是:先查机器号是否唯一,再查时钟,最后才怀疑序列号逻辑。
- 点赞
- 收藏
- 关注作者
评论(0)