首发于 樊浩柏科学院
需求:首先用户经过以必定方式(好友点赞等)开启抽奖资格,而后按照用户 100% 中奖几率进行抽奖,且系统的发放奖品须要按照各个奖品总体的指望中奖比例来进行分布,最后用户抽中奖品调用第三方发放接口发放奖品并记录保存,另有些奖品存在发放数量限制。html
整个抽奖过程是同步进行,因为前置了开启抽奖资格保护,会避免用户集中进行抽奖,故系统并发量并不会过高。突出的问题主要有如下几个:并发
1)因为同步调用第三方接口发放奖品,奖品可能发放失败;
2)有一些奖品存在数量限制,可能已经发放完;
3)系统要求用户 100% 抽中奖品;
4)系统要求各个奖品总的发放状况符合预期的比例分布;函数
针对以上突出问题,给出针对的解决办法。this
核心思想是采用随机函数 mt_rand() 来模拟用户抽奖。编码
奖品信息以下:code
//全部奖品信息 $allPrizes = [ 'jd' => ['name' => '京东券', 'probability' => 30], 'film' => ['name' => '电影票', 'probability' => 10], 'tb' => ['name' => '淘宝券', 'probability' => 60], ]
方式一 htm
这是一个比较中规中矩的方式,主要思想 是:将全部奖品按照指望比例分布,一段一段小区间分布到 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]; }
方式二get
该方式若是直接看代码比较难理解。主要思想:按照给定顺序(按照奖品配置顺序),前后一个一个抽取奖品,直到抽中一个奖品为止, 抽中后续奖品的几率的前提是没有抽中当前奖品,屡次抽取几率应该相乘。
例如:
次数 奖品 几率 基数 中奖几率 未中奖几率 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" }