PHP 抽奖程序怎么做?权重概率算法、库存防超发与接口防刷
活动页里的“大转盘”看起来简单,真正做的时候才会遇到三个问题:概率怎么配得准、库存怎么不超发、并发怎么不被刷爆。本文用原生 PHP 给出一套可直接改造的实现,并说明每一步的坑。

一、为什么不能用 rand()/mt_rand()
rand()、mt_rand() 是非加密安全的伪随机,种子可预测。抽奖一旦涉及实物或现金,用它们等于把结果交给可推算的算法。PHP 里正确选择:
random_int($min, $max)(PHP 7+):基于操作系统 CSPRNG,密码学安全,抛异常而非静默失败;random_bytes($len):需要生成 token/签名时用;- 纯展示、无利益的小游戏可以用
mt_rand,但也要在代码里注明“非公平场景”。
二、按权重抽奖的核心算法
最常用的是“权重区间 + 随机落点”,即把所有奖项权重累加成总权重 T,取 1..T 的随机数,落在哪个累加区间就中哪个奖:
$prizes = [
['id'=>1, 'name'=>'一等奖', 'weight'=>1],
['id'=>2, 'name'=>'二等奖', 'weight'=>9],
['id'=>3, 'name'=>'三等奖', 'weight'=>90],
['id'=>4, 'name'=>'谢谢参与', 'weight'=>900],
];
function draw(array $prizes): array {
$total = array_sum(array_column($prizes, 'weight'));
$point = random_int(1, $total);
$acc = 0;
foreach ($prizes as $p) {
$acc += $p['weight'];
if ($point <= $acc) return $p;
}
return end($prizes);
}
$result = draw($prizes);
权重 1/9/90/900 时,理论概率分别是 0.1%/0.9%/9%/90%。想验证概率,跑 100 万次统计即可,别凭感觉配权重。
性能优化:奖项多且调用频繁时,先预生成累加数组,再二分定位($acc[] + bsearch),把 O(n) 降为 O(log n)。奖项固定不变时,可在配置里直接存累加边界。
三、库存限制:先筛再抽
真实活动必然“一等奖只有 3 个”。做法是抽取前把已发完的奖项从候选池剔除,而不是抽中后再判库存:
$stock = $pdo->query("SELECT prize_id, remain FROM prize_stock WHERE remain > 0")
->fetchAll(PDO::FETCH_KEY_PAIR); // prize_id => remain
$pool = array_values(array_filter($prizes, fn($p) => ($stock[$p['id']] ?? 0) > 0));
if (!$pool) $pool = [ ['id'=>0,'name'=>'已抽完','weight'=>1] ];
这样做的副作用是概率会随库存变化漂移——一等奖发完后,原本 0.1% 落到谢谢参与,导致后面用户的中奖率整体下降。想保持“恒定概率”就要用预生成奖池方案:把 N 个具体奖项打乱后放进队列,每次弹出一个,见下节。
四、并发与防超发(关键)
抽奖事故几乎都是并发导致。三层防护:
- 资格校验:每人次数限制用数据库唯一索引兜底,而不是只在 Redis 里计数。
UNIQUE KEY uq_uid_day (uid, act_date),插入冲突即“已抽过”,天然防重复。 - 扣库存用原子 SQL:
UPDATE prize_stock SET remain = remain - 1 WHERE prize_id = ? AND remain > 0
判断rowCount(),为 0 说明没抢到,立即回退到“谢谢参与”。绝对不要先 SELECT 再 UPDATE,那个间隙足以超发。 - Redis 预扣 + Lua 保证原子(高并发场景):
EVAL "if redis.call('DECR','stock:'..KEYS[1]) >= 0 then return 1 else redis.call('INCR','stock:'..KEYS[1]) return 0 end" 1 prizeId
Lua 在 Redis 内单线程执行,天然原子,QPS 上万时靠它挡在数据库前。
如果用 MySQL,记得扣库存与写中奖记录放同一事务,并给行锁(SELECT ... FOR UPDATE)或用上一条原子 UPDATE 替代锁,减少死锁概率。
五、防刷与安全
- 服务端出结果:转盘角度只是动画。前端只提交“开始抽奖”,返回值里给
prize_id和旋转角度,绝不由前端算。 - 接口签名 + 时间戳:请求带
sign = md5(uid + ts + secret),服务端校验并限制ts偏移,配合nonce去重(RedisSETNX+ 过期),挡掉脚本重放。 - 限流:单 IP/单 uid 每秒 1 次,可用令牌桶(Redis + Lua);活动页静态资源走 CDN,抽奖接口单独一条内网路径。
- 校验登录态,未登录只允许浏览不允许抽奖;关键活动再加滑块验证。
- 日志留痕:记录 uid、时间、随机落点 point、总权重、结果、trace_id,用于事后复盘和投诉处理。
六、可复用的完整流程
1 检查活动状态与时间区间(服务端时间,不信任客户端) 2 校验签名 / nonce / 限流 3 查询剩余次数(唯一索引 + INSERT 占位) 4 取候选池(剔除库存为 0 的奖项) 5 random_int 落点,权重区间定位奖项 6 原子扣库存;失败则降级为“谢谢参与” 7 写中奖记录(同事务) 8 返回 prize_id + 转盘角度 + 奖品图片 URL
七、常见问题 FAQ
问:能不能保证“第 100 次必中”?
答:可以,用保底计数器(每人 pity 字段,达到阈值直接从高档奖池抽),但要明确写进活动规则,避免虚假宣传风险。
问:为什么我配 0.1% 却一晚上就发完了?
答:多半是剔除逻辑写反,或某奖项权重被当成百分比(写成 0.1 而非整数)。权重建议全部用整数,用总权重归一,避免浮点误差。
问:抽奖接口要不要用消息队列?
答:中奖结果落库可以异步(MQ 削峰),但库存扣减必须同步返回,否则用户会看到“恭喜中奖”随后又提示失败。
最后提醒:面向公众的抽奖活动在国内有明确合规要求(《规范促销行为暂行规定》要求公示奖项数量、中奖概率、兑奖期限等),技术实现之外,规则页必须与代码配置一致。代码要公平,规则要透明。
生产内容,同时打通搜索引擎(SEO)与AI搜索引擎(GEO)流量入口的软件! 点击进入365建站器 »
