首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> 網頁程式設計 >> PHP 程式設計 >> PHP 相關知識

PHP 抽獎算法實作:概率控制、獎品庫存、防重複、高併發支援完整方案

     編輯:PHP 相關知識

PHP 抽獎算法實作:概率控制、獎品庫存、防重複、高併發支援完整方案

抽獎系統是營銷活動核心功能,需滿足:概率精確可控、獎品庫存扣減原子性、用戶防重複、高併發效能、資料一致性。本文提供從單機到分散式的演進方案,含完整代碼、資料庫設計、Redis Lua 腳本、測試驗證方法。

核心需求建模

需求技術方案關鍵點
概率控制權重隨機算法 (區間法/輪盤法)權重和=10000,整數運算無浮點誤差
獎品庫存資料庫行鎖 / Redis 原子遞減防止超賣,扣減失敗即未中獎
用戶限制每日/總次數限制 + Redis 計數器鍵設計:lottery:user:{uid}:count:{date}
高併發Redis 預扣庫存 + 異步落庫 / 消息隊列削峰填谷,DB 不直接承擔寫入洪峰
防作弊IP/設備指紋/行為分析 + 風控規則引擎非核心算法,獨立模組

方案一:單機版 (中小流量,ThinkPHP/Laravel 直接使用)

<?php
// LotteryService.php
class LotteryService
{
    private array $prizes = [
        ['id' => 1, 'name' => '特等獎', 'weight' => 10, 'stock' => 1],
        ['id' => 2, 'name' => '一等獎', 'weight' => 50, 'stock' => 5],
        ['id' => 3, 'name' => '二等獎', 'weight' => 200, 'stock' => 20],
        ['id' => 4, 'name' => '三等獎', 'weight' => 1000, 'stock' => 100],
        ['id' => 5, 'name' => '參與獎', 'weight' => 8740, 'stock' => 99999], // 保底
    ];
    
    public function draw(int $userId): array
    {
        // 1. 用戶頻率限制 (每日 3 次)
        $todayKey = "lottery:user:{$userId}:count:" . date('Ymd');
        $dailyCount = Redis::incr($todayKey);
        if ($dailyCount === 1) Redis::expire($todayKey, 86400);
        if ($dailyCount > 3) {
            return ['code' => 400, 'msg' => '今日抽獎次數已用完'];
        }
        
        // 2. 總次數限制 (可選)
        $totalKey = "lottery:user:{$userId}:total";
        if (Redis::incr($totalKey) > 10) {
            return ['code' => 400, 'msg' => '總抽獎次數已達上限'];
        }
        
        // 3. 執行抽獎 (事務保證一致性)
        return DB::transaction(function() use ($userId) {
            // 鎖定獎品表 (防併發超賣)
            $prizes = Prize::lockForUpdate()->get();
            
            // 計算有效獎品 (庫存>0)
            $validPrizes = $prizes->where('stock', '>', 0)->values();
            if ($validPrizes->isEmpty()) {
                return ['code' => 200, 'prize_id' => 0, 'name' => '獎品已抽完', 'is_win' => false];
            }
            
            // 權重隨機
            $totalWeight = $validPrizes->sum('weight');
            $rand = mt_rand(1, $totalWeight);
            
            $accumulated = 0;
            $wonPrize = null;
            foreach ($validPrizes as $prize) {
                $accumulated += $prize->weight;
                if ($rand <= $accumulated) {
                    $wonPrize = $prize;
                    break;
                }
            }
            
            // 扣減庫存
            if ($wonPrize->id !== 5) { // 參與獎不扣庫存
                $wonPrize->decrement('stock');
            }
            
            // 記錄中獎
            LotteryRecord::create([
                'user_id' => $userId,
                'prize_id' => $wonPrize->id,
                'prize_name' => $wonPrize->name,
                'is_win' => $wonPrize->id !== 5,
                'created_at' => now(),
            ]);
            
            return [
                'code' => 200,
                'prize_id' => $wonPrize->id,
                'name' => $wonPrize->name,
                'is_win' => $wonPrize->id !== 5,
            ];
        });
    }
}
?>

方案二:高併發版 (Redis Lua 腳本原子操作,萬級 QPS)

<?php
// Lua 腳本:原子檢查庫存 + 扣減 + 返回結果
// KEYS[1] = prize_stock:{prize_id}
// ARGV[1] = user_id
// ARGV[2] = prize_id
// ARGV[3] = max_per_user (每用戶限制)
local function draw_prize()
    local stock_key = KEYS[1]
    local user_id = ARGV[1]
    local prize_id = ARGV[2]
    local max_per_user = tonumber(ARGV[3])
    
    -- 用戶限制檢查
    local user_key = "lottery:user:" .. user_id .. ":prize:" .. prize_id
    local user_count = tonumber(redis.call('GET', user_key) or '0')
    if user_count >= max_per_user then
        return {0, 'user_limit'}
    end
    
    -- 庫存檢查與扣減
    local stock = tonumber(redis.call('GET', stock_key) or '0')
    if stock <= 0 then
        return {0, 'out_of_stock'}
    end
    
    redis.call('DECR', stock_key)
    redis.call('INCR', user_key)
    redis.call('EXPIRE', user_key, 86400)
    
    return {1, 'success', stock - 1}
end

return draw_prize()
?>

PHP 呼叫:

<?php
class LotteryRedisService
{
    private $luaScript;
    
    public function __construct() {
        $this->luaScript = file_get_contents(__DIR__ . '/lua/draw_prize.lua');
    }
    
    public function draw(int $userId, int $prizeId, int $maxPerUser = 1): array
    {
        $stockKey = "prize_stock:{$prizeId}";
        
        $result = Redis::eval($this->luaScript, 1, $stockKey, $userId, $prizeId, $maxPerUser);
        
        if ($result[0] == 1) {
            // 中獎:異步寫入資料庫 (推送隊列)
            $this->queueWinRecord($userId, $prizeId);
            return ['code' => 200, 'won' => true, 'remaining' => $result[2]];
        } else {
            return ['code' => 200, 'won' => false, 'reason' => $result[1]];
        }
    }
    
    private function queueWinRecord(int $userId, int $prizeId): void {
        // 推送到 Redis List / RabbitMQ / Kafka
        $payload = json_encode([
            'user_id' => $userId,
            'prize_id' => $prizeId,
            'timestamp' => microtime(true),
        ]);
        Redis::lpush('queue:lottery:wins', $payload);
    }
}

// 消費者進程 (獨立 Worker)
class LotteryWinConsumer
{
    public function handle(): void {
        while (true) {
            $payload = Redis::rpop('queue:lottery:wins');
            if (!$payload) {
                usleep(100000); // 100ms
                continue;
            }
            
            $data = json_decode($payload, true);
            DB::transaction(function() use ($data) {
                LotteryRecord::create($data);
                Prize::where('id', $data['prize_id'])->decrement('stock');
            });
        }
    }
}
?>

權重隨機算法深度解析

<?php
/**
 * 區間法 (Wheel/Range-based) - O(n) 但直觀、精確
 * 適用:獎品數 < 100,權重總和 < PHP_INT_MAX
 */
function weightedRandom(array $items, string $weightKey = 'weight'): ?array
{
    $total = array_sum(array_column($items, $weightKey));
    $rand = mt_rand(1, $total);
    
    $accum = 0;
    foreach ($items as $item) {
        $accum += $item[$weightKey];
        if ($rand <= $accum) return $item;
    }
    return null; // 理論不會到達
}

/**
 * 別名法 - O(1) 取樣,O(n) 預處理
 * 適用:超大獎池、極高頻取樣、權重動態變化少
 */
class AliasMethod
{
    private array $prob = [];
    private array $alias = [];
    private int $n = 0;
    
    public function build(array $weights): void {
        $this->n = count($weights);
        $avg = array_sum($weights) / $this->n;
        
        $small = [];
        $large = [];
        foreach ($weights as $i => $w) {
            ($w < $avg ? $small : $large)[] = $i;
        }
        
        $this->prob = array_fill(0, $this->n, 0);
        $this->alias = array_fill(0, $this->n, 0);
        
        while ($small && $large) {
            $s = array_pop($small);
            $l = array_pop($large);
            $this->prob[$s] = $weights[$s] / $avg;
            $this->alias[$s] = $l;
            $weights[$l] = $weights[$l] + $weights[$s] - $avg;
            if ($weights[$l] < $avg) $small[] = $l;
            else $large[] = $l;
        }
        while ($large) $this->prob[array_pop($large)] = 1;
        while ($small) $this->prob[array_pop($small)] = 1;
    }
    
    public function next(): int {
        $i = mt_rand(0, $this->n - 1);
        return (mt_rand() / mt_getrandmax()) < $this->prob[$i] ? $i : $this->alias[$i];
    }
}
?>

資料庫設計 (核心表)

CREATE TABLE `lottery_prizes` (
  `id` int UNSIGNED NOT NULL AUTO_INCREMENT,
  `name` varchar(50) NOT NULL,
  `weight` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '權重',
  `stock` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '剩餘庫存',
  `total_stock` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '總庫存',
  `max_per_user` int UNSIGNED NOT NULL DEFAULT 1 COMMENT '每用戶限量',
  `sort` int NOT NULL DEFAULT 0,
  `status` tinyint NOT NULL DEFAULT 1,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB;

CREATE TABLE `lottery_records` (
  `id` bigint UNSIGNED NOT NULL AUTO_INCREMENT,
  `user_id` int UNSIGNED NOT NULL,
  `prize_id` int UNSIGNED NOT NULL,
  `prize_name` varchar(50) NOT NULL,
  `is_win` tinyint NOT NULL DEFAULT 0,
  `ip` varchar(45) DEFAULT NULL,
  `device_fingerprint` varchar(64) DEFAULT NULL,
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_user_prize` (`user_id`, `prize_id`),
  KEY `idx_created` (`created_at`)
) ENGINE=InnoDB;

-- Redis 庫存預熱鍵
-- prize_stock:1 = 1
-- prize_stock:2 = 5
-- prize_stock:3 = 20

測試驗證方法

<?php
// 壓力測試腳本 (藉由 Swoole/Workerman 或 ab/wrk)
function stressTest(int $concurrency = 100, int $requests = 10000): void {
    $start = microtime(true);
    $results = ['win' => 0, 'lose' => 0, 'error' => 0, 'latency' => []];
    
    $pool = new \Swoole\Coroutine\Channel($concurrency);
    for ($i = 0; $i < $concurrency; $i++) {
        go(function() use ($pool, $requests, $concurrency, &$results) {
            $client = new \Swoole\Coroutine\Http\Client('localhost', 9501);
            $perCoroutine = (int)($requests / $concurrency);
            for ($j = 0; $j < $perCoroutine; $j++) {
                $t1 = microtime(true);
                $client->post('/api/lottery/draw', ['user_id' => rand(1, 10000)]);
                $t2 = microtime(true);
                $results['latency'][] = ($t2 - $t1) * 1000;
                $body = json_decode($client->body, true);
                if ($body['code'] === 200) {
                    $body['won'] ? $results['win']++ : $results['lose']++;
                } else {
                    $results['error']++;
                }
            }
            $pool->push(true);
        });
    }
    
    for ($i = 0; $i < $concurrency; $i++) $pool->pop();
    
    $latency = $results['latency'];
    sort($latency);
    echo "總耗時: " . round(microtime(true) - $start, 2) . "s\n";
    echo "中獎: {$results['win']}, 未中: {$results['lose']}, 錯誤: {$results['error']}\n";
    echo "P50: {$latency[count($latency)*0.5]}ms, P99: {$latency[count($latency)*0.99]}ms\n";
    
    // 驗證概率分布
    // 統計各獎品中獎次數,與理論權重對比 (卡方檢驗)
}
?>

常見坑點與解決

坑點後果解決
權重總和溢位mt_rand 參數錯誤、分布失真用 64 位整數、權重歸一化、或別名法
庫存扣減競態超賣 (賣出超過庫存)DB 行鎖 / Redis Lua 原子 / 單隊列消費
用戶重複請求超過限制次數仍中獎Redis 計數器原子增 + 過期時間
中獎記錄丟失用戶投訴、資料不一致異步落庫 + 重試機制 + 死信隊列
機器人刷獎真實用戶中獎率極低風控模組:IP/設備/行為/驗證碼

擴充:保底機制 / 兜底獎品

// 確保每人必中某獎 (如參與獎)
function drawWithGuarantee(array $prizes, int $userId): array {
    // 先嘗試抽實物獎
    $realPrizes = array_filter($prizes, fn($p) => $p['id'] !== 'consolation');
    $result = weightedRandom($realPrizes);
    
    if ($result) return $result;
    
    // 實物獎抽完/沒中 -> 返回保底獎
    return ['id' => 'consolation', 'name' => '參與獎', 'is_win' => false];
}
?>

抽獎系統的核心難點不在「隨機」,而在於併發下的庫存一致性與概率分布的統計正確性。建議根據流量規模選擇:日均 < 1 萬請求用方案一 (DB 事務);> 1 萬請求必上方案二 (Redis Lua + 異步落庫);並建立完整的壓測、監控、審計體系。

  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved