2021 年,有安全研究者用 JavaScript 的 Math.random 批量生成 UUID,跑到五万条附近就撞出了重复。UUID v4 的随机空间是 2 的 122 次方,约 5.3 后面跟 36 个零,理论上把地球每粒沙子都编上号也用不完它的零头——可重复偏偏发生了。这事跟数学关系不大,跟「谁来掷这枚骰子」关系很大。要讲清楚,得把两笔账分开算。
128 位里真正随机的是 122 位
标准 UUID 是 32 个十六进制字符分五段,形如 xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx,共 128 位。第三段开头的 4 标记这是 v4,y 位置的两位变体位必须是 10,这 6 位是格式开销,真正随机的只有 122 位。2 的 122 次方 ≈ 5.3×10 的 36 次方,这就是「全球唯一」的底气。对比一下:据物理学家估算,地球上所有沙滩的沙粒总数约 7.5×10 的 18 次方——UUID 空间是这个数字的一百万亿倍。就算给地球每粒沙子都发一个 UUID,还可以连续这样发一百万亿年。
碰撞不需要穷完空间:生日悖论先算一遍
「两枚 UUID 相同吗」不是逐条比对全空间的问题,而是生日问题:生成 n 条后,存在任意一对碰撞的概率近似
p ≈ n² ÷ 2¹²³
代入全球一天生成 100 亿条(10 的 10 次方):p ≈ 10²⁰ ÷ 1.06×10³⁷ ≈ 9.4×10⁻¹⁸。要把碰撞概率推到五成,需要约 2.7×10 的 18 次方条——每秒生成 10 亿条、不眠不休跑 85 年才够。所以只要随机源合格,v4 的碰撞概率在工程上就是零,讨论到这里本可以收工。
现实的重复,全都出在随机源和复用
翻车案例分三类。第一类,随机源先天不足:2003 到 2006 年的 Debian OpenSSL 库因补丁错误,密钥生成的伪随机数种子只剩 15 位,32768 种可能可以被逐个列完;V8 引擎旧版 Math.random 是 xorshift32 算法,内部状态只有 32 位,用它拼 UUID 等于把 122 位的空间压回 32 位,按生日界算六万多条就该撞,实测五万条上下出现重复。第二类,v1 由 MAC 地址加时间戳构成:虚拟机镜像复制了同一块网卡的 MAC,系统时钟回拨而 clock sequence 没变,两条「合法」UUID 就长得一样。第三类,代码 bug:两套系统各自生成再合并数据,或者有人拿 UUID 前 8 位当短码截断使用——数学没输,是实现输了。
这个概率小到什么概念
9.4×10⁻¹⁸ 不好想象,换个对照:从全地球 80 亿人里随机点两个人,连续几百次都点到同一对的概率也就这个量级;买彩票中头奖连中两次大约是它的十万分之一。真正该操心的完全不是碰撞概率,而是三件工程事:生成器调用的是不是加密级随机源(crypto.randomUUID、Java 的 UUID.randomUUID 用的是 SecureRandom);数据库列上有没有唯一约束兜底;以及业务上有没有误用——把 UUID 截短、或者拿它当「猜不到的秘密」用在令牌里却配了弱随机源。
小结:什么时候反而不该用 v4
v4 还有一个常被忽略的代价:它毫无顺序,作为数据库主键会让 B 树索引随机插入、页分裂和缓存命中率都变差,这是比碰撞真实得多的成本。需要按时间排序的场景,v7 把 48 位毫秒时间戳放在最前面正合适;雪花 ID 在单一发号器下也够用。想看看各版本的字段长什么样、或对比同一毫秒生成的 v4 与 v7,本站 /uuid 页可以直接生成并按版本筛选。