1. 随机数的本质:真随机、伪随机与密码学安全随机
1.1 从“掷骰子”讲起:到底什么才叫随机
很多刚接触随机数的人,以为随机数就是“没规律的数字”,这个想法在工程上会吃大亏。我接手的模拟项目X里,有个同事用默认种子跑了十万次蒙特卡洛,每次结果都一样,他一度以为自己写错了代码。其实这就是随机数的第一个分水岭:伪随机数生成器(PRNG)不是真的随机,它只是一个“看起来随机”的确定性序列,种子一旦固定,整个序列就固定了。
真正的随机性来自物理世界的熵源,比如热噪声、放射性衰变、电路抖动。但这种真随机源在普通电脑上并不直接可用,成本高、速率低。日常开发里,你用的random()函数大多是伪随机。区分这两者的意义在于:做游戏、模拟实验、抽样调查时,伪随机基本够用;但做密钥、令牌、加密盐时,必须用密码学安全伪随机数生成器(CSPRNG),否则攻击者一旦猜到你用的算法和种子,就能推算出全部输出。
我一般这样跟人解释:伪随机像一部电影,每次播放画面都相同,但你没看过时觉得它是新的;真随机像窗外的人流,你永远无法预测下一个经过的是谁。工程上需要的是“够用”的随机,而不是哲学意义上的绝对随机。
1.2 伪随机数生成器(PRNG)是怎么“骗”过你的
PRNG 的核心是一个状态向量和一个更新函数。初始状态由种子决定,之后每调用一次,状态按照固定规则变化,同时输出一部分状态或对状态做变换得到结果。常见的线性同余生成器(LCG)公式很简单:
state = (a * state + c) mod m其中a、c、m是精心挑选的常数,state是内部状态。这种生成器速度快、代码少,但周期可能不够长、低位随机性差。很多老课程代码爱用 LCG,实际上一旦用于加密或高精度模拟,效果很糟糕。
随后的梅森旋转算法解决了周期问题:它的周期长达 2^19937 - 1,输出质量也通过了大量统计检验,很长一段时间里是各种语言标准库的默认选择。可梅森旋转也有自身问题:初始化状态数组时如果处理不当,会出现大量连续零状态,导致初始输出偏差极大;另外它不属于密码学安全,因为通过连续 624 个输出就能恢复全部内部状态,这对加密场景是致命漏洞。
1.3 密码学安全伪随机数生成器(CSPRNG)到底“安全”在哪
CSPRNG 比普通 PRNG 多了一个关键要求:即使攻击者拿到了大量连续输出,也无法反推出内部状态或预测后续输出。普通 PRNG 做不到这一点,因为它的状态更新是线性或可逆的;而 CSPRNG 在输出阶段会加入不可逆的哈希、加密或模幂运算,让输出的每一位都像是所有内部状态的混合产物。
实际开发中,你不需要自己实现 CSPRNG,直接用操作系统提供的接口就行。Linux 的/dev/urandom、Windows 的BCryptGenRandom、以及各语言标准库的secrets、SecureRandom类都是封装好的入口。我的建议是:凡是跟安全相关的随机数,永远不要从random模块取;必须走专门的安全通道。
这里有一个容易混淆的地方:系统熵源耗尽后,CSPRNG 并不会停下来,而是会通过内部状态继续生成。Linux 的/dev/urandom即使在启动早期也能提供不可预测性,别再用过时的“urandom 会在熵不够时变弱”的说法吓自己了。
2. 主流随机数算法横评:从 LCG 到现代生成器
2.1 线性同余生成器:教科书里的入门方案
LCG 不是不能用,但限制极多。以经典参数a = 1103515245, c = 12345, m = 2^31为例,周期只有 2^31,状态空间太小,且低位的随机性尤其差。如果用它做数组洗牌,你会发现奇偶规律特别明显。曾经有游戏抽卡系统因为用了 LCG 做概率判定,被玩家从几千次抽卡记录中反推出规律,最后被迫回档。
如果你只是做简单的随机打乱,且数据量不大,LCG 可以作为教学示例,但不建议上生产环境。至少要去掉低几位,或者把输出值做一次哈希。不过既然现代语言都内置了更好的生成器,实在没必要自己造轮子。
2.2 梅森旋转:质量不错但坑也不少
梅森旋转(MT19937)在很长一段时间里是 C++、Python、PHP 等语言的标准库默认。它的优点是周期长、统计均匀性好、速度快。学会用mt19937很容易,但你真正要关注的是初始化细节。
我用过一个统计模拟框架,开发人员在构造mt19937时直接传了一个小整数种子,结果批量测试时跑出的随机序列高度相关,因为不同种子的初始状态之间只有极小的差异。后来我们改成用seed_seq对大量熵做混合,再把 32 位种子扩展成 624 个 state 值,问题才消失。
另一个坑是 MT19937 的输出不是密码学安全的。我在某次安全审计中看到有人用mt19937生成会话 token,攻击者只要连续拿到 624 个 token 就能推导出所有历史残留状态。这不是危言耸听,网上有很多现成的预测脚本。所以请明确一条红线:非安全场景可以用 MT,安全场景绝对不能用。
2.3 PCG 和 xoshiro:性能与质量的现代平衡方案
近十年的随机数研究重点已经不在“周期有多长”,而在“状态如何快速更新”和“统计质量如何保证”。PCG 系列采用置换组合思路,输出经过多重乘法、移位和旋转,统计性质非常优秀,而且状态极小(128 位或 64 位),JIT 友好,适合在游戏和模拟中大显身手。
xoshiro 系列则是一组基于移位和异或操作的线性生成器,比如 xoshiro256**,速度极快,且通过了 BigCrush 全套测试。不过它的缺点是低位质量一般,如果你的输出需要落到小范围整数,最好先做右移或哈希混合。
我个人的选型经验是:如果项目追求稳定且不想过多思考,直接用系统库的默认就行;如果是一个高性能模拟应用,需要大量并行随机流,PCG 和 xoshiro 值得考虑。但记得它们同样不是 CSPRNG,只适合非安全场景。
2.4 密码学随机数的实现:依赖系统熵源才是王道
在代码层面实现 CSPRNG,不是自己写一个复杂的混合函数就行的。现代系统的做法是:先收集环境熵(硬件随机数发生器、中断时间、内存布局),灌入一个种子池,然后用 SHA-2 或 AES 等标准原语持续生成输出。用户态程序不需要关心底层细节,直接调用getrandom()或语言库的secrets模块就可以。
有人问过我:“我用 SHA-256 对时间戳哈希一下,能不能当随机数用?”答案是不能。时间戳本身可被预测,哈希并不增加熵,攻击者只要知道你生成的时间点,就能枚举候选值。安全的随机数必须有不可预测的熵源,而不是靠一些看似复杂的变换去“算”出随机性。
3. 随机数使用的实操细节:种子、分布与并行流
3.1 种子选择的常见错误与正确姿势
几乎所有 PRNG 都需要种子来初始化。很多人直接一句话搞定:srand(time(NULL)),这在早期 C 时代是主流做法,但存在两个问题:一是两进程如果同一秒启动,会拿到完全相同序列;二是攻击者如果能大致估计当前时间,就能大幅缩小种子搜索空间。
更好的做法是:从系统熵源取种子。C++ 里可以用std::random_device配合seed_seq;Python 3 里random.seed()默认使用系统熵源,但如果你想复现实验,就必须显式传一个确定种子。实用建议是:调试时用固定种子保证可复现,发布时省略或从系统熵源取种子。
这里还要警惕一个反模式:有些开发者为了“更随机”,反复给 PRNG 重新播种。这反而会破坏生成器的统计特性,因为种子之间的状态关联会导致输出序列片段高度相关。正确的做法是一开始播种一次,之后持续调用即可。
3.2 取模区间映射:为什么rand() % N不是均匀的
一个很经典的问题是:rand() % N到底均不均匀?答案取决于N是否能整除生成器的值域。假设rand()返回 0 到 32767,你想生成 0 到 29999,由于 32768 不是 30000 的整数倍,某些余数值出现的概率就会略高。这个偏差在单次抽样中看不见,但在大量抽样或彩票类系统里会形成明显规律。
正确做法是使用“拒绝采样”:在值域上取一个不超过上限的最大倍数,如果随机数落入超出倍数的范围就丢弃重新生成。更简单的方法是直接用标准库的分布类,例如uniform_int_distribution和uniform_real_distribution。它们内部已经把均匀映射处理好了。
对于浮点数,我提醒一句:不要用rand() / RAND_MAX这样的直接除法,因为RAND_MAX可能不是 2 的幂,会导致浮点分布不均匀。用<random>里的uniform_real_distribution或 Python 的random.uniform更安全。
3.3 并行模拟里如何生成互不相关的随机流
当你用多线程或多进程并行跑蒙特卡洛,如果所有线程共用同一个 PRNG,就会出现同步锁竞争,还会带来随机序列的重叠。很多人的第一反应是给每个线程固定一个种子,但这样做极易让不同线程的初始状态相关,导致并行结果出现伪相关。
推荐做法有两种:一是从主生成器进行“随机跳转”或“分叉”,例如 PCG 的跳跃函数,xoshiro 也提供了jump方法,可以一次性前进 2^128 步,让每条分支落在不同空间段;二是为每个工作进程生成独立的随机数流,即用一个 CSPRNG 生成多个种子,每个种子独立初始化自己的 PRNG。
我实测过的经验是:在 32 核机器上,每个进程独立一个 PCG64,帧间相关性很低,性能也远好于共享一个 MT。如果你用的是 Python 的numpy,建议为每个子进程创建独立的Generator,不要直接复制默认的全局实例。
3.4 别忽略random.seed的可复现性与扰动
可复现性是把双刃剑。在实验设置里,你想要固定种子,这样别人能跑出一样的结果;但如果你用的是 Python 默认的random模块,又希望它基于系统熵源,那么就不要显式给random.seed()传参。Python 虚拟机启动时会自动用系统熵初始化,这个行为在不同版本间大体一致,但别依赖内部的种子算法。
另一种“可复现性陷阱”出现在深度学习里:框架的随机种子很多,包括 numpy、random、torch、tf 等,你只修复了其中一个,别的还是随机的。所以必须写一个统一的种子设置入口,把涉及随机数的模块全部固定下来,才能确保实验可复现。我见过太多论文复现失败,原因就是只设了random.seed,没管 numpy 的全局随机状态。
4. 典型应用场景拆解:模拟、抽样、游戏与安全设计
4.1 蒙特卡洛模拟中的随机数要求:相关性与方差
蒙特卡洛模拟的核心思想是用大量随机样本逼近复杂系统的期望值。比如用随机抽样估算圆周率,或者模拟期权定价。它对随机数的要求不是“不可预测”,而是“均匀、独立、可再现”。你希望不同样本之间没有相关关系,否则估计结果会偏。
实际操作中,我常遇到的坑是:使用了一个周期较短的 PRNG,在百万次抽样后出现重复周期,导致方差被显著低估。解决方法是选择周期足够长的生成器,比如 MT19937 或 PCG64。同时要保存生成种子和随机流状态,方便问题定位。另一个建议是:对输出做变换时,比如使用正态分布,不要直接把均匀随机数代入复杂的非线性公式,而要用算法库里的分布函数,避免变换引入偏差。
4.2 随机采样与 A/B 测试分层:如何保证组间公平
在做 A/B 测试时,通常需要把用户分桶到实验组和对照组。这里的随机数必须满足两个特征:稳定可重复和分组分布均衡。稳定可重复意味着同一用户在多次访问时会被分到同一组,除非你刻意重新分配。常用的做法是取用户 ID 做哈希,再把哈希值映射到 0 到 99 的桶,而不是每次调用随机数。
这里有一个容易犯的错:直接用random()生成一个浮点数再乘上组数取整。因为用户 ID 在多次请求时会被重新分配,导致实验污染。我当时在某公司的某系统上看到过这种实现,排查了很久才发现是随机数每次刷新导致的流量分配不均。正确的做法是使用“确定性分桶”,也就是对用户 ID 和实验标识做 HMAC 或哈希,再映射到桶号。
分层抽样也是同样的思路:如果你想保证男女比例和总体一致,不能单纯用随机抽签,而应该先在性别层内分别生成随机数,再按比例抽取。这个过程里的随机数生成器不需要很复杂,但要保证每一层的抽样独立,避免抽样之间产生干扰。
4.3 游戏中的随机数:手感、公平性与服务器随机
游戏行业的随机数需求非常多元化。地图生成、卡牌洗牌、暴击判定、掉落物抽取、怪物行为,每种场景对随机数的使用方式都不同。比如暴击判定只需要“概率上公平”,但玩家心理上还希望“连暴不要太多”,所以很多游戏引入伪随机分布(PRD),通过动态调整概率来平滑体验。
游戏服务端和客户端的随机数也有区别:客户端随机数主要用于表现层,随时可以更新;服务端随机数用于判定和结算,必须做到防篡改和可审计。我见过一个棋牌项目,洗牌算法放在客户端,玩家利用逆向工程预知了牌序,导致大量作弊。后来他们把洗牌逻辑移到服务端,并用 CSPRNG 每次游戏重新取熵,问题才解决。
如果你做的是单机游戏,用普通 PRNG 就够了,但要注意“种子一致性”:在视频回放或联机同步中,所有客户端必须用同一个种子和时间步进方式,才能保证每个客户端看到相同的结果。这时候就不能使用系统随机源,而要用确定的种子初始化一个可控的 PRNG。
4.4 令牌、密钥与安全随机数的最佳实践
安全领域对随机数的要求不能用“均匀”来概括,它要求的是“不可预测”。生成加密密钥、会话 ID、重置密码链接、API Token,都需要 CSPRNG。我发现很多团队习惯用Math.random()生成验证码,这在浏览器环境中非常危险。好在现代平台提供了crypto.getRandomValues,这是为安全场景设计的。
如果你需要批量生成大量密钥,建议不要直接用主熵源逐字节生成,而是先从一个安全种子生成密钥,再用 KDF 推导多个派生密钥。这里的原则是:安全随机数生成器应当只用来生成熵,不应当承担所有业务逻辑。另外,务必检查随机数生成失败时的异常处理。有些老代码在熵源失败时会回退到伪随机,这是严重的安全隐患。
容易忽略的另一件事是随机数的生命周期管理。密钥生成后,要及时清除内存中的敏感字节,避免被 dump 或日志泄露。相关日志里也别打印完整 token,只打前缀或哈希即可。否则就算生成器没问题,你也会因为日志泄露把密钥交到攻击者手中。
5. 随机数选型决策表与避坑清单
在写代码之前,先回答自己几个问题:这个随机数会不会影响安全?会不会影响实验结果的置信度?会不会被玩家或用户逆向?这三个问题分别对应三类需求:安全不可预测、统计质量高、防逆向/防作弊。下面这张表是我在项目里常用的选型依据,环境不同可以微调。
| 使用场景 | 建议生成器 | 原因 | 禁止使用 |
|---|---|---|---|
| 加密密钥、Token | 系统 CSPRNG(secrets / crypto) | 不可预测、抗离线攻击 | random、MT19937 |
| 蒙特卡洛模拟 | MT19937 或 PCG 系列 | 周期长、统计均匀 | 短周期 LCG |
| A/B 分桶 | 确定性哈希 + 映射 | 稳定重复、可审计 | 每次随机取浮点数 |
| 游戏客户端表现 | PCG、xoshiro 或标准库默认 | 速度快、手感稳 | 无 |
| 游戏服务端判定 | CSPRNG 或专用洗牌 PRNG | 防作弊、防逆向 | 客户端随机数 |
| 可复现实验 | 固定种子 + 标准 PRNG | 结果可对比 | 系统随机熵 |
避坑清单里最值得记住的五条,按重要性排序:
- 安全相关永远走 CSPRNG;
- 别用取模操作做均匀映射;
- 并行系统要使用跳跃或分叉能力,而不是各自传固定种子;
- 实验复现要统一设定所有随机源,不只是默认模块;
- 游戏服务端和客户端的随机数绝不能混用,判定逻辑必须在服务端。
以上这些坑,我几乎都在不同项目里踩过。随机数生成器选错,最可怕的不是性能问题,而是你把所有算力都堆上去后,得到的结果根本不可信。建议拿到一个新需求时,先把“随机数的边界”画清楚:需要复现就固定种子,需要安全就系统熵,需要并行就跳跃分叉,需要均衡就好好做映射。想清楚这三个问题,再开始写代码,你会发现后面的路顺很多。