需求:首先用戶通過以一定方式(好友點贊等)開啟抽獎資格,然后按照用戶 100% 中獎概率進行抽獎,且系統的發放獎品需要按照各個獎品整體的期望中獎比例來進行分布,最后用戶抽中獎品調用第三方發放接口發放獎品并記錄保存,另有些獎品存在發放數量限制。
問題分析
整個抽獎過程是同步進行,由于前置了開啟抽獎資格保護,會避免用戶集中進行抽獎,故系統并發量并不會太高。突出的問題主要有以下幾個:
1)由于同步調用第三方接口發放獎品,獎品可能發放失敗;
2)有一些獎品存在數量限制,可能已經發放完;
3)系統要求用戶 100% 抽中獎品;
4)系統要求各個獎品總的發放情況符合預期的比例分布;
解決方案
針對以上突出問題,給出針對的解決辦法。
- 問題1:采用帶有次數限制的重試機制,降低獎品發放接口發放失敗情況,同時捕獲異常來應對接口返回異常信息。重試機制失敗則自動重新進行一輪按概率抽獎,依次類推并做重發次數限制;
- 問題2:獎品數量在獎品發放端進行限制。因為系統存在數量限制的獎品期望發放比例較低,每輪抽中這些獎品概率也較低,所以可以采用若獎品已發放完,則自動重新進行一輪按概率抽獎,依次類推并做重發次數限制;
- 問題3:盡管有發放接口的重試機制和自動多輪按概率抽獎機制,也可能存在抽取獎品失敗的情況,這里采用一種特定獎品作為兜底的辦法,當然兜底獎品也有重試機制,使用戶抽中概率接近 100%;
- 問題4:因為重試機制失敗或者抽取到已經發送完畢的獎品時,會自動重新進行下一輪抽獎,由于規則也是按照概率抽獎,所以不影響各個獎品總的比例分布情況;
編碼
按概率抽獎
核心思想是采用隨機函數 mt_rand() 來模擬用戶抽獎。
獎品信息如下:
//所有獎品信息
$allPrizes = [
'jd' => ['name' => '京東券', 'probability' => 30],
'film' => ['name' => '電影票', 'probability' => 10],
'tb' => ['name' => '淘寶券', 'probability' => 60],
]
方式一
這是一個比較中規中矩的方式,主要思想 是:將所有獎品按照期望比例分布,一段一段小區間分布到 1~100 這個區間,然后隨機一個 1~100 的隨機數,如果這個隨機數落在某段區間,則表示抽取對應區間的獎品。
1 30 10 60
1|-----------|------|----------------------|100
京東券 電影票 淘寶券
代碼如下:
/**
* 按照概率抽取一個獎品, 返回獎品
* @param array $prizes 所有獎品的probability概率總和應該為100
* @return mixed
*/
private function randPrize(array $prizes)
{
//總概率基數
$totalProbability = array_sum(array_column(array_values($prizes), 'probability'));
if (100 !== $totalProbability) {
throw new Exception('invalid probability config');
}
$rand = mt_rand(1, 100);
$cursor = 0;
$id = '';
while(list($key, $item) = each($prizes)) {
if ($rand > $cursor && $rand <= $cursor + $item['probability']) {
$id = $key;
break;
}
$cursor += $item['probability'];
}
unset($prizes[$id]['probability']);
return $prizes[$id] + ['id' => $id];
}
方式二
該方式如果直接看代碼比較難理解。主要思想:按照給定順序(按照獎品配置順序),先后一個一個抽取獎品,直到抽中一個獎品為止, 抽中后續獎品的概率的前提是沒有抽中當前獎品,多次抽取概率應該相乘。
例如:
次數 獎品 概率 基數 中獎概率 未中獎概率
1 京東券 30 100 30/100 70/100
2 電影票 10 70 (70/100)*(10/70) (70/100)*(60/70)
3 淘寶券 60 60 (70/100)*(60/70)*(1) 1-(70/100)*(60/70)*(1)
/**
* 按照概率抽取一個獎品, 返回獎品,
* @param array $prizes 參與抽獎的獎品信息, 所有獎品的probability概率總和應該為100
* @return array
*/
private function randPrize(array $prizes)
{
//總概率基數
$totalProbability = array_sum(array_column(array_values($prizes), 'probability'));
if (100 !== $totalProbability) {
throw new Exception('invalid probability config');
}
//可以考慮按照概率倒序排序
/*uasort($prizes, function(array $a, array $b) {
if ($a['probability'] == $b['probability']) return 0;
return $a['probability'] > $b['probability'] ? -1 : 1;
});*/
//按照獎品順序依次模擬抽中獎品
$id = '';
foreach ($prizes as $key => $item) {
$rand = mt_rand(1, $totalProbability); //本次抽獎的基數
if ($rand <= $item['probability']) { //表示抽中
$id = $key;
break;
} else {
$totalProbability -= $item['probability']; //后續獎品基數減去抽過的概率, 因為抽中后一個獎品的前提是抽不中前一些獎品
}
}
unset($prizes[$id]['probability']);
return $prizes[$id] + ['id' => $id];
}
抽中獎品
主要包含重試機制、自動重新一輪按照概率抽獎機制、兜底機制的實現。
/**
* 抽獎
* @param array $allPrizes
* @return mixed
*/
public function draw($allPrizes)
{
$tryTimes = 0;
$outPrize = [];
$prize = [];
//如果抽到有數量限制獎品且獎品也已經抽完或者抽取失敗, 最多抽獎次數
while ($tryTimes < 4) {
$tryTimes++;
//按照概率抽取
$prize = $this->randPrize($allPrizes);
//模擬發放獎品方法
$outPrize = $this->getOnePrize($prize['id']);
//抽中退出
if (!empty($outPrize)) {
break;
}
}
echo '嘗試按照概率抽取次數:' , $tryTimes, PHP_EOL;
//多次抽獎都抽中已經抽完的獎品, 則用兜底獎品兜底
$tryTimes = 0;
while (!$outPrize && $tryTimes < 2) {
$tryTimes++;
$prize = $allPrizes['default'] + ['id' => 'default'];
$outPrize = $this->getOnePrize('default');
}
echo '兜底抽取次數:' , $tryTimes, PHP_EOL;
if (!$outPrize) {
//兜底失敗, 可能是券達到上限, 或者接口down了
return false;
} else {
//合并獎品信息
$outPrize = $outPrize + $prize;
}
return $outPrize;
}
驗證
概率分布
抽樣方法
public function sample($all, $times)
{
$out = [];
$count = $times;
if ($times > 1000000) return;
while ($times) {
$times--;
$prize = $this->draw($all);
if (!isset($out[$prize['id']])) {
$out[$prize['id']] = 0;
}
$out[$prize['id']]++;
}
array_walk($out, function(&$value, $key) use ($count) {
$value = ($value / $count * 100);
});
ksort($out);
return $out;
}
抽樣結果
//期望概率
array(3) {
["film"] => int(10)
["jd"] => int(30)
["tb"] => int(60)
}
//抽樣2000次
array(3) {
["film"] => string(4) "9.8"
["jd"] => string(6) "31.35"
["tb"] => string(6) "58.85"
}
異常處理機制
嘗試按照概率抽取次數: 3
兜底抽取次數: 0
抽中獎品為:array(3) {
["name"] => string(20) "淘寶50元消費券"
["content"] => string(12) "WD84-3233-21"
["id"] => string(2) "tb"
}