抽獎系統是營銷活動核心功能,需滿足:概率精確可控、獎品庫存扣減原子性、用戶防重複、高併發效能、資料一致性。本文提供從單機到分散式的演進方案,含完整代碼、資料庫設計、Redis Lua 腳本、測試驗證方法。
| 需求 | 技術方案 | 關鍵點 |
|---|---|---|
| 概率控制 | 權重隨機算法 (區間法/輪盤法) | 權重和=10000,整數運算無浮點誤差 |
| 獎品庫存 | 資料庫行鎖 / Redis 原子遞減 | 防止超賣,扣減失敗即未中獎 |
| 用戶限制 | 每日/總次數限制 + Redis 計數器 | 鍵設計:lottery:user:{uid}:count:{date} |
| 高併發 | Redis 預扣庫存 + 異步落庫 / 消息隊列 | 削峰填谷,DB 不直接承擔寫入洪峰 |
| 防作弊 | IP/設備指紋/行為分析 + 風控規則引擎 | 非核心算法,獨立模組 |
<?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,
];
});
}
}
?>
<?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 + 異步落庫);並建立完整的壓測、監控、審計體系。