抽獎系統的核心需求是:按照預設概率分佈,高效、公平地選出中獎項目。經典的「概率區間法」時間複雜度 O(n),適合獎項較少(<20)的場景;大規模或高併發下可優化為「前綴和 + 二分查找」O(log n)。
/**
* 加權隨機選取
* @param array $probMap ['prize_id' => weight, ...] 權重為整數
* @return string|int 中獎鍵值
*/
function weightedRandom(array $probMap): mixed {
$totalWeight = array_sum($probMap);
$rand = mt_rand(1, $totalWeight);
foreach ($probMap as $key => $weight) {
$rand -= $weight;
if ($rand <= 0) {
return $key;
}
}
// 理論不可達,防御性返回
return array_key_first($probMap);
}
假設獎池:['A'=>10, 'B'=>30, 'C'=>60],總權重 100。
rand ∈ [1,100],假設 rand=45。45-10=35 > 0 繼續。35-30=5 > 0 繼續。5-60=-55 ≤ 0 → 中獎 C。數學期望完全符合權重比例。
class WeightedLottery {
private array $prefixSum = [];
private int $totalWeight = 0;
public function __construct(array $weightMap) {
foreach ($weightMap as $key => $weight) {
$this->totalWeight += $weight;
$this->prefixSum[$key] = $this->totalWeight;
}
}
public function draw(): mixed {
$rand = mt_rand(1, $this->totalWeight);
// 二分查找首個 prefixSum >= rand 的鍵
$keys = array_keys($this->prefixSum);
$low = 0; $high = count($keys) - 1;
while ($low < $high) {
$mid = ($low + $high) >> 1;
if ($this->prefixSum[$keys[$mid]] < $rand) {
$low = $mid + 1;
} else {
$high = $mid;
}
}
return $keys[$low];
}
}
class LotteryService {
private WeightedLottery $lottery;
private array $prizes = []; // ['id' => ['name','stock','weight',...]]
private string $logFile;
public function __construct(array $prizeConfig, string $logFile = 'lottery.log') {
$this->prizes = $prizeConfig;
$weightMap = [];
foreach ($prizeConfig as $id => $cfg) {
if (($cfg['stock'] ?? -1) > 0) { // 有庫存限制
$weightMap[$id] = $cfg['weight'];
}
}
$this->lottery = new WeightedLottery($weightMap);
$this->logFile = $logFile;
}
public function draw(string $userId): array {
// 1. 抽獎
$prizeId = $this->lottery->draw();
$prize = $this->prizes[$prizeId];
// 2. 扣減庫存(Redis 原子操作或文件鎖)
if (isset($prize['stock'])) {
$this->decreaseStock($prizeId);
if ($this->getStock($prizeId) <= 0) {
$this->lottery->remove($prizeId); // 動態調整權重
}
}
// 3. 記錄日誌
$this->log([
'time' => date('c'),
'user' => $userId,
'prize' => $prizeId,
'name' => $prize['name']
]);
return ['code' => 0, 'data' => $prize];
}
private function decreaseStock(string $id): void { /* Redis decr 或 flock+file */ }
private function getStock(string $id): int { /* 讀取剩餘庫存 */ }
private function log(array $data): void { /* file_put_contents JSON Lines */ }
}
$prizes = [
'grand' => ['name' => 'iPhone 15', 'weight' => 1, 'stock' => 5],
'first' => ['name' => 'AirPods Pro', 'weight' => 5, 'stock' => 20],
'second' => ['name' => '充電寶', 'weight' => 50, 'stock' => 200],
'third' => ['name' => '優惠券 10元', 'weight' => 200, 'stock' => 1000],
'consolation'=> ['name' => '再接再厲', 'weight' => 744, 'stock' => -1], // 無限
];
public class LotteryDraw {
private final TreeMap prefixMap = new TreeMap<>();
private long totalWeight = 0;
public LotteryDraw(Map weights) {
for (var e : weights.entrySet()) {
totalWeight += e.getValue();
prefixMap.put(totalWeight, e.getKey());
}
}
public String draw() {
long rand = ThreadLocalRandom.current().nextLong(1, totalWeight + 1);
return prefixMap.ceilingEntry(rand).getValue(); // O(log n)
}
}
| 議題 | 解決方案 |
|---|---|
| 高併發庫存扣減 | Redis Lua 腳本 / Redis SETNX 分佈式鎖 / 數據庫樂觀鎖 |
| 防刷/風控 | IP/用戶頻率限制、設備指紋、行為分析、驗證碼 |
| 概率動態調整 | 後台管理介面修改權重,熱更新 WeightedLottery 實例 |
| 大獎兜底/保底機制 | 記錄用戶抽獎次數,達閾值必中大獎(修改權重或單獨邏輯) |
| 可驗證公平性 | 種子哈希鏈、區塊鏈高度、第三方公證、前端可驗證算法 |
| 分布式一致性 | 中央化抽獎服務(gRPC/HTTP),避免各節點獨立隨機導致概率偏差 |
mt_rand(1,0) 報錯。mt_rand 已足夠,嚴格場景可用 random_int()(CSPRNG)。掌握「加權隨機」核心算法,結合庫存管理、並發控制、風控策略、可觀測日誌,即可構建生產級抽獎系統。小規模用 O(n) 區間法簡單可靠;大規模、高併發建議 O(log n) 前綴和法,並將抽獎邏輯封裝為獨立服務。