什么是"随机":确定性机器的随机性悖论
计算机是确定性系统——相同输入必然产生相同输出。这和我们日常理解的"随机"形成了根本性矛盾。当程序员调用 random() 函数时,计算机并没有真正"掷骰子",而是执行了一个精心设计的数学算法来模拟随机行为。理解这个本质区别是正确使用随机数的基础。
真随机(True Random)来源于物理世界中不可预测的现象:放射性衰变的时间间隔、大气噪声的电压波动、量子隧穿效应的概率分布。硬件随机数生成器(HRNG)通过采集这些物理信号来产生真正不可预测的比特流。Intel CPU 内置的 RDRAND 指令就是利用热噪声作为熵源的典型例子。然而物理熵源的产生速率有限,无法满足高频随机数需求。
伪随机数(Pseudo-Random Number)是用算法从一个初始状态(种子)出发,通过确定性的数学变换不断输出新数值的方式。如果你知道算法和种子,就能完美预测整个序列。但好的伪随机算法能产生通过严格统计测试的输出——在分布均匀性、序列无关性、长周期等方面表现得"足够随机"。
关键认知转变在于:不存在绝对的"好随机"或"差随机",只有"对当前场景是否足够随机"。游戏中的伤害浮动只需要统计均匀性,蒙特卡洛模拟需要低相关性和长周期,而密码生成则需要不可预测性。不同的需求层级对应不同的随机数生成方案,选错方案轻则浪费性能,重则引发安全灾难。
PRNG 原理:Math.random()、LCG 与 Mersenne Twister
线性同余生成器(Linear Congruential Generator, LCG)是最古老也最简单的 PRNG 算法,公式为 next = (a × current + c) mod m。通过精心选择乘数 a、增量 c 和模数 m,LCG 可以产生周期为 m 的伪随机序列。C 语言标准库的 rand() 函数、Java 的 java.util.Random 早期版本都基于 LCG。它极快(一次乘加取模),但有明显缺陷:低位比特的随机性很差,连续输出在高维空间中呈现超平面结构(Marsaglia 效应)。
Mersenne Twister(MT19937)是 1997 年提出的改进算法,使用 624 个 32 位整数作为内部状态,周期长达 2^19937-1(一个梅森素数)。它通过矩阵线性递推和位混淆(tempering)生成输出,在 623 维空间内均匀分布。Python 的 random 模块、Ruby、PHP、MATLAB 默认都使用 MT。MT 的缺点是状态空间大(2.5KB),初始化慢,且观察到 624 个连续输出后可以完全恢复内部状态。
现代浏览器的 Math.random() 大多使用 xorshift128+(V8 引擎)或 xoshiro256**(SpiderMonkey)。xorshift 系列算法通过位移和异或操作混合状态位,速度极快且代码简洁。xorshift128+ 使用 128 位状态,周期为 2^128-1,通过了 BigCrush 统计测试套件。这些算法在性能和统计质量之间取得了优秀的平衡,但同样不具备密码学安全性。
所有 PRNG 共享的核心特征:确定性——相同种子产生相同序列;周期性——输出必然在某个点开始重复;可预测性——给足够信息可以逆推状态。这些特征在非安全场景下不是问题甚至是优点(可复现性便于调试),但在安全场景下就是致命弱点。
// === PRNG 算法演示 ===
// 线性同余生成器 (LCG) 的简单实现
function lcg(seed) {
// 参数来自 Numerical Recipes
const a = 1664525;
const c = 1013904223;
const m = 2 ** 32;
let state = seed;
return function() {
state = (a * state + c) % m; // 核心公式
return state / m; // 归一化到 [0, 1)
};
}
// xorshift32 —— 更现代的轻量级 PRNG
function xorshift32(seed) {
let state = seed;
return function() {
state ^= state << 13; // 左移异或
state ^= state >>> 17; // 右移异或(无符号)
state ^= state << 5; // 再次左移异或
return (state >>> 0) / 4294967296; // 转为 [0, 1)
};
}
// Math.random() 的正确使用方式
// ✅ 生成 [min, max] 范围内的均匀整数
function randomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
// ⚠️ 常见错误:Math.round 会导致边界值概率减半
// Math.round(Math.random() * 10) → 0和10的概率是1-9的一半
// 可复现的 PRNG(mulberry32,适合游戏/模拟)
function mulberry32(seed) {
return function() {
seed |= 0; seed = seed + 0x6D2B79F5 | 0;
let t = Math.imul(seed ^ seed >>> 15, 1 | seed);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
const seededRng = mulberry32(42); // 种子 42
console.log(seededRng(), seededRng()); // 每次运行结果相同CSPRNG:crypto.getRandomValues() 与操作系统熵池
密码学安全伪随机数生成器(Cryptographically Secure PRNG, CSPRNG)在满足统计随机性的基础上,增加了一个关键安全属性:即使攻击者知道使用的算法并且观察到了之前的全部输出,也无法以优于随机猜测的概率预测下一个输出位。这个不可预测性保证是密码、令牌、加密密钥等安全场景的硬性要求。
CSPRNG 的安全性建立在操作系统的熵收集机制之上。Linux 内核通过 /dev/urandom 接口暴露 CSPRNG,熵来源于中断时间抖动、硬件 RNG(如 RDRAND)、磁盘 I/O 延迟等不可预测事件。Windows 使用 BCryptGenRandom,macOS 使用 /dev/urandom(基于 Yarrow/Fortuna 算法)。这些实现经过了数十年的安全审计和攻击测试,是工业级的安全保障。
在 Web 前端,crypto.getRandomValues() 是 W3C 标准的 CSPRNG 接口,所有现代浏览器都支持。它接受一个 TypedArray(如 Uint8Array、Uint32Array)并用密码学安全的随机字节填充。Node.js 中对应 crypto.randomBytes() 和 crypto.randomInt()。这些 API 直接调用操作系统的安全随机源,不存在"熵耗尽"问题(现代 /dev/urandom 在初始播种后始终安全)。
CSPRNG 的性能比普通 PRNG 低 1-2 个数量级,但对于安全场景这完全可以接受——你不会每秒生成数百万个密码。关键原则是:对于安全相关的随机数需求(密码、token、密钥、nonce、验证码),始终使用平台提供的 CSPRNG API,永远不要自己实现密码学随机数生成算法。加密领域的金科玉律是"不要自己造轮子"。
种子与可重现性:确定性随机的应用
种子(seed)是 PRNG 的初始状态值,决定了整个输出序列。相同的算法 + 相同的种子 = 完全相同的"随机"数列。这种确定性在很多场景中不是缺陷而是特性——程序化内容生成(Procedural Content Generation)就是典型代表。Minecraft 的世界种子允许玩家分享和复现特定地图;No Man's Sky 用种子生成了一整个宇宙。
可复现随机性在科学计算和软件测试中同样重要。蒙特卡洛模拟的论文需要其他研究者能验证结果,固定种子使实验可重复。单元测试中使用随机数据时,固定种子确保测试确定性——CI 流水线不会因随机数不同而产生 flaky test。调试随机相关的 bug 时,记录种子可以精确复现问题现场。
JavaScript 的 Math.random() 不支持用户指定种子——浏览器在内部自动设置种子且不暴露接口。如果需要可复现的随机序列,必须使用第三方库(如 seedrandom)或自己实现 PRNG。常见的轻量级选择包括 mulberry32(32 位种子,适合简单场景)、SplitMix64(64 位,Java SplittableRandom 的基础)和 PCG(Permuted Congruential Generator,兼顾质量和性能)。
种子的选择策略取决于场景。需要不可预测性时,从 CSPRNG 获取种子;需要可复现性时,使用用户输入(如世界名称的哈希值)或固定数值。多线程/分布式环境中,每个线程需要独立的种子以避免相关性——SplitMix64 就是为此设计的。注意:永远不要在安全场景中使用可预测的种子(如时间戳、进程 ID),这等于取消了 PRNG 的全部随机性保障。
分布类型:从均匀到正态、指数、泊松
大多数随机数 API 提供的是均匀分布(uniform distribution)——区间内每个值的出现概率相等。但现实世界的随机现象很少服从均匀分布:人的身高服从正态分布,放射性衰变间隔服从指数分布,一定时间内的到达事件数服从泊松分布。将均匀分布转换为目标分布是概率编程的基础技能。
正态分布(高斯分布)是最常用的非均匀分布。Box-Muller 变换可以从两个均匀随机数 u1、u2 生成标准正态分布样本:z = √(-2·ln(u1)) · cos(2π·u2)。要得到特定均值 μ 和标准差 σ 的正态分布,只需线性变换 result = μ + σ·z。这在游戏设计(伤害浮动围绕基础值波动)、金融模拟(股价波动模型)中非常实用。
指数分布常用于模拟等待时间或事件间隔。从均匀分布 u 转换只需 -ln(u)/λ,其中 λ 是速率参数。网络请求重试的指数退避(Exponential Backoff)就是这种分布的实际应用。泊松分布则描述固定时间内独立事件发生的次数,可以通过累加指数分布的间隔来模拟。
加权随机选择是游戏和推荐系统的核心需求:从一组选项中按不同概率随机选取。实现方式包括累积分布函数(CDF)查表法(O(log n) 二分查找)和 Alias Method(O(n) 预处理后 O(1) 选择)。Fisher-Yates 洗牌算法则是对数组进行随机排列的唯一正确方法——任何基于 sort(() => Math.random() - 0.5) 的"洗牌"都会产生偏差分布。
场景决策:游戏 vs 安全 vs 模拟
游戏开发中,普通 PRNG 几乎总是正确选择。随机地图生成、战利品掉落、伤害浮动、AI 行为随机化——这些不需要密码学安全性,但需要高性能(每帧可能调用上千次)和可复现性(相同种子生成相同关卡)。PRNG 的确定性特征在多人同步游戏中尤为重要:所有客户端使用相同种子运行相同 PRNG,可以在不传输随机数的情况下保持同步。
安全场景(身份认证、加密、授权)必须使用 CSPRNG,没有例外。密码生成、session token、CSRF token、JWT secret、OAuth state、一次性密码、密码重置链接中的随机部分——所有这些的安全性都依赖于不可预测性。性能在这里不是考量因素:生成一个 32 字节的安全随机 token 只需微秒级时间,而一旦使用了不安全的随机源,整个系统的安全模型就崩塌了。
科学模拟和统计计算需要的是高统计质量的 PRNG:长周期(避免序列重复导致的相关性偏差)、良好的高维均匀性(蒙特卡洛积分依赖于样本在高维空间中的均匀分布)、可复现性(实验可重复是科学的基本要求)。Mersenne Twister 在这个领域是经典选择,但更现代的 PCG 和 xoshiro 系列在所有指标上都更优。
做出正确选择的决策树很简单:①如果攻击者预测你的随机数会造成安全问题→用 CSPRNG;②如果需要可复现性或极高性能→用带种子的 PRNG;③如果都不需要→Math.random() 就够了。记住:从安全角度来说,"可能不需要 CSPRNG"不等于"可以不用 CSPRNG"——当你不确定时,默认用安全的那个。
常见错误与最佳实践总结
错误一:用 Math.random() 生成密码、token 或任何安全凭证。这是 JavaScript 领域最普遍的安全反模式。Math.random() 的设计目标是速度和统计质量,不是安全性——它的输出在理论上可以从少量样本逆推。正确做法:安全场景一律用 crypto.getRandomValues()(浏览器)或 crypto.randomBytes()(Node.js)。
错误二:用时间戳作种子生成安全相关的值。Date.now() 精度有限(毫秒级),攻击者通常能将服务器时间缩小到一个很小的范围内。用时间戳播种 PRNG 生成 token 等于把安全性降低到了"猜对毫秒时间戳"的难度——这对攻击者来说可能只需枚举几千到几百万个可能性。2012 年某扑克网站就因此被攻破。
错误三:取模偏差(Modulo Bias)。crypto.getRandomValues() 返回均匀分布在 [0, 2^32-1] 的整数,当你用 value % n 映射到 [0, n-1] 时,如果 2^32 不能被 n 整除,某些余数会比其他的多出现一次。解决方案是拒绝采样(rejection sampling):丢弃那些会导致偏差的值,只保留在 n 的整数倍范围内的值。
最佳实践总结:安全场景无条件使用平台 CSPRNG API;游戏和模拟使用高质量 PRNG(xorshift128+、PCG)并记录种子;需要非均匀分布时使用正确的变换方法(Box-Muller、逆变换采样);洗牌只用 Fisher-Yates 算法;不要自己实现密码学原语;在代码审查中将 Math.random() 用于安全场景列为严重 bug。
// === 最佳实践代码示例 ===
// ❌ 错误:用 Math.random() 生成验证码
const badCode = String(Math.floor(Math.random() * 1000000)).padStart(6, '0');
// ✅ 正确:用 CSPRNG 生成安全验证码
function secureOTP(digits = 6) {
const max = 10 ** digits;
const arr = new Uint32Array(1);
let value;
do {
crypto.getRandomValues(arr);
value = arr[0];
} while (value >= Math.floor(4294967296 / max) * max); // 拒绝采样消除偏差
return String(value % max).padStart(digits, '0');
}
// ✅ Fisher-Yates 洗牌(唯一正确的数组随机排列算法)
function shuffle(array) {
const arr = [...array]; // 不修改原数组
for (let i = arr.length - 1; i > 0; i--) {
// 安全场景用 crypto,非安全场景用 Math.random()
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
// ✅ 正态分布随机数(Box-Muller 变换)
function gaussianRandom(mean = 0, stddev = 1) {
const u1 = Math.random();
const u2 = Math.random();
const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
return mean + stddev * z;
}
// ✅ 加权随机选择
function weightedRandom(items, weights) {
const cdf = weights.reduce((acc, w, i) => {
acc.push((acc[i - 1] || 0) + w);
return acc;
}, []);
const total = cdf[cdf.length - 1];
const r = Math.random() * total;
return items[cdf.findIndex(cumWeight => r < cumWeight)];
}
// 使用示例:60%普通、25%稀有、10%史诗、5%传说
weightedRandom(['普通', '稀有', '史诗', '传说'], [60, 25, 10, 5]);