前言
升级到 PHP 8.x 之后,很多人第一次看到Random\Randomizer和四个Random\Engine\*类时都会愣一下:以前一个mt_rand()走天下,现在让我「选择算法」,到底该选哪个?
先说清楚版本关系,因为标题里的版号需要修正:Random\Randomizer这套面向对象的随机数 API 是 PHP 8.2 引入的,不是 8.3。PHP 8.2 带来了引擎(Engine)与随机化器(Randomizer)的分离,以及Mt19937、PcgOneseq128XslRr64、Xoshiro256StarStar、Secure四个内置引擎。PHP 8.3 是在此基础上做加法,补上了Random\Randomizer::getFloat()、Random\Randomizer::getBytesFromString()和边界枚举Random\IntervalBoundary。所以本文的标题虽然写的是 8.3,但一切以 8.2 为基础,涉及 8.3 新增的方法会单独标出。
真正要解决的问题只有一个:这个随机数如果被别人猜到,后果有多严重?后果严重(令牌、验证码、抽奖结果、加密用途)就必须用密码学安全的引擎;后果不严重(洗牌、分流、造测试数据)用非安全引擎换取速度和可复现性。本文给出选择标准、一张对照表和可以直接跑的验证脚本。
一、引擎与随机化器是两层
新的 API 把「产生随机字节」和「把字节变成有用的值」拆成了两层:
| 层 | 职责 | 类 | 你需要关心的 |
|---|---|---|---|
| 引擎 Engine | 源源不断吐出随机字节 | 实现Random\Engine接口 | 安全性、速度、能否设种子 |
| 随机化器 Randomizer | 把字节转成整数/浮点/字符串/洗牌结果 | Random\Randomizer | 取值的边界、分布是否正确 |
四个内置引擎:
| 引擎 | 可设种子 | 密码学安全 | 典型用途 |
|---|---|---|---|
Random\Engine\Mt19937 | 可以 | 否 | 与老代码mt_rand()行为对齐、需要固定序列的模拟 |
Random\Engine\PcgOneseq128XslRr64 | 可以 | 否 | 通用非安全场景:洗牌、抽样、造数据 |
Random\Engine\Xoshiro256StarStar | 可以 | 否 | 同上,另一套统计特性与状态大小 |
Random\Engine\Secure | 不可以 | 是 | 所有安全敏感场景,也是默认引擎 |
这里最关键的一行是Secure那一行:它不可设种子。因为它取的是操作系统提供的密码学安全随机源(Windows 上是BCryptGenRandom一类接口,类 Unix 上是getrandom(2)或/dev/urandom),没有「种子」这个概念。这直接决定了它的用途——能复现的随机数一定不安全,安全的随机数一定不能复现。
二、怎么选:先问「被猜到会怎样」
| 场景 | 后果 | 选择 |
|---|---|---|
| 密码重置令牌、邮箱验证码、API Key | 账号被接管 | Random\Engine\Secure(或random_bytes()) |
| 抽奖中奖者、优惠券发放顺序 | 可被薅羊毛 | Random\Engine\Secure |
| 加密盐、初始化向量 | 加密体系被破 | Random\Engine\Secure |
| 洗牌、随机排序、A/B 分流 | 用户体验受影响 | PcgOneseq128XslRr64或Xoshiro256StarStar |
| 造测试数据、压测流量 | 无 | 上述任一非安全引擎 + 显式种子 |
| 复现某个历史随机序列 | 无 | Mt19937+ 与老代码相同的种子 |
三条实操规则:
- 不确定就用默认的。
new Random\Randomizer()不传引擎时用的是Secure。性能上它不是瓶颈——现代系统的 CSPRNG(密码学安全伪随机数生成器)本来就是为通用场景设计的;真正的开销差异要看你每秒要取多少个值,而绝大多数 Web 请求每秒取的随机数用十个手指头数得过来。 - 需要复现就一定不是安全场景。只要你在构造函数里传了种子,就说明这个随机序列允许被别人知道,那就别拿它生成任何凭证。
- 非安全引擎三选一不用纠结。
Mt19937的价值主要是「与mt_rand()一致」,用于兼容老代码;新写的非安全场景直接用PcgOneseq128XslRr64或Xoshiro256StarStar,哪个更快请自己在目标环境上实测,不同 PHP 版本与 CPU 架构下的排序并不总是一致。
另外要记住:换了引擎不等于换了Randomizer的方法。getInt()、shuffleArray()这些方法在哪个引擎下都可用,它们的区别只在于底层字节从哪来。
三、安全边界:这些函数不是密码学安全的
新 API 不会自动保护你——只要你还在用老函数,风险照旧。
| 函数 | 密码学安全 | 说明 |
|---|---|---|
random_bytes()/random_int() | 是 | PHP 7.0 起提供,底层就是 CSPRNG |
Random\Engine\Secure | 是 | 8.2 起 |
new Random\Randomizer()(不传引擎) | 是 | 内部用Secure |
rand()/mt_rand() | 否 | 可预测,仅凭少量输出即可还原内部状态 |
shuffle()/str_shuffle()/array_rand() | 否 | 使用非密码学的内部随机源 |
uniqid() | 否 | 基于微秒时间戳,可枚举 |
lcg_value() | 否 | 线性同余,统计质量差 |
也就是说,把mt_rand(100000, 999999)当短信验证码发出去,是这套 API 里最危险的一种用法——攻击者拿到几个验证码样本后,理论上可以推算出后续序列。
四、PHP 8.3 新增的方法
在 8.2 的基础上,8.3 补齐了几块明显的短板:
<?php declare(strict_types=1); // 需要 PHP 8.3+ $r = new Random\Randomizer(); // 默认使用 Secure 引擎 // 1) 取浮点数,并指定边界开闭:getFloat(float $min, float $max, IntervalBoundary $boundary) $lat = $r->getFloat(-90, 90, Random\IntervalBoundary::ClosedClosed); // [-90, 90] $lng = $r->getFloat(-180, 180, Random\IntervalBoundary::OpenClosed); // (-180, 180] // 2) 从指定字符集中取字节组成字符串:getBytesFromString(string $string, int $length) $token = $r->getBytesFromString('abcdefghijklmnopqrstuvwxyz0123456789', 32);IntervalBoundary枚举的四个取值,是把数学区间记法直接搬了过来:
| 枚举值 | 区间 | 含义 |
|---|---|---|
ClosedOpen | [min, max) | 默认值,含下界不含上界 |
ClosedClosed | [min, max] | 上下界都可取到 |
OpenClosed | (min, max] | 不含下界含上界 |
OpenOpen | (min, max) | 上下界都取不到 |
为什么浮点数要专门搞个边界参数?因为浮点数无法在任意区间里做到「每个值等概率」——需要决定端点是否允许被返回,以及如何处理舍入。getFloat()把这个决策显式地摆到参数上,而不是像老代码那样靠mt_rand() / mt_getrandmax()自己乘,是对的。
getBytesFromString()有个很实用的性质:字符在源字符串里重复出现,就等于给它加权。这在抽奖里非常方便:
<?php declare(strict_types=1); // 需要 PHP 8.3+ $r = new Random\Randomizer(); // 'a' 出现 3 次,'b' 出现 1 次 —— 抽到 a 的概率是 75% $winner = $r->getBytesFromString('aaab', 1); // 'a' 或 'b'注意它是按字节挑选,不是按字符。源字符串里如果有中文(UTF-8 下一个汉字占 3 字节),长度的语义就变成了字节数,很容易把一个汉字切成半个。加权字符集请只用 ASCII。
五、完整可运行示例
把下面这段存成rand_demo.php直接运行(需要 PHP 8.3+)。它包含四个真实场景,以及一段让你在自己机器上实测的性能对比:
<?php declare(strict_types=1); // 需要 PHP 8.3+(getFloat / getBytesFromString / IntervalBoundary 均为 8.3 新增) // ---------- 1. 安全场景:密码重置令牌 ---------- function passwordResetToken(int $bytes = 32): string { // 默认引擎就是 Secure,这里显式写出来是为了让审阅者一眼看到安全意图 $r = new Random\Randomizer(new Random\Engine\Secure()); return bin2hex($r->getBytes($bytes)); } echo '重置令牌:' . passwordResetToken() . "\n"; // ---------- 2. 安全场景:6 位数字验证码(避免取模偏差)---------- function numericOtp(int $digits = 6): string { $r = new Random\Randomizer(); $out = ''; for ($i = 0; $i < $digits; $i++) { // getInt 的上下界都是闭区间 $out .= (string) $r->getInt(0, 9); } return $out; } echo '验证码:' . numericOtp() . "\n"; // ---------- 3. 非安全但需要复现:固定种子的洗牌 ---------- function seededShuffle(array $items, int $seed): array { $r = new Random\Randomizer(new Random\Engine\PcgOneseq128XslRr64($seed)); return $r->shuffleArray($items); } $deck = range(1, 10); $a = seededShuffle($deck, 20240929); $b = seededShuffle($deck, 20240929); printf("种子相同结果相同:%s\n", $a === $b ? '是' : '否'); printf("洗牌结果:%s\n", implode(',', $a)); // ---------- 4. 加权抽奖 + 浮点坐标 ---------- $r = new Random\Randomizer(); $prizes = $r->getBytesFromString('aaabbc', 1); // a:50% b:33.3% c:16.7% echo "抽奖结果:$prizes\n"; $lat = $r->getFloat(-90, 90, Random\IntervalBoundary::ClosedClosed); $lng = $r->getFloat(-180, 180, Random\IntervalBoundary::OpenClosed); printf("随机坐标:%.6f, %.6f\n", $lat, $lng); // ---------- 5. 性能对比:在你的机器上自己跑,我不给结论 ---------- function bench(string $label, callable $fn, int $rounds = 200000): void { $t0 = hrtime(true); for ($i = 0; $i < $rounds; $i++) { $fn(); } $ms = (hrtime(true) - $t0) / 1e6; printf("%-34s %d 次共用 %.1f ms\n", $label, $rounds, $ms); } $secure = new Random\Randomizer(new Random\Engine\Secure()); $pcg = new Random\Randomizer(new Random\Engine\PcgOneseq128XslRr64(1)); $xoshiro = new Random\Randomizer(new Random\Engine\Xoshiro256StarStar(1)); bench('Secure::getInt', static fn () => $secure->getInt(0, 1000)); bench('PcgOneseq128XslRr64::getInt', static fn () => $pcg->getInt(0, 1000)); bench('Xoshiro256StarStar::getInt', static fn () => $xoshiro->getInt(0, 1000)); bench('mt_rand(老函数)', static fn () => mt_rand(0, 1000)); bench('random_int(老函数)', static fn () => random_int(0, 1000));在一台普通开发机上,输出大致长这样(具体数字每次都不一样,请以你自己跑出来的为准):
重置令牌:9f2c1b7e4a08d3... 验证码:471902 种子相同结果相同:是 洗牌结果:6,2,9,1,7,4,10,3,5,8 抽奖结果:a 随机坐标:-13.482910, 176.201385 Secure::getInt 200000 次共用 ... ms PcgOneseq128XslRr64::getInt 200000 次共用 ... ms Xoshiro256StarStar::getInt 200000 次共用 ... ms mt_rand(老函数) 200000 次共用 ... ms random_int(老函数) 200000 次共用 ... ms跑完你就有了自己环境上的真实数据。如果Secure与PcgOneseq128XslRr64的差距在你的业务量级下可以忽略(对绝大多数 Web 应用来说是这样),那唯一正确的选择就是默认的Secure。
常见坑点
1. 用mt_rand()生成令牌
❌$token = md5(mt_rand());——Mt19937不是密码学安全的,观察到足够多的输出后可以还原内部状态并预测后续值;md5也改变不了这一点。 ✅bin2hex(random_bytes(32)),或(new Random\Randomizer())->getBytes(32)。
2. 用时间当种子
❌new Random\Engine\Mt19937(time());—— 种子空间只有几千万种可能,攻击者枚举一遍就能复现你的「随机」序列。 ✅ 需要复现的场景,种子来自配置或测试固件;需要安全的场景,用Secure,它压根不给你种子。
3. 拿Secure引擎想要「可复现」
❌new Random\Randomizer(new Random\Engine\Secure())然后指望两次运行结果一致 ——Secure不接受种子,输出永远不可复现。 ✅ 要复现就换成Mt19937/PcgOneseq128XslRr64/Xoshiro256StarStar并显式传种子,同时确认这个随机数不用于安全用途。
4. 忘了getInt()的上下界都是闭区间
❌ 想取一个 0 到 99 的值却写$r->getInt(0, 100),结果偶尔出现 100;或者想取[1, 6]的骰子写成getInt(1, 5)。 ✅getInt($min, $max)两端都可取到。要[0, 100)就写getInt(0, 99);$min大于$max会直接抛ValueError。
5. 用shuffle()/str_shuffle()生成安全相关的顺序
❌ 用str_shuffle('0123456789')取前 6 位做验证码 —— 这些函数用的是非密码学的内部随机源,且内部算法是公开的洗牌。 ✅ 用$r->shuffleArray($digits)($r为默认的安全引擎),或者直接numericOtp()那样逐位getInt()。
6. 用getBytesFromString()处理非 ASCII 字符集
❌$r->getBytesFromString('甲乙丙丁', 2)—— 它是按字节挑的,会从汉字中间切开,产出非法 UTF-8。 ✅ 加权字符集只用 ASCII;要处理多字节字符就先按字符数组取索引,再映射回字符。
7. 把引擎对象序列化进缓存或 Session
❌ 把Random\Engine存进 Session 或 Redis,指望跨请求延续序列 —— 引擎的内部状态是随机数质量的一部分,序列化与共享会带来不可预期的分布问题,也容易在并发下互相干扰。 ✅ 引擎只在单个请求/单次计算内创建;需要跨请求的「随机」就用数据库或缓存里的持久化值本身。
8. 假设随机 API 永远不会失败
❌$t = random_bytes(16);完全不考虑系统熵源不可用的情况 —— 它会抛异常,未捕获就是 500。 ✅ 用try { ... } catch (Throwable $e) { /* 记录并降级为不可发放凭证 */ }包住;异常的具体类型随版本变化,捕获Throwable最稳。真正要记住的是:熵源拿不到时必须拒绝发放凭证,而不是退回mt_rand()。
总结
| 问题 | 答案 |
|---|---|
Random\Randomizer是哪个版本引入的 | PHP 8.2(标题里的 8.3 只对了后半段) |
| PHP 8.3 加了什么 | getFloat()、getBytesFromString()与边界枚举IntervalBoundary(getBytes()、getInt()、shuffleArray()在 8.2 就有) |
| 默认引擎是什么 | Random\Engine\Secure |
| 安全场景选哪个 | Secure,或者random_bytes()/random_int() |
| 非安全但要快/要复现 | PcgOneseq128XslRr64/Xoshiro256StarStar/Mt19937(可设种子) |
和mt_rand()对齐 | Random\Engine\Mt19937 |
| 老函数还能用吗 | 能用,但rand/mt_rand/shuffle/array_rand/uniqid都不是密码学安全的 |
选算法这件事,说到底只有一句话:先判断「被猜到会怎样」,再决定用不用安全引擎。判断不了就一律用默认的Secure——它慢不到哪里去,而选错的代价是账号被接管。真正需要非安全引擎的场合只有两类:需要复现的测试与模拟,以及需要与老代码序列对齐的兼容场景。把这两类和业务凭证严格分开,选型就不会出错。