PHP抽奖算法怎么公平

wen PHP项目 2

PHP抽奖算法怎么公平?揭秘防止“内定”与“随机性”的终极平衡术


目录导读

  1. 抽奖公平的“敌人”是谁? —— 算法偏见与随机数伪随机
  2. 基础随机:rand()mt_rand() 的真正区别
  3. 中奖概率的“加权”陷阱 —— 如何实现“概率可控”但不失公平
  4. 进阶:洗牌算法(Fisher-Yates)在抽奖中的应用
  5. 高并发下的公平性 —— 原子操作与分布式锁
  6. 防作弊机制 —— 抽奖次数限制、用户指纹与时间戳校验
  7. 常见问题解答(FAQ) —— 为什么我连续抽10次都不中?算法能人为干预吗?

抽奖公平的“敌人”是谁?

很多开发者以为抽奖公平 = 完全随机,但真正的公平包含两层含义:

PHP抽奖算法怎么公平

  • 统计学公平:每个参与者的理论中奖概率必须一致。
  • 可验证公平:用户无法通过逆向工程或重复请求提高中奖率。

搜索引擎上大量案例(如某电商平台抽奖被爆“中奖者全是内部员工”)都源于概率权重被硬编码随机种子可预测,PHP中最常见的坑是使用rand()作为随机源,它在某些系统上依赖线性同余生成器(LCG),周期短且可预测。


基础随机:rand()mt_rand() 的真正区别

在PHP 7.1+中,rand()已被mt_rand()替代,但很多老代码仍在使用,我们通过一个对比测试证明:

// 不推荐:rand() 依赖系统C库,可能产生重复序列
$r1 = rand(1, 1000);
// 推荐:mt_rand() 基于梅森旋转算法,周期为2^19937-1
$r2 = mt_rand(1, 1000);

永远使用mt_rand()或更现代的random_int()(密码学安全),如果你要模拟一次抽奖(如10%中奖率),正确写法是:

$chance = mt_rand(1, 100); // 生成1-100的整数
if ($chance <= 10) {
    // 中奖
}

注意mt_rand()是均匀分布,但均匀分布不等于公平——如果奖品池总数与参与者总数不匹配,概率会失衡。


中奖概率的“加权”陷阱

很多业务要求“一等奖1%,二等奖5%”,如果直接为每个用户生成一个随机数判断,会浪费大量计算,更优方案是区间映射法

// 奖品权重映射
$prizes = [
    ['id' => 1, 'name' => '一等奖', 'weight' => 1],
    ['id' => 2, 'name' => '二等奖', 'weight' => 5],
    ['id' => 3, 'name' => '谢谢参与', 'weight' => 94],
];
$totalWeight = array_sum(array_column($prizes, 'weight'));
$rand = mt_rand(1, $totalWeight);
foreach ($prizes as $prize) {
    if ($rand <= $prize['weight']) {
        // 中奖!
        break;
    }
    $rand -= $prize['weight'];
}

公平性关键:权重数值必须使用整数,且总和不能太大(建议小于10000),否则浮点误差会让低概率奖品失效,更重要的——权重值必须存数据库,且由后台动态调整,这样即使用户反编译前端也无法知道哪个权重对应哪个奖品。


进阶:洗牌算法(Fisher-Yates)在抽奖中的应用

如果你有固定奖品池(如100个奖品,1000人抽),但要求每人中奖概率相同,则需要打乱奖品池顺序,按顺序发奖,这就是经典的洗牌算法

function shuffleAssoc(array &$arr) {
    $keys = array_keys($arr);
    for ($i = count($keys) - 1; $i > 0; $i--) {
        $j = mt_rand(0, $i);
        // 交换键值
        list($arr[$keys[$i]], $arr[$keys[$j]]) = [$arr[$keys[$j]], $arr[$keys[$i]]];
    }
}

场景:用户抽中了一等奖,但该奖已被领完,系统自动降级为二等奖,此时需要动态调整剩余权重,否则会出现超发。


高并发下的公平性 —— 原子操作与分布式锁

最经典的“不公平”场景:100个人同时点击抽奖按钮,但后台使用if($userWin) { ... }并发时可能两个用户同时通过检查,解决办法:

  • 数据库原子更新UPDATE prizes SET stock = stock - 1 WHERE id = ? AND stock > 0,受影响行数为0则代表已抢完。
  • Redis分布式锁:在生成随机数之前获取锁,防止重复中奖。

注意:随机数生成本身是线程安全的(PHP是多进程模型),但业务判断和库存扣减必须在一个事务里


防作弊机制

公平的另一面是防止恶意用户刷奖

  • 同一用户抽奖限频:时间窗内限制次数(如1次/秒)。
  • IP + UserAgent指纹:防止换账号。
  • 时间戳加盐哈希:用户ID + 时间戳 + 私钥生成抽奖签名,服务端校验,防止重放攻击。
$token = hash_hmac('sha256', $userId . time(), 'secret_key');
if (verifyToken($token)) {
    // 执行抽奖
}

关键在于:让用户无法预测随机种子,可以使用random_int()(底层调用操作系统的熵源)代替mt_rand()


常见问题解答(FAQ)

Q1:为什么我连续抽10次,一次都不中?

A:只要奖品池不为空,且概率设置合理(如10%),连抽10次不中的概率是 9^10 = 34.8%,这是正常随机现象,但如果你发现所有人几乎都不中,检查是否代码错误地在foreach循环里重置了$rand

Q2:算法能人为干预中奖结果吗?

A:可以,且必须能,如果后台管理员想让特定用户中奖(如内测),需要在抽奖前根据用户ID做一次“预分配”,但公开的全民抽奖,建议算法随机、管理端不可干预,否则触碰法律红线。

Q3:如何验证抽奖结果是否公平?

A:记录每次抽奖的request_id、用户ID、随机种子、时间戳,存入日志,后期可复现算法并审计,实践中可用openssl_random_pseudo_bytes()生成64位随机种子,存库中,事后计算哈希值比对。

Q4:使用Redis做抽奖队列,如何保证顺序公平?

A:使用LPUSH+BRPOP模拟队列,但不要用队列的顺序作为抽奖依据,因为用户网络延迟不同,更好的做法是:每个用户独立生成随机数,结果互不影响。


PHP抽奖公平性的核心在于:随机源不可预测 + 事务性库存扣减 + 防重放校验,作为开发者,请务必使用random_int(),永远不用rand();权重配置与业务逻辑分离;并且千万不要把随机逻辑写在前端JS里——那是最大的不公平漏洞,从今天开始,把你的抽奖代码提升到密码学安全级别,用户才会真正信任你。

抱歉,评论功能暂时关闭!