PHP项目怎么实现抽奖系统?

wen java案例 2

PHP项目抽奖系统实现全攻略:从核心逻辑到高并发优化

目录导读

  1. 抽奖系统的基本原理与常见玩法
  2. 数据库设计与概率分配算法
  3. 核心业务逻辑实现(含代码示例)
  4. 高并发场景下的防超抽与一致性保障
  5. 安全机制:防刷与风控策略
  6. 实战问答:5个最常遇到的开发问题

抽奖系统的基本原理与常见玩法

在PHP项目中,抽奖系统的核心是概率控制与资源锁定,常见的玩法包括:幸运转盘、随机抽卡、积分抽奖、定时抢券等,无论形式如何,底层逻辑都是:用户发起请求→服务端校验资格→执行概率算法→扣除库存或积分→返回结果

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)

推荐方案(中大型项目)

  1. 用Redis的List或SortedSet缓存奖品库存与权重。
  2. 使用Lua脚本实现“查库存→减库存→写记录”原子操作。
  3. 异步任务将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 基础防刷手段

  1. IP+设备指纹限流:同一IP每小时抽奖上限,使用Redis计数器。
  2. 用户行为模式检测:连续N次抽奖间隔<1秒,判定为脚本攻击,临时封禁。
  3. 请求签名校验:前后端约定HMAC算法,防止伪造请求。

2 高级风控规则

  • 概率实时动态调整:当某个奖品库存低于阈值时,自动降低其概率。
  • 黑名单机制:对虚假注册、羊毛党账号加载黑名单,直接返回“未中奖”。
  • 每日中奖封顶:单用户每天最多中N个实物奖品,防止集中刷奖。

实战问答:5个最常遇到的开发问题

Q1:抽奖概率总和不等于1怎么办?

A:预留“未中奖”项,把剩余概率归入该虚拟奖品,一等奖0.1+二等奖0.2+未中奖0.7=1,未中奖项库存默认为无限(remain置为-1)。

Q2:用户抽奖时请求超时导致扣了库存但没返回结果?

A:使用事务+状态机,MySQL中增加win_records表的create_timeexpire_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和队列进行压测优化。抽奖系统最重要的不是让用户中奖,而是让用户觉得“公平”

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