活动过程中抽奖系统设计

活动过程中抽奖系统设计

作者:admin |  时间:2015-06-25 |  浏览:324 |  0 条评论

目录

一 抽奖形式

二 撒点过程

三 大奖和小奖

四 抽奖过程

并发问题的解决

防作弊机制

 

 

 

一 抽奖形式

抽奖形式分两种:活动结束时抽奖和活动过程中抽奖。

活动结束时抽奖

如魅族在QQ空间进行的新品抽奖活动,在一百多万参与者中选出 100 名中奖者,下午5点公布结果。公司年会抽奖也属此类。特点是:参与人数确定,参与抽奖时间确定,奖品发放时间确定。一般采用随机概率抽奖方式,从N个元素中随机抽取k个,并且要尽量保证每个元素被抽中的概率都是k/N。最简单的办法就是将这N个元素存在一个数组中,随机打乱(保证每个元素出现在每个位置的概率都相同),然后选取前k个就行。如果N的值很大,可以考虑蓄水池抽样算法。

蓄水池抽样算法伪代码:

Init : a reservoir with the size: k
for i= k+1 to N
	M=random(1, i);
	if( M < k)
		 SWAP the Mth value and ith value
end for

活动过程中抽奖

公司各个产品线针对用户举行的抽奖活动。特点:活动期间参与人数不确定、用户参与抽奖时间不确定、奖品发放时间不确定。运营方希望能随时控制奖品发放时段,这样能提高抽奖的热度,如果一个奖品很快就发放出去了,后面来的人会失望。另外奖品不能少发,要让尽可能多的用户都能享受福利。

 

针对“活动过程中抽奖”的抽奖形式,常见的应对方式有以下几种:

1. 概率抽奖

根据奖品的过期日期来计算它当前时间的中奖率,当时间逐渐接近奖品过期时间时,中奖概率会逐渐发生变化,如果线性衰减或平方衰减等

优点:容易想到

难点:采用多大的概率,何种情况下采用何种概率

2. 逢“几”抽奖

""中奖,即通过预估抽奖人数和奖品数来判断,""=(抽奖人数/奖品数)*N。如微博根据转发次数来发奖的做法。

优点:简单,容易实现

难点:可能产生无人中奖和很多人中奖的情况

3. 基于时间撒点发放

选择奖品m,填写奖品数量n,选择要投放的时间段time1-time2,然后在time1-time2间随机均匀生成n个时间点,将n个时间点按照由先到后的顺序存在队列t中。每一个时间点代表一个奖品。

优点:只要抽奖用户数量大于奖品数量,肯定不会少发奖。可以随意控制撒点的密度。

难点:由于前期参与用户过少,撒点大量过期造成后来用户都有资格中奖形成“井喷”。需要好的随机算法保证撒点随机均匀。每个奖品被抽中的概率无法估算。

 

活动过程中抽奖,奖品数量固定,参与人数未知,无法控制奖品的消耗速度,概率过高很快发完,概率过低发不完。每个奖品被抽中的概率是一个很模糊的说法,总样本数量(抽奖次数)无法知道,实现概率需要满足的条件未知。其实用户不关心奖品被抽中的概率,只要在活动期间陆续有奖品被抽走即可,只要能控制好每个奖品的发放时间点和发放顺序,大奖不会太快被抽走,直到活动最后阶段都保证有奖,活动热度也能得到保证。基于时间撒点发放策略明显最优。至于“井喷”,在过期奖品被抽走之前设置一个随机概率,降低发放速度,就可以解决问题。

 

本文介绍的抽奖系统就是基于上面的思路设计的。

 

二 撒点过程

伪代码:

 

$senddate_arr = array();
//$startdate发放开始日期,$enddate发放结束日期
for($i=strtotime($startdate);$i<=strtotime($enddate);){
    $senddate_arr[] = date('Y-m-d', $i);
    $i += 86400;
}
$senddate_arr_count = count($senddate_arr);
$drawnum_day = array();
//$num是奖品数量,计算每天应发放的奖品数量
for($i = 1; $i <= $num; $i++) {
    $item = rand(0, $senddate_arr_count);
    $drawnum_day[$item]++;
}
//pond是奖池
$redis_prefix = $drawcheck == "1" ? "drawinfo_true{$pond}_" : "drawinfo_virtual{$pond}_";
$zKey = $drawcheck == "1" ? "truedrawtime{$pond}" : "virtualdrawtime{$pond}";
$zOtherKey = $drawcheck == "1" ?  "virtualdrawtime{$pond}" : "truedrawtime{$pond}";
//开始撒点
foreach($senddate_arr as $key => $tempdate){
    //note 循环发奖次数
    $drawnum_indeed = $drawnum_day[$key];
    $draw['drawdate'] = $tempdate;
    //$starttime_hour是发放开始时间,$endtime_hour是发放结束时间
    $timestart = strtotime("{$tempdate} {$starttime_hour}:00:00");
    $timeend = strtotime("{$tempdate} {$endtime_hour}:00:00");
    //发放开始时间和发放结束时间相同,说明是一个小时之内
    if($starttime_hour == $endtime_hour) {
       $timeend = strtotime("{$tempdate} {$endtime_hour}:59:59");
    }
    //撒点日期和活动开始日期比较
    if($tempdate == $hd_starttime_date) {
       //撒点日期等于活动开始日期,比较撒点开始时间、结束时间与活动开始时间、结束时间
       if($hd_starttime_hour >= $starttime_hour && $hd_starttime_hour <= $endtime_hour) {
          $timestart = strtotime("{$tempdate} {$hd_starttime_hour}:{$hd_starttime_min}:59");
       } else if($hd_starttime_hour > $endtime_hour) {
          continue;
       }
    }
    //撒点日期和活动结束时间比较
    if($tempdate == $hd_endtime_date) {
       //撒点日期等于活动结束日期
       if($hd_endtime_hour < $starttime_hour) {
          continue;
       } else if($hd_endtime_hour >= $starttime_hour && $hd_endtime_hour <= $endtime_hour) {
          $timeend = strtotime("{$tempdate} {$hd_endtime_hour}:00:00");
       }
    }
    if($timestart >= $timeend) {
       continue;
    }
    for($i=0; $i < $drawnum_indeed; $i++){
       ...
       $drawtime = getrandtime($timestart, $timeend, $zKey, $zOtherKey);
       ...
       //$draw奖品的信息,包括激活码等
       $redis->set($redis_prefix.$drawtime, $draw);
    }
}

function getrandtime($start, $end, $zKey, $zOtherKey){
    global $redis;
    $resultrand = '';
    $time = $start + lcg_value() * ($end - $start);
    $tvdrawtime = $redis->zScore($zKey, $time);
    $tvdrawtimeOther = $redis->zScore($zOtherKey, $time);
    if($tvdrawtime > 0 || $tvdrawtimeOther > 0){
        $resultrand = getrandtime($start, $end, $zKey, $zOtherKey);
    }else{
        $resultrand = $time;
        $redis->zAdd($zKey, $time, $time);
    } 
    return $resultrand;
}

 

1. 如果发放开始日期到结束日期大于一天,将奖品数量平均分到每一天。

2. 发放日期和活动开始结束日期同一天,需要算出发放开始结束时间和活动开始结束时间之间的交集,即撒点不能在活动开始时间之前,也不能在活动开始时间之后。

3. 每天最多86400秒,实际应用中,每天的奖品数量最大有200万,所以需要精确到0.0001秒。生成随机数发生器的最基本也是最普遍的理论即是线性同余法,也称作LCG法,主要以数论中的同余算法生成随机数。目前较为流行的组合发生器,即可利用两个或多个随机数发生器进行组合,从而生成随机性更好,周期更长的优质发生器。经过百万级别数据的测试,算法具有很好的随机性。

 

三 大奖和小奖

每个奖池有两个redis zset结构,一个是truedrawtime{$pond},一个是virtualdrawtime{$pond}。计算的随机时间点xxxxxx会被存在zset中,scorexxxxxx,所以所有的撒点会自动按照时间由小到大排序,读取速度也可以得到保证。

truedrawtime{$pond}中的奖品如果被用户抽中,用户的ip或者id会被记录,此用户不能再从truedrawtime中再次中奖,即不可重复抽队列virtualdrawtime{$pond}中的奖品没有这个限制,所以通常truedrawtime通常存放一些大奖,virtualdrawtime中存放小奖,可以被用户反复抽中,即可重复抽队列

撒点对应奖品具体的信息会被存储在redis key/string结构 drawinfo_true_xxxxxx或drawinfo_virtual_xxxxxx中。

 

如运营要求下载A游戏的用户,可以从p1,p2,p3三种奖品抽奖,下载B游戏的用户,可以从p4,p5,p6三种奖品中抽取,如果只有一个奖池,所有用户都会从这个奖池中抽奖,无法满足需求。truedrawtime{$pond}中的pond即是奖池编号,抽奖时只要传入编号,即可只从特定奖池中抽取。

 

 

四 抽奖过程

伪代码:

$rand = rand(1, 100);
if($rand <= $this->activeinfo['percent']){
    if($drawcheck === 0){
        $drawtimetype = "virtual";
        $draw = $this->dealdrawprocess($nowtime, $drawtimetype);
    } else {
        $drawtimetype = "true";
        $draw = $this->dealdrawprocess($nowtime, $drawtimetype);
        if($draw === 1) {
            $drawtimetype = "virtual";
            $draw = $this->dealdrawprocess($nowtime, $drawtimetype);
        }
    }
}

function dealdrawprocess($nowtime, $drawtimetype) {
    $draw = array();
    $pond = $this->pond;
    $starttime = strtotime($this->activeinfo['starttime']);
    $key_drawtimetypedrawtimepond = "{$drawtimetype}drawtime{$pond}";
    $key_drawtimetypedrawlockpond = "{$drawtimetype}drawlock{$pond}";
    $truedrawtime = $this->redis->zRange($key_drawtimetypedrawtimepond, 0, 0);
    if($truedrawtime[0] && $nowtime >= $truedrawtime[0]){
        if($this->redis->incr($key_drawtimetypedrawlockpond) == 1){
            $drawtime = $truedrawtime[0];
            $ret = $this->redis->zDelete($key_drawtimetypedrawtimepond, $drawtime);
            ...
            $key_drawinfo = "drawinfo_{$drawtimetype}{$pond}_{$drawtime}";
            $draw_info = $this->redis->get($key_drawinfo);
            $this->redis->delete($key_drawinfo);
            ...
            $this->redis->set($key_drawtimetypedrawlockpond, 0);
        }
        if($truedrawtime[0] < $starttime) {
            $draw = array();
        }
    }else if($drawtimetype == 'true'){
        return 1;
    }
    return $draw;
}

 

t1<t2,t1和t2是随机的两个时间点。如果用户在t1之前抽奖,不可能抽中;在t1和t2之间抽奖,如果满足到点抽奖概率,就可以抽中奖品1;过了t2抽奖,如果满足到点中奖概率,因为奖品1仍然在队列中,所以抽中的奖品仍然是奖品1,而不是奖品2,奖品1被抽走以后,如果满足条件,后来用户可抽中奖品2。如果运营调整了活动的开始时间,开始时间以前撒的时间点自动无效。运营可以随意调整发放密度和发放时间段,可以保证不会错发或者漏发。运营如果发现有大量过期撒点,减小到点抽奖概率就可以延缓发奖速度。

逻辑示意图:

 

五 并发问题的解决

1. 抽奖过程标红部分伪代码实际上回避了并发问题。一个奖池中zset队列同一时间只能有一个客户端操作。其它没有获得锁的客户端将返回未中奖。抽奖问题的特殊性使这种解决方案可以满足需求。

2. 如果不回避并发,问题转化为解决商品秒杀问题。因为redis是单进程运行,所以可以借助于redis lua脚本解决问题。

PHP伪代码:

$redis->evalSha($sha, array($drawtimetype, $pond, $nowtime), 0); //$sha = $redis->script("load", $script); 只用执行一次即可
lua伪代码:
-- true or virtual
local drawtimetype = ARGV[1];
-- "" or 1...12
local pond = ARGV[2];
local nowtime = ARGV[3];
if( drawtimetype == nil or pond == nil or nowtime == nil )
then
   return -1;
end
local key_drawtimetypedrawtimepond = drawtimetype.."drawtime"..pond;
local truedrawtime = redis.call('zrange', key_drawtimetypedrawtimepond, 0, 0);
local draw_info = nil;
if( table.maxn(truedrawtime) > 0 and nowtime > truedrawtime[1] )
then
    local drawtime = truedrawtime[1];
    local retval = redis.call("zrem", key_drawtimetypedrawtimepond, drawtime);
    if( retval ~= 1 )
    then  
        return -2;
    end
    local key_drawinfo = "drawinfo_"..drawtimetype..pond.."_"..drawtime;
    draw_info = redis.call('get', key_drawinfo);
    redis.call('del', key_drawinfo);
end
return draw_info;

可以看出,2方案明显优于1方案。PHP操作redis,时间大多浪费在redis协议解析组装,网络IO上。1方案有6次redis操作,2方案只有一次redis操作。redis lua脚本经过编译,虽然执行步数较多,但都是内存操作,执行速度很快。经测算,每秒可以支持2万并发。

 

六 防作弊机制

基本模式:

referrer域名限制,无refererua中需含有特定串

单个ipxx分钟内最多访问xxx次,否则ip进入黑名单,同时ip锁定一个小时。同一ip两次请求之间小于x秒,ip被记录,并返回不中奖。

缺点是如果恶意用户掌握大量ip地址,防刷机制被攻破。

密钥模式:

继承基本模式,抽奖请求添加token串验证。

如果客户端被反编译,密钥泄露,防刷机制会被攻破。

中间态模式:

继承基本模式,增加中奖中间态。用户中奖后,将中奖信息存入缓存,返回验证码或者短信,让用户验证。如果验证通过,从缓存中取出中奖信息返回给用户,确认用户中奖。如果在规定时间内用户没有验证通过,从缓存中删除中奖信息,奖品重回奖池。

登录模式:

继承基本模式,用户需要登陆用户中心后才能抽奖。

 

四种模式都能满足抽奖需求。可以看出安全级别逐渐递增,同时开发成本也在递增,需要综合需求和奖品价值确定使用模式。

相关推荐

发表评论

电子邮件地址不会被公开。

您可以使用这些HTML标签和属性: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>