Math.random() 到底有多随机:我把 V8 的随机数算了一遍

上周 review 代码,看见这么一行:

const token = Math.random().toString(36).slice(2, 10)

生成一次性 token。我盯着它看了一会儿,然后在终端里敲了这条命令:

node --random-seed=42 token.js
node --random-seed=42 token.js

两次输出一模一样:skwq3mwsix1omot2。

Math.random() 从来没承诺过自己是安全的随机源——MDN 白纸黑字写着「不提供密码学安全的随机数」。但"不安全"这三个字太抽象了。我把能测的都测了一遍,下面是账本。

一个发光的巨大骰子正在溶解成一串绿色的二进制数字,深色背景

所有数据来自本机 Linux x86_64、Node v24.16.0,脚本不依赖网络,你可以照着复现。

# 一、五个进程,同一个 token

先看最有冲击力的那个。我写了个"随机生成 token"的脚本,然后同时用父进程、两个 fork() 出来的子进程、两个 worker 线程各生成一次。

不加任何参数:

parent tok: bulwh8a8
child    : x8uzyjzg
child    : eroidbeq
worker   : xeikiqti
worker   : c6r70vpj

五个都不一样,符合预期。

加上 --random-seed=42:

parent tok: cnz7c19g  execArgv=["--random-seed=42"]
child    : skwq3mws  execArgv=["--random-seed=42"]
child    : skwq3mws  execArgv=["--random-seed=42"]
worker   : skwq3mws
worker   : skwq3mws

五个跑在不同进程、不同线程里的"随机数",完全一致。

一排五个一模一样的深色终端窗口,每个都显示着相同的青色 token 字符串

关键点有两个:

第一,--random-seed 会通过 execArgv 传给 fork() 出来的子进程。这意味着一个启动参数就能让整棵进程树的随机数全部退化为同一条确定的序列。你以为你开了 8 个 worker 并行发号,其实它们在发同一个号。

第二,--random-seed=0(默认值)走的是系统熵,每次都不同:

node --random-seed=0 token.js   → 3mh5gc44lu98lz1l
node --random-seed=0 token.js   → oiom6db1v45c988e

所以这个坑的触发条件是"有人显式指定了种子"——测试环境里为了可复现,加这个参数其实是很常见的操作。加完之后所有依赖 Math.random() 的"唯一 ID"、"验证码"、"抽奖"就全变成常量了,而测试还会一片绿。

# 二、天花板:只有 2^53 个取值

Math.random() 返回的是 [0, 1) 里的一个 double,但它能取的值不是连续的,是离散的——一共 2^53 = 9,007,199,254,740,992 个。

怎么测出来的?直接看分辨率:

let int53 = true
for (let i = 0; i < 500000; i++) {
  const v = Math.random()
  if (v * 2 ** 53 !== Math.floor(v * 2 ** 53)) int53 = false
}
// 50 万次,全部命中

而把 2^53 换成 2^52:

v × 2^53 恒为整数: true
v × 2^52 不是整数的比例: 0.5011  (50 万次采样)

一半不是整数,说明分辨率比 2^-52 更细,正好落在 2^-53。换句话说:每个返回值都严格等于 k / 2^53,k 是一个 0 到 2^53−1 的整数。

这跟 V8 的实现对得上。Math.random() 底下是 base::RandomNumberGenerator(xorshift128+ 的一个变体),每次推进两个 64 位状态字,返回它们之和——但只吐出这个 64 位和的高 53 位,低 11 位被扔掉了。

发光的二进制数字 0 和 1 像瀑布一样落下,撞上一条坚硬的水平天花板线

2^53 听起来很大,但一旦你的取值范围超过它,就会开始漏整数:

取值范围 R 奇数占比 4 的倍数占比 8 的倍数占比
R = 2^52 49.89% 24.98% 12.48%
R = 2^53 49.98% 25.03% 12.56%
R = 2^54 0.00% 49.93% 24.97%
R = 2^55 0.00% 100.00% 50.10%
R = 2^56 0.00% 100.00% 100.00%

Math.floor(Math.random() * 2 ** 54) 永远是偶数。* 2 ** 55 永远是 4 的倍数。有一半(或四分之三)的整数你永远随机不到。

日常业务里取值范围超过 9e15 的场景不多,但这条规律的意义在于提醒你:Math.random() 的输出空间就那么大,它不是"无限的实数",是一张有 2^53 个格子的表。

# 三、分布本身没毛病:1 亿次的账

公平地说,Math.random() 的统计性质是过关的。我跑了 1 亿次:

指标 实测值 理论值
均值 0.49997236 0.5
方差 0.08333681 0.08333333(1/12)
标准差 0.28868115 0.28867513
卡方(1000 桶,df=999) 954.85 均值 999,标准差 44.7
1000 个桶中最少/最多 98928 / 100985 期望 100000
v < 1e-6 的次数 102 期望 100
最小值 / 最大值 1.78e-9 / 0.999999988 —

卡方 954.85,距离理论均值 999 差 −0.99 个标准差,完全落在正常范围内。如果你只是要一个均匀分布的浮点数,Math.random() 是好用的。

它的问题不在"分布不均",在"可预测"和"用错地方"。

# 四、洗牌:sort(() => Math.random() - 0.5) 是歪的

这是最常见的一种误用。直觉上"给每对元素一个 ±0.5 的随机比较结果"应该能打乱数组,实际上它产出的分布严重不均匀。

我用 3 个元素的数组各洗了 120 万次,统计每个元素落在每个位置的概率(理论值 33.33%):

sort(() => Math.random() - 0.5)

位置 0 位置 1 位置 2
元素 0 43.73% 18.72% 37.56%
元素 1 18.73% 68.74% 12.54%
元素 2 37.55% 12.55% 49.90%

Fisher-Yates

位置 0 位置 1 位置 2
元素 0 33.34% 33.36% 33.30%
元素 1 33.29% 33.35% 33.36%
元素 2 33.38% 33.29% 33.33%

元素 1 有 68.74% 的概率留在中间——是理论值的两倍多。最大偏差 35.40 个百分点;Fisher-Yates 的最大偏差是 0.04 个百分点。

6 个元素时偏差小一些(11.96 个百分点),但依然存在:

元素0: 28.63%  10.83%  14.29%  19.10%  12.27%  14.89%
元素5: 19.50%   5.27%  15.86%  14.00%  18.76%  26.62%

散落在深色桌面上的扑克牌,背后是一张歪斜不均的柱状图

原因是:V8 的 sort 用的是 TimSort,比较次数和顺序取决于数组的初始状态,而一个不满足传递性的比较函数(a-b 时正时负)在不同位置上被调用的次数并不相等。它不是"每对元素各掷一次硬币"。

如果你的洗牌结果要给人看(歌单、抽奖顺序、AB 分桶),请老老实实写 Fisher-Yates:

function shuffle(arr) {
  const a = arr.slice()
  for (let i = a.length - 1; i > 0; i--) {
    const j = (Math.random() * (i + 1)) | 0
    ;[a[i], a[j]] = [a[j], a[i]]
  }
  return a
}

# 五、ID 碰撞:200 万条就撞一次

回到开头那行代码。Math.random().toString(36).slice(2, 10) 生成的是 8 位 base36 字符串,空间大小 36^8 = 2,821,109,907,456。

按生日问题,首次碰撞的中位数大约是 1.17741 × √N = 1.17741 × 36^4 = 1,977,556 条。

实测 20 轮,每轮一直生成直到第一次撞上:

478973  598666  643999  722952  979518
1312170 1355081 1384715 1421367 1436503
1937419 2107354 2139338 2357927 3220012
3257669 3386802 3441087 3539354 3968518

中位数 1,937,419,均值 1,984,471,跟理论值 1,977,556 差不到 0.4%。

两张一模一样的白色门卡正面相撞溅出火花

也就是说:一个看起来挺长(8 位)的随机 ID,写到 200 万条左右就会开始撞。最乐观的那轮只撑了 478,973 条。

对一个用户表来说 200 万不算多。而如果 ID 是要暴露给外部的单次 token、优惠券码、邀请码,那这个碰撞率就更危险了——撞上意味着两个人拿到同一个码。

# 六、V8 里跑的到底是什么

把上面几条串起来看,就清楚了:

两个 64 位状态字 (state0, state1)
        ↓  xorshift128+ 变体
     s1 = state0; s0 = state1
     s1 ^= s1 << 23; s1 ^= s1 >>> 17; s1 ^= s0; s1 ^= s0 >>> 26
        ↓
   sum = s0 + s1            (64 位)
        ↓  只取高 53 位
   Math.random() = top53(sum) / 2^53

这套算法除了最后一次加法,全是异或和移位——在 GF(2) 上是完全线性的。而输出又把 64 位状态字里的 53 位直接吐了出来,只藏了 11 位。

这两点加起来,就是它"不安全"的具体含义:状态是确定的、转移是线性的、输出几乎不隐藏状态。拿到足够多的连续输出,就可以把 128 位状态解出来,然后预测之后所有的"随机数"。(我没真去解 V8 的状态——Node 不暴露它,而且 128 位的搜索我自己也跑不动——但这是这类非密码学生成器公认的性质,MDN 和 Node 文档也都直接标注了 Math.random() 不应用于安全场景。)

对比一下真正的 CSPRNG:它要求即使攻击者拿到全部历史输出,也无法以优于瞎猜的概率预测下一位。Math.random() 一条都不满足。

# 七、安全随机源其实不贵

很多人不用 crypto 是因为"慢"。实测一下(每次 300 万次调用):

方法 耗时
Math.random() 5.1 ns
randomInt(0, 1e9) 18.9 ns
randomUUID() 78.3 ns
crypto.getRandomValues(Uint32 × 1) 761.3 ns
randomBytes(16) 906.0 ns

厚重的钢制保险库门旁边放着一个脆弱的木质骰子,强烈的侧光

randomInt() 只比 Math.random() 慢 3.7 倍——18.9 纳秒。你的业务逻辑里随便一次对象解构都不止这个数。

至于 getRandomValues 的 761 ns,那是单次调用的固定开销,不是每个字节的开销。批量取 256 个 32 位随机数摊薄后是 3.4 ns/个,反而比 Math.random() 批量调用的 4.0 ns 还快。所以"用 crypto 会拖慢系统"这个理由基本站不住,除非你真的在一个紧密循环里一个一个地取。

# 八、所以该怎么选

场景 用什么 理由
动画抖动、粒子、游戏内的非关键随机 Math.random() 5.1 ns,分布没问题,没人会去预测你的粒子
洗牌、随机排序 Math.random() + Fisher-Yates 别用 sort 那个写法,实测偏差 35 个百分点
抽样、A/B 分桶、模拟 Math.random() 统计性质过关,1 亿次卡方 954.85
验证码、一次性 token、重置链接 crypto.randomBytes / randomUUID() 种子一旦被指定就全变常量
会话 ID、订单号、优惠券码 crypto.randomUUID() 8 位 base36 在 200 万条就开始撞
抽奖、抽签(涉及利益) crypto.randomInt() 18.9 ns,可预测性会被利用
密码学密钥、salt、nonce 只用 crypto,绝不碰 Math.random() 没有讨论余地

一句话总结:Math.random() 是个质量不错的"统计随机",不是"安全随机"。 它的分布我能测到满意,它的可预测性我也能用一个启动参数演示给你看。区分这两件事,比记任何一条具体规则都重要。


本文所有脚本不依赖网络,可在本机复现(Node v24.16.0 / Linux x86_64):

  • 种子复现:node --random-seed=42 your-script.js,比两次输出
  • 分辨率:Math.random() * 2 ** 53 是否恒为整数
  • 洗牌偏差:统计 sort(() => Math.random() - 0.5) 各位置的落点频率
  • 碰撞:一直生成 ID 直到 Set.has() 命中