雪花 ID 四个边界实测:时钟回拨重号 500 个,前端 JSON.parse 直接丢精度

摘要

雪花 ID(Snowflake)是"看起来三行代码就能写完"的组件,所以多数团队的实现停留在"能跑",直到某次 NTP 校时或流量峰值才暴露问题。本文不重复讲原理,而是把四个真正会咬人的地方量化:单节点容量上限由序列位还是 CPU 决定、时钟回拨四种策略各自的重号数与停顿、多线程无锁为什么"测不出问题"、以及雪花 ID 交给前端 JavaScript 后到底丢多少位。全部数据来自本机 Windows 上真实运行的 snowflake_id_lab.py(3 轮)与 Node v24.18.0 的真实输出,每个指标给区间。

实验环境与口径

  • 机器:Windows 10 x64,AMD64,32 逻辑核
  • 运行时:Python 3.11.5;JS 复核用 Node v24.18.0(真实执行,非推断)
  • 位分配:1 符号位 + 41 位时间戳 + 10 位 workerId + 12 位序列号 → 单节点每毫秒容量 4096,workerId 取值域 0~1023
  • 每个实验跑 3 轮(seed 无关,均为真实计时),下文的"最小~最大"就是这 3 轮的区间
  • 时钟抽象成可注入对象:SystemClock(真实墙钟)与 StepClock(可编程阶跃,用于精确复现回拨),这样"回拨 50ms"这种事件不需要真的等网络

一句话概括结论:雪花 ID 的安全性不在位布局里,而在三个边界条件上------序列位耗尽、时钟回拨、跨语言传输。

一、单节点吞吐:容量下限和实际耗时差多远

单线程连续生成 200,000 个号,3 轮实测 1,341,275 ~ 1,678,947 ids/s,唯一性 200,000/200,000,因同毫秒序列耗尽而被迫等待 1~10 次。也就是说 12 位序列在这个量级下基本没被压到。

真正有意思的是把序列位从 12 位往下砍,看"理论下限"和"实际耗时"的比值。同样生成 100,000 个号,理论下限 = 100000 / 每毫秒容量:

序列位 每毫秒容量 实测耗时 理论下限 实测/下限 耗尽等待次数
8 256 541.91 ~ 603.69ms 390.62ms 1.39 ~ 1.55 390
10 1024 103.11 ~ 184.02ms 97.66ms 1.06 ~ 1.88 91 ~ 93
12 4096 65.06 ~ 81.27ms 24.41ms 2.67 ~ 3.33 0 ~ 5

读法:8 位时是容量卡住的 (比值接近 1,等待次数打满),12 位时是 CPU 卡住的(比值 2.673.33,几乎不等)。所以"多给几个序列位是不是白给"------不是,它把瓶颈从"等下一毫秒"换成了"纯粹的算力开销",而这个换算是值得的:6581ms 对比 542~604ms,同样 10 万个号差了 7~9 倍。

反过来也成立:序列位不是多到可以忽略。业务峰值若接近每毫秒 4096,只要有一毫秒超量,实现里那条"自旋等下一毫秒"的路径就会被触发,尾延迟立刻从微秒级跳到毫秒级。

二、时钟回拨:四种策略的分水岭

回拨来源很实际:NTP 步进校时、虚机迁移、双机热备切换后旧机器继续发号。常见处理有四种:raise(直接抛异常)、wait(自旋到真实时钟追上来)、logic(逻辑时钟不后退,继续用 last_ts)、none(什么都不做,照用回拨后的系统时间)。

为了把重号数算准,我用可编程时钟构造最坏情况:发 500 个号 → 时钟回拨 → 回拨后时钟再落回同一个毫秒区间 → 再发 500 个号 → 收尾 500 个号,共 1500 个号。回拨幅度分别试 1 / 5 / 50 / 200ms,每种跑 3 轮:

策略 1500 个号中的重号数 异常数 回拨窗口耗时
none 500(所有幅度、所有轮次完全一致) 0 0.32 ~ 0.37ms
wait 0 0 1.33 ~ 200.36ms
logic 0 0 0.36 ~ 0.41ms
raise 0 500 0.55 ~ 0.62ms

none 的重号是确定性的 500 个,不是概率性的------因为回拨后时间戳完全重复、序列号从 0 重新开始,第二段 500 个号和第一段逐一对撞。回拨 1ms 和回拨 200ms 结果一模一样,很多人以为"回拨得小就没事",数据不支持这个直觉。

raise 看着干净,但代价是把 500 次发号变成了 500 次异常。

然后在真实墙钟下重新测(不发号到重复区间,只看对业务的影响):回拨后各发 300 个号,测停顿与成功率。
#mermaid-svg-LSw7cQ95Met7XdIV{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-LSw7cQ95Met7XdIV .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-LSw7cQ95Met7XdIV .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-LSw7cQ95Met7XdIV .error-icon{fill:#552222;}#mermaid-svg-LSw7cQ95Met7XdIV .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-LSw7cQ95Met7XdIV .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-LSw7cQ95Met7XdIV .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-LSw7cQ95Met7XdIV .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-LSw7cQ95Met7XdIV .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-LSw7cQ95Met7XdIV .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-LSw7cQ95Met7XdIV .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-LSw7cQ95Met7XdIV .marker{fill:#333333;stroke:#333333;}#mermaid-svg-LSw7cQ95Met7XdIV .marker.cross{stroke:#333333;}#mermaid-svg-LSw7cQ95Met7XdIV svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-LSw7cQ95Met7XdIV p{margin:0;}#mermaid-svg-LSw7cQ95Met7XdIV .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-LSw7cQ95Met7XdIV .cluster-label text{fill:#333;}#mermaid-svg-LSw7cQ95Met7XdIV .cluster-label span{color:#333;}#mermaid-svg-LSw7cQ95Met7XdIV .cluster-label span p{background-color:transparent;}#mermaid-svg-LSw7cQ95Met7XdIV .label text,#mermaid-svg-LSw7cQ95Met7XdIV span{fill:#333;color:#333;}#mermaid-svg-LSw7cQ95Met7XdIV .node rect,#mermaid-svg-LSw7cQ95Met7XdIV .node circle,#mermaid-svg-LSw7cQ95Met7XdIV .node ellipse,#mermaid-svg-LSw7cQ95Met7XdIV .node polygon,#mermaid-svg-LSw7cQ95Met7XdIV .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-LSw7cQ95Met7XdIV .rough-node .label text,#mermaid-svg-LSw7cQ95Met7XdIV .node .label text,#mermaid-svg-LSw7cQ95Met7XdIV .image-shape .label,#mermaid-svg-LSw7cQ95Met7XdIV .icon-shape .label{text-anchor:middle;}#mermaid-svg-LSw7cQ95Met7XdIV .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-LSw7cQ95Met7XdIV .rough-node .label,#mermaid-svg-LSw7cQ95Met7XdIV .node .label,#mermaid-svg-LSw7cQ95Met7XdIV .image-shape .label,#mermaid-svg-LSw7cQ95Met7XdIV .icon-shape .label{text-align:center;}#mermaid-svg-LSw7cQ95Met7XdIV .node.clickable{cursor:pointer;}#mermaid-svg-LSw7cQ95Met7XdIV .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-LSw7cQ95Met7XdIV .arrowheadPath{fill:#333333;}#mermaid-svg-LSw7cQ95Met7XdIV .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-LSw7cQ95Met7XdIV .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-LSw7cQ95Met7XdIV .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-LSw7cQ95Met7XdIV .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-LSw7cQ95Met7XdIV .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-LSw7cQ95Met7XdIV .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-LSw7cQ95Met7XdIV .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-LSw7cQ95Met7XdIV .cluster text{fill:#333;}#mermaid-svg-LSw7cQ95Met7XdIV .cluster span{color:#333;}#mermaid-svg-LSw7cQ95Met7XdIV div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-LSw7cQ95Met7XdIV .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-LSw7cQ95Met7XdIV rect.text{fill:none;stroke-width:0;}#mermaid-svg-LSw7cQ95Met7XdIV .icon-shape,#mermaid-svg-LSw7cQ95Met7XdIV .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-LSw7cQ95Met7XdIV .icon-shape p,#mermaid-svg-LSw7cQ95Met7XdIV .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-LSw7cQ95Met7XdIV .icon-shape .label rect,#mermaid-svg-LSw7cQ95Met7XdIV .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-LSw7cQ95Met7XdIV .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-LSw7cQ95Met7XdIV .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-LSw7cQ95Met7XdIV :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
raise
wait
logic
读系统时间戳
ts < last_ts ?
正常发号
选哪种策略
抛异常

本轮 298~300 次全部失败
自旋等真实时钟追上

停顿 0.47~206.97ms
沿用 last_ts

停顿 0.18~0.21ms 与回拨幅度无关

策略 回拨 1ms 回拨 5ms 回拨 50ms 回拨 200ms 成功发号
logic 0.18 ~ 0.19ms 0.19 ~ 0.20ms 0.20 ~ 0.21ms 0.19 ~ 0.21ms 300
wait 0.47 ~ 6.86ms 4.86 ~ 6.18ms 48.54 ~ 51.06ms 199.31 ~ 206.97ms 300
raise 0.32 ~ 0.43ms 0.32 ~ 1.13ms 0.31 ~ 0.34ms 0.32 ~ 0.49ms 0 ~ 2

这张表才是选型依据:wait 的停顿等于回拨量 ,回拨 200ms 就是整个线程被钉死 200ms;logic 的停顿和回拨量无关 ,四种幅度都是 0.18~0.21ms,因为它不等真实时钟,只借用上一毫秒的时间戳继续把序列号往上排。代价是逻辑时钟会比真实时间"跑在前面",不过实测这个超前量在三种保护策略下都是 0ms------因为借用只发生在同一毫秒内,序列位耗尽时仍然会推到下一毫秒。raise 在真实墙钟下 300 次只成功发出 0~2 个号,等于该节点在恢复前彻底停摆。

三、多线程:0 重号不代表无锁是对的

每线程生成 50,000 个号,对比加锁与不加锁:

线程数 无锁吞吐 加锁吞吐 重号数
1 1,063,368 ~ 1,577,431 1,339,247 ~ 1,711,461 0
2 1,249,421 ~ 1,352,228 1,139,422 ~ 1,628,145 0
4 1,009,167 ~ 1,479,418 331,243 ~ 1,272,595 0
8 1,028,284 ~ 1,409,460 249,122 ~ 359,247 0

两件事值得注意。第一,加锁到 8 线程时吞吐塌到 24.9 万~35.9 万 ids/s,是 8 线程无锁的 1/4 到 1/6 ;4 线程那一档波动特别大(33 万~127 万),说明锁竞争在这个规模上已经进入了"看调度运气"的区间。第二,也是更重要的:整个表里无锁一栏重号是 0。

这 0 是假的。Python 的 GIL 把 self.seq = (self.seq + 1) & mask 这条"读---改---写"序列切得很碎,但切分点恰好落在字节码边界上时,窗口小到你压不出问题。于是我加了一个变体,只在做一件事:在读 seq 和写 seq 之间插一个 time.sleep(0) 让出点,把窗口放大到可观测。

python 复制代码
def next_id_racy(self):
    # 把「读 seq → 写 seq」之间插一个让出点,人为放大竞争窗口。
    # 仅用于演示无锁为什么不安全;GIL 让正常版本测不出问题,不代表无锁是对的。
    ts = self.clock.now_ms() - self.epoch
    if ts == self.last_ts:
        read = self.seq
        time.sleep(0)                    # 另一个线程在这里读到同一个 seq
        self.seq = (read + 1) & self.seq_mask
    ...

结果:2 线程每线程 2000 个号,4000 个号里重号 1,197 ~ 1,682 个,出现过的序列号只剩 705 ~ 935 种;4 线程 8000 个号里重号 5,462 ~ 5,719 个,序列号只剩 138 ~ 268 种。

这就是无锁的真实面貌:一旦窗口被撑开,重号率是 30%~71%。把这段搬到 Java/C++(没有 GIL,且指令会重排)只会更糟。结论很直白:无锁"测不出问题"不是安全,只是没打到那个窗口;要么上锁,要么用原子操作(Java 里等价于 AtomicLong 把 ts 和 seq 打包成一次 CAS),不能靠"我们压测跑了 24 小时没重复"。

四、JavaScript:雪花 ID 过不了 JSON 这一关

Number.MAX_SAFE_INTEGER = 2^53 − 1 = 9,007,199,254,740,991。而 64 位雪花 ID 远超它,这不是新知识,但超出多少、丢多少精度值得实测。

先生成一批真实 ID,转成 double 后统计"值被改掉"的比例(每组 5000 个号,3 轮):

纪年 超出 2^53 转 double 后变值 最大取整偏差 ID 是安全上限的
Unix 1970-01-01 15,000/15,000 4,992 ~ 4,993 / 5000(99.8%~99.9%) 512 834.6 倍
自定义 2026-01-01 15,000/15,000 4,685 ~ 4,686 / 5000(93.7%) 8 11.6 倍

纪年选择影响巨大:以 1970 为纪年时,时间戳部分本身就快顶满 41 位,整个 ID 是安全上限的 834.6 倍,double 的间隔达到 1024,所以偏差高达 512------ID 加一都加不动。改成自定义纪年(2026-01-01)后比值降到 11.6 倍,偏差只有 8。

再用 Node v24.18.0 真实执行复核(把 Python 生成的 ID 写成 JSON,让 Node JSON.parse 后和真值字符串比对):

复制代码
epoch_1970: mismatch_after_json_parse = 19/20,Number.isSafeInteger 为 false
            first_value "7517040632850846000"  vs 真值 "7517040632850845696"
            id + 1 === id  ->  true
epoch_2026: mismatch_after_json_parse = 18/20
            first_value "104759229868445700"   vs 真值 "104759229868445696"
            bigint_ok -> true,as_string_keeps_exact -> true

id + 1 === id 返回 true 是最直观的一记耳光------前端拿到这个数以后连"下一个 ID"都算不出来。而 BigInt 和"以字符串形式传输"两条路都实测能精确还原。 所以修复方案不是"少发点号",是接口层强制 ID 序列化为 string(Java 侧 ToStringSerializer、Go 侧自定义 MarshalJSON、Node 侧 JSONbig),这一条应该写进团队的接口规范。

顺带算清两个日期:41 位时间戳本身可用 69.68 年 ;而以 2026-01-01 为纪年时,JS 安全的截止点是 2026-01-25T20:31:23.647Z------距上线只有二十几天。结论:JS 精度和位长度无关,只和数值量级有关,而雪花 ID 的量级永远在安全上限之上。

五、workerId 撞号:生日问题不是玄学

把两个生成器的时钟钉死在同一毫秒 (StepClock.freeze()),各自发 2000 个号,3 轮结果完全一致:

  • workerId 相同:重叠 2,000/2,000,前 5 个 ID 逐字节完全相同
  • workerId 不同:重叠 0
  • 各自流内部重号:0

也就是说位布局本身对"同一毫秒两个节点"毫无抵抗力,全靠 workerId 唯一。那 workerId 是怎么来的?如果是随机分配,10 位空间的生日碰撞概率实测(用 fractions.Fraction 精确算,再转百分数):

节点数 8 16 32 64 128
至少一对撞上的概率 2.7% 11.11% 38.7% 86.61% 99.97%

64 个节点随机取 10 位 workerId,86.61% 的概率已经有人和你撞号了。 这不是"要不要加监控"的问题,是必须用 ZooKeeper/etcd/数据库自增这种集中式分配,或者按机房+机架做结构化划分。

另外本机时钟粒度这件事也值得记一笔:紧凑循环 100,000 次读 time.time_ns(),3 轮只拿到 4 / 5 / 6 个不同值 ,同值最长连续 34,153 ~ 54,916 次,最小正增量 349,900 ~ 998,800ns;time.monotonic_ns() 更夸张,5 万次读只得到 1 个值。而每隔约 1ms 采样 400 次,能拿到 400 个不同值,最小步长 1027 ~ 1112µs(共 234 种步长)。结论:Windows 上"毫秒"其实粗到一毫秒里能重复读五万次,任何"用纳秒戳做去重键"的想法在这台机器上都要先测粒度。

最后是可逆性验证:每轮 3000 个号,workerId 全部可还原 、时间戳单调不减、3000 个号跨 0~6ms、序列号最大用到 2,568~2,999、符号位从未被用到。位布局本身没毛病------问题全在上面三节。

避坑指南

  1. 别用"回拨幅度小"来评估风险。实测回拨 1ms 和 200ms 的重号数都是 500/1500,完全一样。
  2. wait 策略的停顿等于回拨量。回拨 200ms 就是线程被钉死 206ms,在请求线程池里这叫雪崩起点。要么用逻辑时钟,要么把这个等待挪出请求路径。
  3. logic 策略必须处理"逻辑时钟跑在前面" 。本文实测三种保护策略的超前量都是 0ms,但那是序列位够用的情况下;序列位一耗尽,逻辑时钟会强制 +1ms,长期高负载会累积出可观的偏移,必须监控 last_ts - 真实时间。
  4. 无锁实现压测不出重号 ≠ 无锁安全。GIL/指令重排会让窗口小到看不见;要么加锁,要么把 (ts, seq) 打包成一次 CAS。
  5. 8 线程加锁的吞吐只有无锁的 1/4~1/6(24.9 万~35.9 万 vs 102.8 万~140.9 万 ids/s)。真要横向扩,正确做法是多进程/多实例分 workerId,而不是加线程数。
  6. 接口层必须把 ID 序列化成字符串 。这是唯一实测有效的修复;BigInt 能精确还原,+1 不能。
  7. 别用 Unix 纪年省那 20 天。纪年只改变偏差量级(512 vs 8),不改变"超出 2^53"这个事实------两种纪年实测都是 15,000/15,000 全部超出。
  8. workerId 一定要集中分配。10 位空间里 64 个节点随机分配,撞号概率 86.61%。
  9. 上线前先测本机时钟粒度 。Windows 上 time_ns() 一毫秒内可重复读五万次,monotonic_ns() 五万次读只有 1 个值,任何依赖"每读必变"的逻辑都会踩空。
  10. 序列位耗尽的路径要有告警。8 位时每 10 万个号触发 390 次等待,12 位时 0~5 次------这个计数是最早的容量预警信号,比出问题早得多。

结论

按这个顺序做决策,能覆盖 95% 的雪花 ID 事故:

  1. 容量:按业务峰值算"每毫秒/节点",向上取 2 的幂得序列位;12 位在本机 CPU-bound 区间(65~81ms / 10 万个号),够用。
  2. 回拨 :默认选逻辑时钟(logic),停顿 0.18~0.21ms 且与回拨量无关;同时监控逻辑时钟超前量,超过阈值再降级告警。不要用 wait 放在请求线程里,也不要把 raise 当成"安全"------实测它让节点在恢复前完全停摆。
  3. 并发:加锁或 CAS,不接受"无锁压测通过"。锁竞争在 8 线程时是真实的(吞吐掉到 1/5),但那是性能问题不是正确性问题;正确性只能靠原子性保证。
  4. 传输:接口规范写死"ID 以 string 传输",前端禁止对 ID 做算术。
  5. 身份:workerId 集中分配 + 启动时冲突检查,位布局对同毫秒多节点零抵抗力(2000/2000 全撞)。

可运行验证代码在 demos/snowflake_id_lab.py,3 轮原始输出与 JSON 留档在 demos/snowflake_id_lab.transcript.txt,Node 复核脚本 demos/_js_precision_check.js 为 v24.18.0 真实执行结果。本文所有数字均由本机运行得到,未做任何外推。

相关推荐
DongQiShanRen1 小时前
六根硬件映射(四):归属澄清、两处不一致与卷十一总表
开发语言·人工智能·算法·rust·哈希算法·集成学习
CS创新实验室1 小时前
Python 对象“死“了之后去了哪?——引用计数与分代垃圾回收
开发语言·jvm·python
我是人✓1 小时前
SpringBoot项目的五种创建方式
java·spring boot·后端
quantdash_cc1 小时前
Pandas 处理分钟级股票行情:从原始数据清洗到 K 线聚合
开发语言·python·数据分析·pandas·量化交易·股票数据·quantdash
阡陌数智1 小时前
Gemini 4 Argon 百万 Token 输出能力解析:原生长输出模型的工程落地与并发成本治理
人工智能·架构
丑梨数据1 小时前
信创环境里跑文档工具:格式兼容与本地合规的几个坑
数据结构·人工智能·算法·决策树·排序算法
把我格子衫拿来1 小时前
VTK 9.5 与 Qt 6 联合开发:从嵌入三维窗口到可维护的桌面架构
c++·python·qt·vtk·三维可视化
cpolar技术支持2 小时前
把配置发出去怕带出密钥?先把含凭据配置转成脱敏只读快照,用 cpolar 给同事核对差异
运维·python·内网穿透·cpolar·配置脱敏
惊讶的猫2 小时前
langchian_agent的高级使用
python