使用redis zset实现抽奖,奖池商品按时间随机分布
话不多说,直接上需求描述:
最近需要上一期活动,这个活动是以转盘抽奖为形式的抽奖活动,要求每个用户用积分进行抽奖,且中奖率为100%即不可出现不中任何奖品的情况,之后,又加了一个要求,即不能实行纯随机的抽取,如果如此会产生一个极端情况,如果开始的时候活动极其火爆由于随机的不可控性头一天用户便将所有优质奖品全部抽走,那么后来的用户将只会抽到保底奖品。
那么奖品就需要按时间分布在从活动开始到结束的时间段,其次需要做的是,在某些特殊的时间段,我们希望多投放一些奖品给用户抽到。
需求分析:
那么开奖策略可以为为每个奖品设置开奖时间,只有在开奖后来抽奖才能抽到该奖品,否则视为未中奖发保底奖品,我们只需要拿当前时间与最接近奖品开奖时间对比即可。
由上需求,那么就需要一个容器来存放这些奖品,对这个容器的要求:
1. 它可以以时间轴为维度取出奖品;
2. 它可以以时间轴为维度放入奖品;
3.它可以以时间轴为维度将奖品排序;
同时,后台应该有地方配置每个小时应投放的奖品数量,同时为保证配置数据能及时生效,应当是每小时前去向奖品池投放下一个小时的奖品;
如下图所示,每个奖品都有对应开奖时间,奖品1只有10000毫秒之后的请求才可以抽到,且只有奖品1抽走之后才可以抽奖品2;
抽奖步骤:
性能安全考虑:
显然,抽奖是容易引发并发问题的场景,高并发情况往往会带来两个问题
1. 超发问题,例如将10个奖品发给了11个人,用锁可解决;
2.数据库等基础组件负载过高导致宕机,以数据库为例,如果每个用户每抽走一个奖品都去连接数据库更新库存,数据库很有可能承受不住(数据库能承受的qps远不如redis);
方案:
使用redis的zset数据结构,这里简单说明下zset,它是一个基于跳表实现的有序集合,尤其适合排序场景比较多的场景,是一个典型的用空间换取时间的数据结构。这里我们用开奖时间戳作为score,保证其按照时间排序,存入的时候可以直接将奖品ID与时间戳存入其中即可。
同时设置定时任务,每个小时去拿下一个小时的所需的奖品,随机将其散列在下一个小时的各个时间上,并在此时就将各个奖品库存扣除。
ok,需求完美解决,锁的问题直接上代码,锁就是保证zset的排序操作与移除操作是原子操作,否则便会出现超发,使用了redis的setNx做分布式锁。
/** * 抽奖 * * @param turnTableNum 转盘编号 * @return 奖品ID */ public long getLotteryResult(long userId, int turnTableNum, Map<Long, ActivityTurntableGoodsConfig> goodsConfigMap) { Set<String> prizeSet = null; String prizeResStr = null; try { if (RedisUtils .lock(RedisKey.TURNTABLE_PRIZE_QUEUE_LOCK, String.valueOf(turnTableNum))) { Set prizeSet = RedisClusterAccessor .zrangeByScore(RedisKey.TURNTABLE_PRIZE_QUEUE, String.valueOf(turnTableNum), 0, System.currentTimeMillis(), 0,1); if (null != prizeResStr) { //在奖池中移除奖品 log.debug("{} remove prize {} {}", XGameContextHolder.get(), turnTableNum, prizeResStr); RedisClusterAccessor .zrem(RedisKey.TURNTABLE_PRIZE_QUEUE, String.valueOf(turnTableNum), prizeResStr); } } } catch (Exception e) { throw e; } finally { RedisUtils.unlock(RedisKey.TURNTABLE_PRIZE_QUEUE_LOCK, String.valueOf(turnTableNum)); } if (null == prizeResStr) { return -1; } return CommonUtil.safeParseLong(prizeResStr.split("_")[0]); }