PHP项目抽奖系统实现全攻略:从核心逻辑到高并发优化
目录导读
- 抽奖系统的基本原理与常见玩法
- 数据库设计与概率分配算法
- 核心业务逻辑实现(含代码示例)
- 高并发场景下的防超抽与一致性保障
- 安全机制:防刷与风控策略
- 实战问答:5个最常遇到的开发问题
抽奖系统的基本原理与常见玩法
在PHP项目中,抽奖系统的核心是概率控制与资源锁定,常见的玩法包括:幸运转盘、随机抽卡、积分抽奖、定时抢券等,无论形式如何,底层逻辑都是:用户发起请求→服务端校验资格→执行概率算法→扣除库存或积分→返回结果。

一个完整的抽奖系统需要解决三个关键问题:
- 如何保证概率公平?
- 如何在并发下不“超额发放”?
- 如何防止恶意刷奖?
数据库设计与概率分配算法
1 数据表结构(核心三表)
-- 奖品表 CREATE TABLE `prizes` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL COMMENT '奖品名称', `total` int(11) NOT NULL DEFAULT 0 COMMENT '总库存', `remain` int(11) NOT NULL DEFAULT 0 COMMENT '剩余库存', `probability` decimal(5,4) NOT NULL COMMENT '概率 如0.1500表示15%', `prize_type` tinyint(1) NOT NULL COMMENT '1实物 2虚拟 3积分 4无奖', `weight` int(11) NOT NULL DEFAULT 0 COMMENT '权重排序字段', `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 中奖记录表 CREATE TABLE `win_records` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` int(11) NOT NULL, `prize_id` int(11) NOT NULL, `award_code` varchar(32) NOT NULL COMMENT '兑奖码', `status` tinyint(1) DEFAULT 0 COMMENT '0未领取 1已领取', `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 用户每日抽奖次数表(防刷) CREATE TABLE `user_lottery_limit` ( `user_id` int(11) NOT NULL, `draw_date` date NOT NULL, `times` tinyint(1) DEFAULT 0, PRIMARY KEY (`user_id`,`draw_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
2 概率分配算法:权重区间法
/**
* 核心抽奖算法 - 基于权重区间
* @param array $prizes 奖品列表(含id,name,probability,remain)
* @return array|null 中奖奖品数据或null(未中奖)
*/
function lotteryByWeight(array $prizes): ?array {
// 1. 过滤库存为0的奖品,计算总权重
$validPrizes = array_filter($prizes, fn($p) => $p['remain'] > 0);
$totalWeight = array_sum(array_column($validPrizes, 'probability'));
if ($totalWeight <= 0) return null;
// 2. 生成随机数 [0, 总概率)
$rand = mt_rand(1, $totalWeight * 10000) / 10000; // 保留4位小数精度
// 3. 遍历奖品,累加概率区间
$current = 0;
foreach ($validPrizes as $prize) {
$current += $prize['probability'];
if ($rand <= $current) {
return $prize;
}
}
return null; // 理论上不会执行到,但保留兜底
}
原理说明:将每个奖品的概率映射到一条数轴上,随机数落在哪个区间就中哪个奖。注意:总概率可以小于1(剩余概率即为“不中奖”),但务必保证所有概率之和不超过1。
核心业务逻辑实现(含代码示例)
1 抽奖主流程
class LotteryService {
private $db; // PDO实例
private $redis; // Redis实例
/**
* 执行一次抽奖
* @param int $userId
* @return array ['code'=>0成功/-1失败, 'prize'=>奖品数据]
*/
public function draw(int $userId): array {
// Step1: 校验资格(每日次数、积分余额等)
if (!$this->checkDrawLimit($userId)) {
return ['code' => -1, 'msg' => '今日次数已用完'];
}
try {
// Step2: 使用数据库乐观锁扣库存(关键)
$this->db->beginTransaction();
// 查询当前可用奖品列表(带行锁)
$stmt = $this->db->query("SELECT * FROM prizes WHERE remain > 0 ORDER BY weight DESC FOR UPDATE");
$prizes = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Step3: 执行概率算法
$winPrize = lotteryByWeight($prizes);
// Step4: 更新库存与记录
if ($winPrize) {
$updateSql = "UPDATE prizes SET remain = remain - 1 WHERE id = {$winPrize['id']} AND remain > 0";
$affectRows = $this->db->exec($updateSql);
if ($affectRows === 0) {
// 库存已被其他请求扣完(并发冲突)
$this->db->rollBack();
return $this->handleConcurrentFallback($userId);
}
// 写入中奖记录
$awardCode = $this->generateAwardCode($userId, $winPrize['id']);
$this->db->exec("INSERT INTO win_records (user_id, prize_id, award_code) VALUES ($userId, {$winPrize['id']}, '$awardCode')");
}
// Step5: 更新用户抽奖次数(用Redis或DB计数器)
$this->decrDrawLimit($userId);
$this->db->commit();
return ['code' => 0, 'prize' => $winPrize ?? ['name' => '谢谢参与'], 'award_code' => $awardCode ?? ''];
} catch (\Exception $e) {
$this->db->rollBack();
// 记录日志并返回降级处理
return ['code' => -2, 'msg' => '系统繁忙,请稍后再试'];
}
}
}
2 库存扣减的两种主流方案
| 方案 | 原理 | 适用场景 |
|---|---|---|
| 数据库行锁(FOR UPDATE) | 借助MySQL行锁保证扣减原子性 | 并发量 < 1000 QPS,奖品数量少 |
| Redis Lua脚本 | 原子性操作Redis库存,再异步写DB | 高并发场景(>1000 QPS) |
推荐方案(中大型项目):
- 用Redis的List或SortedSet缓存奖品库存与权重。
- 使用Lua脚本实现“查库存→减库存→写记录”原子操作。
- 异步任务将Redis中的记录同步到MySQL保证持久化。
高并发场景下的防超抽与一致性保障
1 核心挑战
- 超发:同一奖品剩余1个,100人同时请求,需保证只1人中奖。
- 库存不一致:Redis扣减成功但MySQL写入失败,导致数据矛盾。
2 解决方案:Redis+Lua+消息队列
-- Lua脚本:原子扣减库存并记录
-- KEYS[1] = 奖品库存key (prize:stock:{id})
-- KEYS[2] = 用户抽奖次数key (user:draw:{userId})
-- ARGV[1] = 用户ID
-- ARGV[2] = 奖品ID
local stock = redis.call('GET', KEYS[1])
if not stock or tonumber(stock) <= 0 then
return -1 -- 库存不足
end
-- 扣减库存
redis.call('DECR', KEYS[1])
-- 写入中奖记录(使用List作为消息队列,后续消费写入MySQL)
redis.call('LPUSH', 'lottery:record:queue', cjson.encode({user_id=ARGV[1], prize_id=ARGV[2], time=ARGV[3]}))
return 1 -- 成功
PHP调用代码:
$luaScript = file_get_contents('lottery.lua');
$result = $redis->eval($luaScript,
["prize:stock:{$prizeId}", "user:draw:{$userId}", $userId, $prizeId, time()],
2 // 指定KEYS数量
);
为什么要用Lua?
Redis保证脚本的原子执行,中间不会被其他请求打断,彻底解决库存超发问题。
安全机制:防刷与风控策略
1 基础防刷手段
- IP+设备指纹限流:同一IP每小时抽奖上限,使用Redis计数器。
- 用户行为模式检测:连续N次抽奖间隔<1秒,判定为脚本攻击,临时封禁。
- 请求签名校验:前后端约定HMAC算法,防止伪造请求。
2 高级风控规则
- 概率实时动态调整:当某个奖品库存低于阈值时,自动降低其概率。
- 黑名单机制:对虚假注册、羊毛党账号加载黑名单,直接返回“未中奖”。
- 每日中奖封顶:单用户每天最多中N个实物奖品,防止集中刷奖。
实战问答:5个最常遇到的开发问题
Q1:抽奖概率总和不等于1怎么办?
A:预留“未中奖”项,把剩余概率归入该虚拟奖品,一等奖0.1+二等奖0.2+未中奖0.7=1,未中奖项库存默认为无限(remain置为-1)。
Q2:用户抽奖时请求超时导致扣了库存但没返回结果?
A:使用事务+状态机,MySQL中增加win_records表的create_time和expire_time字段,设置超时时间,PHP设置CURLOPT_TIMEOUT为较小值(如3秒),超时后查询记录是否存在,若存在则补偿返回结果;若不存在则回滚库存。
Q3:如何保证奖品的均匀分布而不是集中在某段时间?
A:加一层时间片随机偏移,在概率算法中引入当前时间戳的哈希值微调随机种子,mt_srand(crc32(date('YmdH') . $userId)),确保每个小时段的随机分布独立。
Q4:Redis宕机后库存数据恢复问题?
A:采用预热+落盘策略,系统启动时从MySQL读取奖品库存写入Redis,每次扣减库存时,异步写入MySQL(用消息队列),Redis宕机恢复后,重新从MySQL加载库存,并且根据win_records表中成功但状态为“未发货”的记录扣除库存差。
Q5:如果奖品被瞬间抢光,用户体验如何优化?
A:前端做好CDN缓存+本地事件预判。
- 使用SSE或WebSocket实时推送剩余库存变化,避免频繁轮询。
- 当剩余库存≤3时,前端自动隐藏“抽奖”按钮并提示“奖品即将告罄”。
- 后端对“无库存”请求直接返回预定义无奖提示,不执行数据库查询,减少压力。
实现一个高可用的PHP抽奖系统,核心并非代码有多复杂,而在于概率公平、库存准确、防刷有效,本文从业务逻辑、并发控制到风控安全,提供了完整的实战思路,建议开发者先在低并发环境中跑通核心流程,再逐步引入Redis和队列进行压测优化。抽奖系统最重要的不是让用户中奖,而是让用户觉得“公平”。