如何实现抽奖功能

  • 场景
  • 思路

  • 可配置化
  • 部分细节
  • 部门源码


场景

前段时间做了个抽奖功能 ,因为几场活动的业务场景不同,实现逻辑也稍有不同,这是我遇到的几种场景(这里的活动奖品不只一种,支持同时抽奖):
1.有些抽奖活动,是奖品数量有限,先抽先得 (奖品没抽完之前,100%中奖)
2.奖品数量无限,每个奖品的中奖率各有不同
3.奖品数量有限,中奖概率各不相同,支持安慰奖 (其实没中奖,也可以看成一种安慰奖,兜底用的)

思路

在抽奖的时候,通常有2个要考虑的点,奖品数量和概率;先按给定的概率进行选中奖品,当抽中奖品时需要考虑剩余数量(如果无限数量则不需考虑),如果剩余数量已经为0,则给安慰奖(或者未中奖)。
在抽奖时要特别注意的是,当奖品数量为0时,不能再抽中该奖品了,特别是在高并发的情况下。

用户点击抽奖时,实际上能同时点击的并发量不会很大,所以可以用 信号量控制下同时点击抽奖的人数(不会对性能造成很大影响);至于有些要实时查询统计的东西,比如剩余数量,已抽中人数等,可以放入缓存中,避免重复查库;而当奖品的总数量明显不足时(比如小于10个),产生抽中奖品却库存不足(超卖问题)的几率会大大增加,这时其实可以使用悲观锁,以此保证不会出现并发问题。

可配置化

当时在开发功能时 ,有10多场活动要抽奖,奖品以及中奖概率都不相同 ,所以当时就将几种场景抽离出来,实际的活动直接往场景上套,调用封装好的接口即可。至于不同活动的奖品及概率配置,就放进数据库中,有新的活动,只需要简单的配置下即可。

CREATE TABLE `t_draw_prize` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) DEFAULT NULL COMMENT '奖品名称',
  `num` int(11) DEFAULT NULL COMMENT '奖品数量',
  `draw_activity_id` int(11) DEFAULT NULL COMMENT '抽奖活动id',
  `is_del` int(3) DEFAULT '0' COMMENT '是否删除',
  `probability` int(11) DEFAULT NULL COMMENT '概率占比 (千分制 ,去除 千分号)',
  `is_consolation` int(3) DEFAULT NULL COMMENT '是否安慰奖(0否1是  ,这个是未抽中奖品时的默认奖品,也可视作未中奖 )',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=25 DEFAULT CHARSET=utf8mb4 COMMENT='抽奖奖品信息';

上面是奖品配置的表结构 ,根据不同的活动,就配置不同奖品的数量及概率。如果奖品数量无限,可配置为-1,对应的业务代码做好判断即可。
这里的概率,其实是千分制概率占比,真正抽奖时也是利用随机数落在对应的中奖区间来选中奖品,下一部分会说明。

部分细节

这里,针对上面列举的几种场景来说下我的实现细节。
第一种场景下,因为先到先得,只要总数量还在,就100%中奖,这种情况下,奖品对应的剩余数量就可以当做是概率占比。抽奖时,根据总剩余数量来生成随机数,查看这个随机数落在哪个奖品的区间,则抽中该奖品。
以下是第一种场景的部分实现:

/**
     * 执行 选择奖品  (有限奖品 ,抽完为止)
     *
     * 思路 就是 : 在剩有奖品中 ,以数量 排成 一列数组 ,以总剩余数量 作为界限取随机数 ,落在谁那里 ,就中该奖品
     * @Param userId (抽奖人)
     * @Param activityId(抽奖活动)
     * @return
     */
    protected DrawPrize chooseDrawPizeLimited(Integer userId,Integer activityId){
        deduceTotalLeftNum(activityId);// 先锁定奖品 ,总奖品数减一
        try{
        	//查询活动的奖品(可走缓存)
            Map<Integer,DrawPrize> drawPrizeMap = queryPrizeMap(activityId);
            /*
             * 抽完校验下 ,抽中的这个是否还剩余 ,
             * 防止 同时抽中一项奖品的时候 ,奖品就剩下一个 ,出现超卖  (这样的话,再重抽一次 ,乐观锁思想)
             */
            Integer choosePrizeId =  null;
            for(int retry = 0 ; retry <3 ;retry++){//1次正常执行 ,最多包含2次重试
                choosePrizeId = choosePrizeIdLimited(drawPrizeMap,activityId); // 执行随机算法选择 奖品
                if(statLeftNumByPrize(drawPrizeMap.get(choosePrizeId),activityId) <= 0 ){//说明在抽奖算法执行时,已经有人将抽中的那个奖给抽没了 ,再重新抽
                    choosePrizeId = choosePrizeIdLimited(drawPrizeMap,activityId);
                    if(retry>0){
                        logger.info("---------进行了重试-********************-------------");
                    }
                }else {
                    break;
                }
            }
            DrawPrize choosedPrize = drawPrizeMap.get(choosePrizeId);
            saveDrawWinInfoWithCache(userId,activityId,choosedPrize);//往缓存里加中奖信息
            deducePrizeLeftNumById(choosedPrize,activityId);// 对应奖品数 减1
            return choosedPrize;
        }catch (Exception e){
            logger.error("执行抽奖算法时出错,要回退总剩余量",e);
            resumeTotalLeftNum();//恢复刚才缓存中预先减少的数量
            return null;
        }
    }

	/**
     * 利用随机数 来选择 抽中的奖品  (有限奖品数 ,区间是以 奖品数量做基数的)
     * @Param drawPrizeMap  商品列表 ,传参进来,省的再查一次
     * @return
     */
    private Integer choosePrizeIdLimited(Map<Integer,DrawPrize> drawPrizeMap,Integer activityId){
        List<Integer> avalidPrizeIds = new ArrayList<>(); // 还没抽完的奖品类型
        //查询还有剩余数量的奖品
        Map<Integer,Integer> prizeLeftNumMap = new HashMap<>();
        Integer totalLeftNum = 0;
        for(Integer prizeId :drawPrizeMap.keySet()){
            Integer prizeLeftNum = statLeftNumByPrize(drawPrizeMap.get(prizeId),activityId);
            if(prizeLeftNum >0){
                avalidPrizeIds.add(prizeId);
                totalLeftNum += prizeLeftNum;
                prizeLeftNumMap.put(prizeId,prizeLeftNum);
            }
        }

        Integer choosePrizeId = null;
        if(avalidPrizeIds.size() == 1){// 只剩一种奖品 (不用再走随机了)
            return avalidPrizeIds.get(0);
        }
        Random random = new Random(); //
        Integer randNum = random.nextInt(totalLeftNum); //[0 ,totalLeftNum)  左闭右开
        Integer cursor = 0 ;
        for(int i= 0;i< avalidPrizeIds.size();i++){
            if(randNum < cursor+ prizeLeftNumMap.get(avalidPrizeIds.get(i))){
                choosePrizeId = avalidPrizeIds.get(i);
                break;
            }
            cursor += prizeLeftNumMap.get(avalidPrizeIds.get(i));
        }
        return choosePrizeId; //一定是有值的
    }

针对第二种和第三种场景 ,其实大同小异,无非是剩余数量会不会为0,当抽中奖品为0时,直接安慰奖(所以安慰奖可以提前查出来),这里的中奖概率就是数据库配置的中奖概率(千分制);这里说明下 ,如果业务中有一部分概率是要未中奖的,其实就将其配置为安慰奖就行,只是安慰了个寂寞 。
下面是第二、三种场景的部分实现细节 :

/**
     * 按概率抽奖 ,抽不中的 ,直接安慰奖 ,可以一直抽
     *
     * (先按概率算 落在哪里 ,不包括安慰奖,  占据奖品位置 ,入缓存 ,如果库存为0 ,直接替换成安慰奖 )
     *
     * @param userId 抽奖人
     * @param activityId 抽奖活动
     * @return
     */
    protected DrawPrize chooseDrawPizeWithoutLimit(Integer userId, Integer activityId) {
        DrawPrize consolationAward = queryConsolationAward(activityId);//兜底的安慰奖
        try{
            Map<Integer,DrawPrize> drawPrizeMap = queryPrizeMap(activityId);//所有的礼品 (不包括安慰奖)
            Integer choosePrizeId = choosePrizeIdWithOutLimit(drawPrizeMap);
            if(choosePrizeId == null){//没抽中 ,直接安慰奖
                saveDrawWinInfoWithCache(userId,activityId,consolationAward);//放入 已中奖缓存 ,这里不用扣减库存
                return consolationAward;
            }
            if(statLeftNumByPrize(drawPrizeMap.get(choosePrizeId),activityId) <= 0 ){//抽中的奖品,已经没有库存了,安慰奖伺候
                logger.info("抽中的奖品,已经没有库存了,安慰奖伺候 ,prizeId : " + choosePrizeId);
                saveDrawWinInfoWithCache(userId,activityId,consolationAward);
                return consolationAward;
            }
            DrawPrize choosedPrize = drawPrizeMap.get(choosePrizeId);
            saveDrawWinInfoWithCache(userId,activityId,choosedPrize);//往缓存里加中奖信息
            deducePrizeLeftNumById(choosedPrize,activityId);// 对应奖品数 减1
            return choosedPrize;
        }catch (Exception exception){
            logger.error("抽奖失败,给默认安慰奖",exception);
            saveDrawWinInfoWithCache(userId,activityId,consolationAward);//放入 已中奖缓存 ,这里不用扣减库存
            return consolationAward;
        }
    }

/**
     * 利用随机数 来选择 抽中的奖品  (不限奖品数,按概率来 ,区间是以概率占比作为基数的,千分制 ,也就是随机数区间 【0,1000) ,左闭右开 )
     *
     * 这里可能是存在不中奖概率的 ,也就是所有奖品的基数区间加起来 不一定等于1000  , 落在区间之外的 ,都是安慰奖
     *
     * @Param drawPrizeMap  商品列表 ,传参进来,省的再查一次
     * @return
     */
    private Integer choosePrizeIdWithOutLimit(Map<Integer,DrawPrize> drawPrizeMap){
        List<Integer> avalidPrizeIds = new ArrayList<>(); // 还没抽完的奖品类型
        Integer totalLeftNum = 0;
        Integer choosePrizeId = null;
        Random random = new Random(); //
        Integer randNum = random.nextInt(1000); //[0 ,totalLeftNum) 千分制, 左闭右开
        Integer cursor = 0 ;
        for(Integer prizeId : drawPrizeMap.keySet()){
            if(randNum <= cursor + drawPrizeMap.get(prizeId).getProbability()){
                choosePrizeId = prizeId;
                break;
            }
            cursor += drawPrizeMap.get(prizeId).getProbability();
        }
        return choosePrizeId; //可能为空
    }

部门源码

上一部分讲了具体场景下判断中奖的实现细节,当有新的抽奖活动时,你只需要在数据库配置好对应的奖品信息(数量及概率等),然后选择抽奖实现即可(区别就在于总奖品数是否无限)。因为当时开发时,涉及到公司的一些业务,包括抽奖资格等等,所以不太好抛出全部源码,这里将几块关键代码贴一下吧(至于抽奖资格及保存中奖信息入库等逻辑就不贴了),如果考虑性能问题,可以合理的利用缓存:

public abstract class DrawExecuter implements SelfDrawHandleService{

    private Logger logger = LoggerFactory.getLogger(DrawExecuter.class);

    //信号量 ,考虑 大量用户涌入 , (每台服务器)每次支持 30人 同时抽奖
    private Semaphore semaphore = new Semaphore(30);

    //悲观锁 ,当 总奖品数量 小于 50 时 ,加锁处理 ,防止超卖
    private ReentrantLock totalLeftNumLock = new ReentrantLock();
    
    /**
     * 统计某次抽奖活动 所有奖品总剩余数量
     * @return
     */
    public Integer statTotalLeftNum(Integer activityId){
        //略
    }

    /**
     * 统计某一奖品的剩余数量
     * @param prizeId
     * @return
     */
    public Integer statLeftNumByPrize(DrawPrize drawPrize,Integer activityId){
        //略
    }


  	//执行抽奖逻辑
    public DrawWinVo doExecDraw(Integer userId,Integer activityId) throws InterruptedException {
        DrawWinVo drawWinVo = new DrawWinVo();
        drawWinVo.setUserId(userId);
        DrawPrize drawPrize = null;
        // 信号量控制
        semaphore.acquire();
        try{
            Integer totalLeftNum =  statTotalLeftNum(activityId);
            //执行抽奖逻辑 (小于50 悲观锁 )
            if(totalLeftNum < 50){// 当数量 小于 50时 ,加锁选择
                totalLeftNumLock.lock();
                logger.info("--抽奖数量已小于50,执行悲观锁逻辑--");
                try{
                    totalLeftNum = statTotalLeftNum(activityId);// 再次校验总剩余数量
                    if(totalLeftNum <= 0){
                        drawWinVo.setErrorState(2);
                        drawWinVo.setErrorMsg("奖品已经抽完了");
                        return drawWinVo;
                    }
                    drawPrize = chooseDrawPizeByActivityId(userId);
                    totalLeftNumLock.unlock();
                }catch (Exception e){
                    logger.error("--加锁选中 奖品时出现错误---",e);
                    totalLeftNumLock.unlock();
                }
            }else{
                 drawPrize = chooseDrawPizeByActivityId(userId);
            }
            //抽完奖了
            if(drawPrize == null){
                //没有抽中奖品
                drawWinVo.setErrorState(5);
                drawWinVo.setErrorMsg("暂未中奖");
                logger.error("---暂未中奖:用户 {}--",userId);
                return drawWinVo;
            }
            // 执行保存数据库操作 (缓存 已经 在选择奖品时就加进去了) ,其实可以 异步化的 ,但是 没必要
            return saveDrawWinInfo(userId,activityId,drawPrize);
        }catch (Exception e){
            logger.error("抽奖时发生错误",e);
            drawWinVo.setErrorState(4);
            drawWinVo.setErrorMsg("系统错误,稍候重试");
            return drawWinVo;
        }finally {
            semaphore.release();
        }
    }
/**
 * 不同抽奖活动 自定义逻辑
 * (抽奖资格判断 、 是否需要填写邮寄地址 、抽奖逻辑,按概率抽 ,按库存数量抽 ,兼而有之)
 */
public interface SelfDrawHandleService {

    /**
     * 对应的抽奖活动id
     * @return
     */
    Integer getActivityId();

    /**
     * 判断 用户是否具有抽奖资格
     * @param userId
     * @return
     */
    boolean judgeCanDraw(TUser user);

    /**
     * 判断活动还能否抽奖
     * @param drawWinVo
     */
    void checkDrawForActivity(DrawWinVo drawWinVo);

    /**
     * 是否有抽奖用户限制 (其实也是总奖品数 是否 是有限值)
     * (有的抽奖限制 总数量 ,抽完就结束 (概率就是库存占比),也就是变相限制了参与的人数;
     * 有的抽奖没有限制参与人数 ,抽不中或者实物奖品结束 ,就给安慰奖 (概率是 指定的)
     * @return
     */
    boolean isDrawUserLimited();

    /**
     * 执行抽奖 (由于抽奖奖品、数量等条件不同 ,需要定制化实现)
     *
     * 第一种 ———— 抽奖必中 ,数量有限 ,以数量作为概率 ,抽完为止 (activity 为  1)
     * 第二种 ———— 按指定概率来算 ,概率上抽到啥就是啥 ,抽不中的 发安慰奖 ,而抽中的奖品 如果库存不足,也发安慰奖 ,可以无限抽 (activity 为 2)
     * 第三种 ---- 按指定概率来算 ,概率上抽到啥就是啥 ,库存无限
     *
     * 。。。。。。  (可能还有其他情况 ,依次扩展)
     * @return
     */
    DrawPrize chooseDrawPizeByActivityId(Integer userId);

    /**
     * 实际奖品的库存是否有限 (不是安慰奖)
     *
     * 正常抽奖时 ,奖品库存是有限的 ,库存为0 ,要不重新执行抽奖算法 ,要不就是 默认安慰奖(或者没中奖)
     *
     * 也有一种情况 ,抽中啥就给啥,不用考虑库存 ,实际库存有线下人员负责
     * @return
     */
    boolean isStockNumLimited();
}
@Data
public class DrawPrize implements Serializable {

    private Integer id;

    private Integer level;

    private Integer num;//数量

    private Integer probability;//概率占比 (千分制 ,去除千分号)

    private Integer isConsolation;//是否安慰奖(0否 1是,这个是未抽中奖品时的默认奖品,也可视作未中奖 )
}