需求理解阶段
先来一张图,用户送一个礼物后,基于送的数量会执行一个爆奖过程,最终可能会得到礼物原价*N倍的奖励,后面会解释整个玩法
如图,当你花100块钱,送一个礼物时(同一个礼物可以在一次抽奖中多次赠送,即10连,100连), 系统给这个礼物配置了N个组合(表格横着看,即图里为10个组合), 然后1个组合里有0倍、10倍、50倍、500倍、5000倍的库存(表格竖着看)。这里可以将中奖概率理解为权重,库存既是中奖的权重,也是库存,因为当中了某个倍数之后,就把这个倍数对应的库存减掉1个,比如中了组合1的0倍,之前0倍的库存是29994,中了之后就是29993了,最终用户中奖获得的金额就是本次抽奖所有次数获得的倍数 * 原始礼物价格。
拿个数据举例
小明在一次请求中送了10组100礼物,共消费1000块, 拿组合1举例,根据权重举例,其中8次中了0倍,1次中了10倍,1次中了50倍,那么最终小明就是获得了60倍的奖励,即会得到100 * 60 = 6000块的奖励。当然如果获得0倍,则什么都无法获得。
抽奖之后,看图中数据,则组合1目前还剩下
- 29994 - 8 个0倍
- 292 - 1 个10倍
- 138 - 1 个50倍
- 500倍未中,保持为27个
- 5000倍未中,保持为1个
现在详细说明需求的抽奖逻辑:
所有的组合是为全平台公用,即不是某个用户个人所有。抽奖之前,必须先判定有没有正在使用的组合,如果没有就从10个里面随机挑选一个,注意随机,很重要,主要就是保证没有规律,毕竟组合的数据是固定的,因此抽奖挑选组合的时候不能有规律,这个组合的使用顺序要是乱的。 当挑选一个组合之后,只要这个组合没用完,就不能用第二个组合。注意这个行为也不是用户的行为,是要保持全局统一的。某个用户先选了一个组合,只要这个组合里还有库存,后续抽奖的用户决不能自己去挑一个组合。当系统所有上一次刷新的所有组合都抽完时, 要保证新的组合库存能刷新出来。
注意问题
- 常规逻辑,抽奖之前先确定用哪个组合再去这个组合抽,存在当你拿到组合的时候实际去抽的时候组合里没数据了
- 即使去抽的时候组合里有数据,多抽的情况下抽一半的时候这个组合没数据了
- 怎么保证抽奖不会卡顿,即库存一直是有数据的
初步动手阶段
这个需求最明显的特征,就是组合与库存的对应关系,至于库存其实很好解决,直接用Redis
的list
即可解决,将库存rpush
到队列中,然后给这个队列取个缓存的名字,然后编号,这样当抽奖的时候,先判断当前使用的组合然后到对应的队列中去lpop
数据即可。
还需要维护一个全局的正在使用中的组合编号,以供所有人抽奖的时候去获取。
因为需要用到队列,而且队列需要刷新,数据取到之后还要移除掉,数据本身又会重复,还要保证一个库存不能被两个用户取到,所以选择Redis
的list
数据结构 通过rpush
来刷库存, 将最新的数据添加到队列右边,使用lpop
来取出队列左侧的数据并移除,通过r 和 l的这种互不影响,来完成取和添加的操作。
来看一下这两个命令的时机解释和作用
维护队列库存的伪代码,即通过库存配置将库存刷到对应组合的队列中以供后续使用,几个组合就几个队列,队列中的value对应的则是中奖的倍数
List<组合配置> 组合配置集合 = 获取所有的组合库存配置();
for (组合配置 当前组合 : 组合配置集合) {
redis.list.rpush(当前组合.组合id, 当前组合.倍数);
}
形成的数据存储结构如下
当抽奖时,比如要去组合1抽数据,则直接获取到组合1的队列,调用lpop
方法即可获得并移除元素,而且lpop
命令可以避免多个用户取到同一个库存的问题。
比如组合1现在10连抽,伪代码如下, lpop
弹出的元素即使本次中奖倍数,直接累加即可获得最终总中奖倍数。
int 总中奖倍数;
for (int i = 0; i < 10; i++) {
总中奖倍数 += redis.list.组合1.lpop();
}
整个核心逻辑就是上面那样,但是目前为止还需要考虑几个小细节但是却很复杂的东西
- 如何获得当前系统正在使用的组合?这个问题不是特别复杂,至少可以解决,通过
Redis
来维护一个key,代表当前系统正在使用的组合,但是由于要保证全平台用户都可能使用或者修改这个值,因为必须加全局分布式锁,而且加锁失败还不能用锁等待的方式。 - 现在解决了获取到组合的问题,也解决了怎么到组合抽奖的逻辑,但是如果获取到组合时,实际再去抽奖的时候,这个组合里没数据了怎么办?
- 这里要考虑怎么刷库存的问题,还要保证谁去刷库存的问题。因为获取当前组合、再到组合中抽数据,这样一个过程不是原子的操作, 如果很多人在抽奖, 那么
lpop
的话,可能很多用户都抽不到数据,那么谁去刷库存呢? - 还有一个比库存更棘手的问题,当前是用户正在抽奖,
lpop
空了之后,你要继续去处理用户的抽奖逻辑,不能停了啊,又要回到获取当前正在使用的组合然后继续去抽的逻辑。
- 这里要考虑怎么刷库存的问题,还要保证谁去刷库存的问题。因为获取当前组合、再到组合中抽数据,这样一个过程不是原子的操作, 如果很多人在抽奖, 那么
- 这个和2差不多,是抽奖抽到一半了这个组合里的数据大家都在抽,这个组合没数据了怎么办?
现在再回想一下这个方案,其实除了主要逻辑以外,其它各种细节都极其难以处理,归根到底就是所有的操作都不能保证原子性,数据也一直被共享、实时变更。要考虑的问题实在太多。
数据完整性方案(绝大情况下使用)
说明一下这个方案,是个人认为无论是数据上还是逻辑上,以及代码上的严谨性都是能够非常好的满足需求的,除非是数据非常大的情况。比如每次的库存刷新是几十上百万的库存,那就要考量一下了,否则优先级别还是第一的。
算一笔粗略的账,不考量redis内部实现,只非常粗略的来算。一个奖品的value是20个字节, 如果是10W个奖品,那么这个list的大小为20 * 100000 / 1024 / 1024 约等于2MB左右。俨然属于一个大key了,不过从实际抽奖使用数据来说,每次都是O1操作,所以不会有问题。数据再大,就看情况考虑了。
思路分析
说回方案本身,按照之前的设计方案,最恶心的地方是在哪?即需要先获取到当前使用的组合,然后再去抽,后续出现的所有问题都是基于这个实现的,因为这个天然分隔的两个操作,导致后续所有数据都需要考虑中途变更带来的影响。
现在回头看下,在前面的方案中,我们在队列中存储的数据是什么,是对应组合的倍数,而且有几个组合我们就维护几个队列也导致了我们在代码实现上获取和抽奖两个操作的割裂。但是最初我们为什么要这么设计呢?因为抽奖的时候需要知道在哪个组合抽的,而且组合还是随机挑选的。那么如果我们换一种实现方式呢?
且看下面的内容,现在不管你有几个组合,我全部都给你刷到一个组合队列里去。
存在的问题是啥呢?按需求逻辑顺下去,必须是先知道组合然后才能知道去哪抽,现在我给你反过来,我也不知道要在哪个组合抽,系统也不知道,但是我能保证我抽出来的时候就知道了,而且我还能保证一旦我抽到了某个组合的第一个库存,我可以确实实现会将你这个组合一定给你抽完,才能抽到后面的组合。而且我刷组合的时候给你随机掉,先把所有组合变成一个集合,然后把集合打乱再顺序处理集合,是不是就是随机。一旦本次随机好,这个顺序就固定了,然后每次全量刷组合的都随机,是不是就都不一样。这样虽然是一个总的大库存,但是由于是先进先出的队列来保存库存,依然能够完成需求本身逻辑。整体思路怎么做呢?
- 获取到所有的组合配置集合,并且随机打乱这个集合。注意只是打乱组合1~组合n,这个组合的顺序,不是打乱具体组合里库存配置的顺序。即取出来比如是组合1,2,3,4,打乱后为组合2,3,4,1。
- 遍历所有的组合,往同一个队列中的队尾依次添加数据。重点是队列中放的数据是什么?之前放的是倍数,确实这个倍数使我们真正需要的东西,但是我现在要放一些辅助的东西在里面。
格式为倍数,组合id,当前库存在当前组合中的索引,当前组合总库存数量
;
如何理解上面那个格式呢?四个变量,逗号分隔,一起放入到队列中。
第1个值很好理解,这是我们真正需要的中奖倍数
第2个值为组合id,因为放入到一个队列中了,所以将组合id也放入进去,取出来的时候根据这个值就能知道这个库存是哪个组合的
第3个值需要解释一番,含义是当前库存在当前组合中的一个顺序角标。比如组合1有10个0倍, 5个5倍, 1个50倍。那么在将组合1放入到队列中的时候必然是一个循环,这个角标就是循环所有库存时的角标,是一个顺序索引,从1到16。首先明确一点,我们放库存的时候是for
循环放的,那么有几层循环呢?至少两层,第一层是组合数,有几个组合就有几个循环。但是嵌套的第二层才是我们关心的,这一层循环的是库存的数量,因为我们需要将倍数放入到队列中,因此放库存是在一个循环中的。我们第三个值就是这个for
循环内部的角标,不过循环的时候要稍微处理一下,角标从1开始。从0开始也行,那这个索引值就是当前循环角标+1。要注意这个角标是一个组合内所有倍数累加的,不是一个倍数下累加的。这样可以准确的知道,到目前为止抽到一个奖品之后,可以得知的信息就有,这个奖品的倍数,属于哪一个组合,是这个组合里第几个奖品。
第4个值为当前组合的所有库存数,是组合内所有倍数的库存总和。这样可以方便的得知当前组合一共有多少库存。现在来看,第3个值为同一个组合内顺序累加的库存索引,第4个值为这个组合的所有库存,现在来看,如果将一个奖品取出来,发现这两个值相等,我们能够得出什么结论?即这个奖品是某个组合的最后一个奖品,这有什么好处呢?如果我们要刷库存还是一些其他处理,这个就是一个很好的契机,因为我们可以用这个作为突破口用来做刷库存的契机。
第5个值,我们准备按照一定的策略将这个位置的值设置一个标识位,代表抽到这个奖品要不要去刷下一批库存。然后我们只需要按照业务的体量和表现形式寻找一个合适的位置,比如将这个值设置为true,然后抽到奖品的时候去判断这个值是不是true, 如果是true,就去执行刷新库存的逻辑。一般而言,这个值肯定是库存数量的一个提前量, 肯定不是库存最后或者快到库存最后再去刷库存,所以提前多少就看业务情况了,这样只要抽到这个奖品,我们用异步线程去刷库存就行了,这样就能解决不需要组合的库存抽完再刷新一轮的库存带来的延迟问题,也解决了提前刷新的时机选择问题,而且这样我们还可以异步操作。
假如我们目前的组合配置如下,那么来看一下实际我们在队列中存储的数据内容
倍数 | 组合1 | 组合2 | 组合3 |
---|---|---|---|
0 | 3 | 3 | 2 |
10 | 2 | 1 | 2 |
50 | 1 | 1 | 0 |
这个图代表一共有三个组合,其中组合1有3个0倍的库存,2个10倍的库存,1个50倍的库存,其它组合类推。
- 获取到组合列表之后,打乱组合顺序,即组合1、2、3的顺序,这里比方随机后最终顺序为组合2、3、1举例
- 依次循环处理每个组合下的倍数库存,按照上面对队列中一个库存四个含义的设计,注意组合内部的库存也是打乱后再放入的,即组合1一共3个0库存,2个10库存,1个50库存,不能直接就放入到库存了,要打乱,否则相同倍数的库存都连在一起了,抽奖体验太扯淡了。
- 效果如下图,注意组合的衔接处,红色虚线圈起来的部分,每一个组合的结尾,索引值和库存值一定是相等的,同组合内倍数的随机分布就按图里随便写的,没有实际规律。
- 第5个值,比如我们的策略是库存抽到一半的时候去设置下一个奖品为刷新库存的奖品,这样总的组合库存数量为15,我们将第8个奖品的第5位值设置为ture即可,当然这个不是硬性规定,自己决定就行,甚至可以在奖品的后半部分平均多放几个刷库存的奖品,这样可以避免只有一个提前刷奖品的库存如果出现问题,还能多几个兜底,使奖池更加稳定。不过要结合总库存的内存来考量,多一个提前刷的即多一倍的库存,内存也是一样。
如上图,如果我们的数据这么存的话,依照先入先出的原则,队尾入,队头出,只有一个队列就可以非常轻易的解决问题。
部分代码参考
以Redis
举例, 采用数据结构为List
, 核心命令是刷库存用rpush
, 取奖品用lpop
。
刷库存逻辑
代码有些类虽然没法点开,但后面会有应用,只是个大概的思路,又因为有各种奖励配置,想展示清楚,也没法精简下演示代码。
- 整体刷队列库存的逻辑
注意刷库存的时机,这个按自己业务调整。比如我现在设置的是1/2 + 1个组合的最后一个奖品,抽到这个奖品就去刷库存。但是存在的问题是, 按需求来理解,比如10个组合, 一定是这10个组合抽完才去刷下一轮的这10个组合再随机一批库存。我现在抽了6个,就去刷下一轮的了, 再生成的新一轮的10个组合排在前面的组合可能上一轮已经被抽掉的6个是重复的。那么在一些概率上可能就会不满足需求。
但其实这个影响并没有那么大,因为这个数据永远也只会影响到一次。这个数据就相当于第一次初始化组合抽了6个就刷第二轮的10个了,但是第二轮再抽6个的时候,又去刷第三轮的库存。只有初始化的那个库存抽了6个就去刷了,剩余的其它每次刷库存虽然也是这一个批次的第6个刷库存,但是上一轮还剩4个呢,这样就其实也是10个库存。所以只有初始化的那个库存是抽了6个刷库存,后续数据就会自动变成上一轮的剩余和这一轮已抽到的又组成10个组合了。数据就正常了。
如果实在觉得膈应, 可以把刷库存的时机设置靠后一点,这个按照业务自己决定就好,但是要考虑数据量,比如我这个业务是一个用户的请求就可以上千次的抽奖次数,抽奖的数据幅度是比较大的,总之要保证刷的时候,后面还剩余的奖品足够你的业务撑一段时间的数据,而不是剩的奖品都不够一个用户抽的采取刷奖池。
public void pushPrizePool(List<String> giftMultipleStrList) {
// 这里是把组合打乱,起到随机的作用
Collections.shuffle(giftMultipleStrList);
// 将字符串集合转换为礼物组合对象集合
final List<GiftMultipleConfig> giftMultipleConfigList = giftMultipleStrList.stream()
.map(str -> JSONUtils.parseObject(str, GiftMultipleConfig.class))
.collect(Collectors.toList());
// 这里就是所谓的组合配置列表,每个组合下有不同的奖励数据
for (int i = 0; i < giftMultipleConfigList.size(); i++) {
GiftMultipleConfig giftMultipleConfig = giftMultipleConfigList.get(i);
// 这里在总组合一半+1的组合中加一个标识,当抽到这个标识就去重新刷这个礼物的库存
if (i + 1 == giftMultipleConfigList.size() / 2 + 1) {
pushPrizePool(giftMultipleConfig, true);
} else {
pushPrizePool(giftMultipleConfig, false);
}
}
}
- pushPrizePool的详细逻辑
public void pushPrizePool(GiftMultipleConfig giftMultipleConfig, boolean isReload) {
if (Objects.isNull(giftMultipleConfig)) {
return;
}
List<String> prizeStrList = new ArrayList<>(20000);
final int totalStock = giftMultipleConfig.getTotalStock();
// 正常按表象需求存在问题
// 按正常流程来说, 存在多个组合, 那么发布的时候就需要知道当前一共有多少组合,然后再存每个组合对应的概率配置;
// 中奖的时候先确定平台当前使用的是哪个组合,然后每个用户抽奖就到这个组合的奖池中去抽奖。
// 但是上述的实现思路存在几个复杂问题, 首先是维护礼物和组合的奖池对应关系,还有一共多少奖池,最重要的是当前平台使用的是哪个组合,但是
// 这些数据无论哪个都是在实时变化的,获取到使用的这个组合实际去用的时候说不定这个组合已经被抽空了,这些原子性问题全部无法保证,各种数据都存在
// 突然不能使用的问题,然后就得再次获取下个组合,再去取数据
// 解决办法
// 这里通过刷库存的方式, 直接将value使用原始中奖倍数和组合名称拼接的方式, 然后抽奖的时候,直接取出来, 只要能取到数据,那么这个值就立刻确定是哪个组合,
// 而且同一个请求的多次抽奖,取出来的第一条数据和最后一条数据的组合不一样,那么就可以确认当前组合肯定被抽完了,甚至可以在这里发现组合变了就去刷库存也是可以的。
// 还有一个问题,取的时候永远是从left取, 而且在取的时候不需要提前知道要往哪个组合中取,就避免了,先获得组合再去取然后发现组合又空的问题。
// 而且整个刷库存的逻辑也变的简单了, 之前刷库存还要考虑哪个组合用完了,然后往里面刷,现在就可以定个时机,直接按配置将库存从右边可以刷就行了,
// 刷库存的时机也变的灵活多了,就算多刷了几次库存,也仅仅是提前为未来多刷一些数据,并不会实际影响中奖概率,这对程序的严谨性来说会变的轻松很多。
for (int i = 0; i < giftMultipleConfig.getZeroMultipleStock(); i++) {
prizeStrList.add("0," + giftMultipleConfig.getKey() + "," + giftMultipleConfig.getName());
}
for (int i = 0; i < giftMultipleConfig.getTenMultipleStock(); i++) {
prizeStrList.add("10," + giftMultipleConfig.getKey() + "," + giftMultipleConfig.getName());
}
for (int i = 0; i < giftMultipleConfig.getFiftyMultipleStock(); i++) {
prizeStrList.add("50," + giftMultipleConfig.getKey() + "," + giftMultipleConfig.getName());
}
for (int i = 0; i < giftMultipleConfig.getFiveHundredMultipleStock(); i++) {
prizeStrList.add("500," + giftMultipleConfig.getKey() + "," + giftMultipleConfig.getName());
}
for (int i = 0; i < giftMultipleConfig.getFiveThousandMultipleStock(); i++) {
prizeStrList.add("5000," + giftMultipleConfig.getKey() + "," + giftMultipleConfig.getName());
}
Collections.shuffle(prizeStrList);
// 保证最终存储格式为"倍率,组合唯一标识,组合名称,总库存,当前编号,是否刷新库存",这样取出来数据的时候, 如果发现总库存==当前编号,就可以判断是谁把这个组合的最后一个奖抽走了
// loadCombinationStr解决的问题是,现在业务不允许抽完一个刷一个,而是抽到一定程度之后再刷新已经抽掉的一批组合,然后组合再打乱顺序,如果抽完一个刷一个,
// 那么组合顺序就固定了。这个字段就是用来存要刷的组合的唯一标识的,谁抽到有这个标识的奖品就去根据这个里面存的组合去刷这一批组合到库存中
for (int i = 0, size = prizeStrList.size(); i < size; i++) {
String prizeStr = prizeStrList.get(i) + "," + (i + 1) + "," + totalStock;
// 抽到这个奖品则代表要重新刷库存了
if (i + 1 == totalStock && isReload) {
prizeStr += ",true";
}
prizeStrList.set(i, prizeStr);
}
Lists.partition(prizeStrList, 1000).forEach(partition -> {
JedisUtil.rpush(String.format(GIFT_COMBINATION_PRIZE_POOL, giftMultipleConfig.getGid()), partition.toArray(new String[]{}));
});
prizeStrList.clear();
}
抽库存的逻辑
抽奖的时候只要一直从队头pop
出元素即可,因为我们只刷到一个集合里,所以固定到这个集合里直接取数据即可。
下面贴出来取奖品的的大致逻辑代码,因为我们允许一次抽奖的次数可以多次,由于pop
命令每次只能弹出一个元素,我们需要考虑怎么能尽量减少由于抽奖次数增大而带来的每次连接的延迟问题,所以要去处理批量pop
来减少耗时。
public PrizeMetadata popPrize(Integer gid) {
final String prizeStr = JedisUtil.lpop(String.format(GIFT_COMBINATION_PRIZE_POOL, gid));
// 上层处理这个空的逻辑问题,上层设计层面已经保证这里几乎不可能走到空
if (org.apache.commons.lang3.StringUtils.isBlank(prizeStr) || Objects.equals("nil", prizeStr)) {
// 这里只是个兜底判断,避免循环中调用次数太多,从缓存中获取,不保证准确
final Map<String, String> config = getAllPublishedBombPrizeGiftMultipleConfig(gid);
// 如果配置存在,则返回null, 让上层去处理重试逻辑
if (CollUtil.isNotEmpty(config)) {
return null;
} else {
// 配置不存在,则上层根据这个实例判断,要终止循环,避免死循环,配置不存在,再刷也取不出来礼物
return PrizeMetadata.noPrizeConfigMetadata();
}
}
return PrizeMetadata.fromPrizeMetadataStr(prizeStr);
}
上面这个是核心的与redis
交互的取奖品的逻辑,如果我们要接入业务,还是需要处理几个细节点的,一些是正常要处理的逻辑,一些是如果提前刷库存的时候服务宕机了或者redis
抖动等不可抗拒因素导致失败了的兜底逻辑
- 处理提前刷库存的逻辑
- 业务中有些硬性条件,当中了某个大奖时,需要满足一定条件,否则需要归还奖品,重新再抽
- 抽奖返回的奖品数量 < 用户实际请求的抽奖次数, 要能判断出出现了这种情况,然后补偿数据。
- 中途因为配置变更或者配置数据丢失导致库存永远也刷不上,不要死循环
核心代码逻辑如下, 不要解决与某个细节,这是大致的处理流程
// 这个就是调用了上面那个跟redis交互的取奖品的方法
final List<PrizeMetadata> prizeMetadataList = stockBombPrizeRepository.popPrize(gid, count);
if (CollectionUtils.isEmpty(prizeMetadataList) || prizeMetadataList.size() < count) {
// 重新刷新全部奖池(这里是最终兜底方案,正常设计逻辑不可能会走到这的,除非全部奖池的库存数量还少于一次抽奖的count, 这在业务上是不可能的)
// 还有这种情况下的刷奖池,最好分布式锁控制下,不要出现了之后很多人都去刷库存。方法内部就不展示了,和之前刷库存大致逻辑差不多,
// 只是多了个分布式锁的控制而已。
stockBombPrizeReloadPool.execute(() -> stockBombPrizeRepository.reloadGiftPrizePoolStock(gid));
}
// 这个实例代表礼物就不存在奖池配置, 第一次没返回数据,就不要再重试了,否则会死循环,注意集合为空也要重试
if (CollUtil.isEmpty(prizeMetadataList) || prizeMetadataList.get(0) != PrizeMetadata.NO_PRIZE_CONFIG) {
// 不允许中出的奖品
List<PrizeMetadata> missList = new ArrayList<>();
for (int i = prizeMetadataList.size() - 1; i >= 0; i--) {
// 5000倍不满足条件要重新放入到奖池中
if (prizeMetadataList.get(i).isSupMultipleStock() && giftDailyConsume < giftConfig.getFiveThousandMultipleConsumeLimit()) {
missList.add(prizeMetadataList.get(i));
prizeMetadataList.remove(i);
}
}
while (prizeMetadataList.size() != count) {
final List<PrizeMetadata> currentBatchList = stockBombPrizeRepository.popPrize(
gid, count - prizeMetadataList.size());
for (int i = currentBatchList.size() - 1; i >= 0; i--) {
// 5000倍不满足条件要重新放入到奖池中
if (currentBatchList.get(i).isSupMultipleStock() && giftDailyConsume < giftConfig.getFiveThousandMultipleConsumeLimit()) {
missList.add(currentBatchList.get(i));
currentBatchList.remove(i);
}
}
if (CollectionUtils.isEmpty(currentBatchList)) {
stockBombPrizeRepository.reloadGiftPrizePoolStock(gid);
}
prizeMetadataList.addAll(currentBatchList);
}
stockBombPrizeReloadPool.execute(() -> {
// 判断是否存在不允许中出的奖品,然后要重新放回到奖池中
if (CollUtil.isNotEmpty(missList)) {
for (PrizeMetadata metadata : missList) {
// 重新刷回奖池中
stockBombPrizeRepository.lPushPrize(gid, metadata);
}
}
// 判断这一批抽中的是否有某个组合的打了需要刷库存的标识,如果有的话,就去刷库存,这样判断,是谁最后抽没了,谁去刷,就避免了竞争不知道该什么什么刷库存的问题
prizeMetadataList.stream().filter(PrizeMetadata::isLoadCombination).forEach(currentMetadata -> {
// 也是通过这里来保证奖池中永远都有数据, 但极限情况下如果刷的过程中出错或者任务呗拒绝,则可能会没有数据,但当前有10个组合,
// 所以不存在问题,就算10个组合都在极短的时间内刷失败了,也会有极限兜底重刷全部奖池
stockBombPrizeRepository.pushTargetGiftMultipleStock(gid);
});
});
}
// prizeMetadataList这个现在就是最终抽到的奖品数据了
// 这个就是最终总中奖的倍数和
int totalMultiple = prizeMetadataList.stream().mapToInt(PrizeMetadata::getMultiple).sum();
一种特定情况下超级简单的实现方案
上一中方案是采用的Redis
的List
,然后利用pop
和push
相关的命令来完成抽奖队列的先进先出。然后由于不能批量pop
还需要额外处理一波。
这个方案是诞生于上一个方案的特定进化版本, 上一个版本在实际业务中, 比如10个组合刷到一个库存里,可能是几十万的奖品里只有几百几千个奖品, 但是因为使用的是pop
, 所以每个奖品都必须占用空间。
现在换一种解决方案, 最初的想法就是能不能0不占用空间。最终确定使用Redis
的hash
结构
思路分析
整体思路是, 使用Hash
来存奖品, 但是Hash
是按照hash key
来取数据的,我抽奖的时候怎么知道hash key
呢,以及存数据的时候如何确定奖品的hash key
呢, 还要保证抽奖的时候能和这里对应上。
这里解释个东西,否则下面说全局的时候容易引起误会。就是抽奖的时候, 比如抽奖有载体还是什么,比如我们上面的载体是礼物,那可以有多个礼物,我们下面说的东西全局都是和礼物绑定的,因为每个礼物都是隔离的。如果有自己的抽奖业务是不隔离的,那就是一个。
两个索引操作
定义一个全局的hash
配置, 有两个hash key
, 一个是max_index
, 一个是current_index
,每次使用都是原子递增。
max_index
代表奖池里最大一共申请了多少个索引。比如总库存是100个, 但是有奖励的奖品只有10个,那么max_index
也是100,它是总的库存大小,而不是有奖励的的大小。所以要放库存的正确步骤是,先调用一个申请库存索引方法,传入预期申请多少个索引,然后返回最大值。程序中就可以通过最大值-传入的索引大小来原子性的判定出这一批库存对应的奖品的hash key
。 即这一批奖品的初始索引为(返回的最大索引值 - 申请的奖品索引count + 1。 如下代码段
// 申请最大奖池索引
public Long applyCurrentMaxPrizeIndex(Long step, Integer gid) {
return JedisUtil.hincrBy(STOCK_BOMB_PRIZE_CONFIG_KEY, getPrizePoolMaxIndexKey(gid), step);
}
// 申请奖池角标, 反减回去的值和这个值之间的区间即是这一批奖品所在的位置区间
long needPrizeIndexNum = compositions.stream().mapToLong(Composition::getCompositionNums).sum();
final long maxPrizeIndex = compositionStockBombPrizeRepository.applyCurrentMaxPrizeIndex(needPrizeIndexNum, gid);
AtomicLong beginPrizeIndex = new AtomicLong(maxPrizeIndex - needPrizeIndexNum + 1);
current_index
代表当前奖池一共抽奖的多少次,每次按照抽奖的次数递增即可。抽奖之前先去按照抽奖次数去申请索引,然后按照和max_index
一样的办法反算这一批抽奖对应的索引,然后这一批索引即使奖池的hash key
,直接去mget
即可。代码段如下
// 申请抽奖奖池索引
public Long incrementPrizeIndex(Long times, Integer gid) {
return JedisUtil.hincrBy(STOCK_BOMB_PRIZE_CONFIG_KEY, getPrizePoolCurrentIndexKey(gid), times);
}
// 当前抽奖最后的奖品角标
long afterPrizeIndex = incrementPrizeIndex((long) size, gid);
// 当前抽奖最开始的奖品角标, + 1是实际奖品的角标是从1而不是0开始的, 即5个奖品,最后一个奖品角标是5,那么这5个奖品的角标序号是1,2,3,4,5
long beginPrizeIndex = afterPrizeIndex - size + 1;
String[] fields = new String[size];
// 计算这一批抽奖对应的奖品角标, 现在这个fields就是奖品的hash key数组了
for (long i = beginPrizeIndex; i <= afterPrizeIndex; i++) {
fields[(int) (i - beginPrizeIndex)] = i + "";
}
刷库存的逻辑
如何去刷库存, 上面我们已经解释了max_index和current_index
的作用,现在刷库存就是要使用到max_index
了。有了max_index
现在我们就能知道这一批奖品对应的hash key
了,然后先满足业务要求的前提下将这一批奖品打乱满足随机性,然后在循环奖品处理实际奖品这个value
的数据结构, 大致结构和之前方案相同
- 第一位是实际的奖品奖励信息, 我们案例中就是倍数
- 第二位是为当前奖品分配的索引
- 第三位也是和之前方案一样的一个刷库存的奖品标识符。当满足某种情况时,将这一位设置为true, 抽到奖品的时候判断如果包含了true就去异步提前刷下一轮的库存
当然刷库存还有一点要注意的是, 因为奖品需要打乱, 所以不能同时在循环处理奖品的同时然后给奖品索引,因为我们的索引是有序的,给过索引之后再打乱集合已经没有任何意义了。
全部数据处理完之后,再持久化到奖池中。这个时候再处理过滤掉无效奖品的数据。过滤掉之后的奖品才是真正要持久化到奖池中的数据。
还有就是确定要刷奖池的那个奖品的方法,要比之前麻烦一点。之前直接在所有奖品里找个定位就可以了,现在则不行,直接在所有里找定位,因为索引分配是随机的(奖品生成打乱后再递增分配造成的),并不能保证后面的奖品的索引一定比前面奖品的索引大。这样直接在所有奖品里找靠后的一个角标,很有找到的这个奖品是一个无效的奖品,根本不会持久化到奖池中。
所以只能在过滤掉所有无效奖品中的集合中来按照之前的算法, 比如在这一批有效奖品的百分比(比如70%)的位置上的这个奖品设置为刷奖池的奖品。这个百分比位置看自己业务决定,要保证尽量靠前。而且只适用于大量的奖池中有很少的有效奖品,这样打个相对极端的例子,虽然这个奖品在库存里是最后一个,但是比如这一批库存的最大索引为9000, 而这个奖品虽然是最后一个,但它对应的索引为2000, 那也没太大问题,因为后面还有7000个抽奖机会。因为我们这个方案索引一直是递增的,所以提前生成下一批、下下一批的奖池都不会有问题的,可控就行。总之一定不能等奖品抽完了才去刷奖池,这个方案如果不能提前刷奖池,在业务上是会出问题的。后面会详细解释。
当我们过滤掉无效的奖品之后,将集合复制一份,然后按照索引从小到大排序,然后再去找这个刷新奖池的奖品的索引,在这个集合里比如70%的奖品,然后获取到这个奖品的索引,拿到这个索引之后再到过滤掉无效的奖品的原始集合里根据这个索引遍历找到这个奖品,然后将它的value的刷新奖池的标识设置为true。
现在就可以将这个集合持久化了。
相关代码如下, 这个主要关注处理奖品的索引的操作
// 获取根据库存的有奖励数量提前生成的奖励集合,后面要放奖励的时候直接拿就行了,生成的时候已经按照权重生成过了
final List<Integer> multipleStock = config.generateMultipleStock();
List<PrizeMetadata> tempMultipleList = new ArrayList<>();
// 申请奖池角标, 反减回去的值和这个值之间的区间即是这一批奖品所在的位置区间
long needPrizeIndexNum = compositions.stream().mapToLong(Composition::getCompositionNums).sum();
final long maxPrizeIndex = compositionStockBombPrizeRepository.applyCurrentMaxPrizeIndex(needPrizeIndexNum, gid);
AtomicLong beginPrizeIndex = new AtomicLong(maxPrizeIndex - needPrizeIndexNum + 1);
for (Composition composition : generateCompositions) {
// 同一个组合内打乱
tempMultipleList.clear();
// 组合当前组合的无奖励库存, 但是先不分配索引,因为后面要打乱
if (composition.getEmptyRewardNum() > 0) {
for (int i = 0; i < composition.getEmptyRewardNum(); i++) {
tempMultipleList.add(PrizeMetadata.builder().multiple(0).build());
}
}
// 组合当前组合的有奖励库存, 但是先不分配索引,因为后面要打乱
if (composition.getRewardNum() > 0) {
for (int i = 0; i < composition.getRewardNum(); i++) {
if (multipleStock.size() > 0) {
// 从奖励库存中取一个奖励放入当前组合库存
tempMultipleList.add(PrizeMetadata.builder().multiple(multipleStock.get(0)).build());
multipleStock.remove(0);
} else {
log.error("[组合库存幸运礼物]-库存有奖励数量和组合配置未对应,请检查配置! stockConfig = {}", JSONUtils.toJSONString(config));
// 兜底不报错
tempMultipleList.add(PrizeMetadata.builder().multiple(0).build());
}
}
}
// 打乱当前一组有奖励和无奖励的顺序,然后再放入到库存中
Collections.shuffle(tempMultipleList);
// 打乱后给奖品分配索引
tempMultipleList.forEach(obj -> obj.setIndex(beginPrizeIndex.getAndIncrement()));
multipleStockList.addAll(tempMultipleList);
}
compositionStockBombPrizeRepository.pushStock(gid, multipleStockList);
然后看下如何处理过滤掉为0的库存以及如何设置奖池刷新位的逻辑, 这个方法的入参就是上面生成的奖品的集合
public void pushStock(Integer gid, List<PrizeMetadata> multipleStockList) {
// 过滤掉倍数为0的数据,让其不占用库存空间,节省大量内存, 这么写需要抽奖的逻辑对应支持
final List<PrizeMetadata> finalPrizeList = multipleStockList.stream()
.filter(obj -> Objects.nonNull(obj.getMultiple()) && obj.getMultiple() != 0)
.collect(Collectors.toList());
// 拷贝一份原始集合来进行奖池刷新位的计算,因为不能打乱原奖品集合
final List<PrizeMetadata> tempMultipleStockList = BeanCopierUtils.copy(finalPrizeList, PrizeMetadata.class);
tempMultipleStockList.sort(Comparator.comparingLong(PrizeMetadata::getIndex));
// 根据实现和业务综合考虑, 在奖池的第70%那么元素的位置时,设置一个刷新位,一旦抽到这个奖品,则启动异步线程去刷奖池,用以解决提前生成奖池的时机
final long reloadIndex = tempMultipleStockList.get(((int) (finalPrizeList.size() * 0.7))).getIndex();
// 根据上面临时计算出的reloadIndex找到这个奖品,将刷奖池标志设为true,但是因为原中奖集合的索引是打乱的,所以存在索引很大的值分配给了前面的奖品,
// 这样就存在reloadIndex找到的奖品很靠后,如果是很靠后,由于抽奖实现方式就会造成吃用户抽奖次数的问题, 这个由业务决定,现在的业务是非常大
// 的数据里有奖品的数量很少(十几万级别库存里百位数有效库存),再加上reloadIndex已经尽量去取有效库存的中位值了,所以可以不去管这个事情。真在乎的话,那就是
// 无效库存很少,根本不需要用现在这种0不占内存的实现方式
final PrizeMetadata metadata = finalPrizeList.stream()
.filter(obj -> Objects.equals(reloadIndex, obj.getIndex()))
.findFirst()
.orElse(null);
if (Objects.nonNull(metadata)) {
metadata.setLoadCombination(true);
} else {
log.error("[组合库存幸运礼物]-未找到刷库存的奖品, gid = {}, reloadIndex = {}, multipleStockList.size = {}", gid,
reloadIndex, multipleStockList.size());
}
JedisUtil.pipelinedExec((v) -> {
Lists.partition(finalPrizeList, 1000).forEach(list -> {
JedisUtil.hmset(getGiftCombinationPrizePoolKey(gid), list.stream().collect(
Collectors.toMap(obj -> obj.getIndex() + "", PrizeMetadata::toPrizeMetadataStr)));
});
});
}
这个对应的数据结构就是100个库存, 只有10个有奖励的,那么最终只有这个10个占用了空间,max_index
就是100, 下一次刷新总库存的时候max_index
就是200。
抽奖的逻辑
如何去抽奖, 抽奖按照上面解释的current_index
获取到hash key
数组之后,直接调用mget
就可以获取到数据了。
但是因为上面的实现方式, 即奖池里并不包含全部的库存(即有奖励的奖品和无奖励的奖品),目前只有有奖励的奖品,这个时候根据索引(hash key) 去获取数据的时候,肯定存在获取到的数据的大小小于抽奖次数。但是出现这种情况又分为两种情况
- 一个是确实没中奖,奖池里有数据,但是这个用户抽奖的索引小于当前奖池中索引最小的一个奖品的索引,所以根本就没中奖。
- 一个是库存出问题了,即没有提前刷出来库存或者库存刷的不及时等原因,导致了用户先计算出来索引去抽奖,库存到抽奖后面才生成出来这个索引的奖品,那么用户也是抽不到奖品的。
根据上面两个情况,我们就知道了这个方案的局限性了,当然如果奖池里有全部奖品的话,则不存在这个问题。现在出问题以及后面逻辑的前提都是0不占内存引起的。所以这个方案的局限性就是库存出问题的时候系统没办法判断出来,没法根据返回的数据大小来判定是不是少给了用户抽奖次数的奖品。那么也就没有办法去纠正这个情况,但其实如果我们处理好数据与刷库存的时机的话,是不会出现这个问题的。
还有一个就是上线的时候,需要我们主动先初始化一遍库存。然后后续的话,系统介入自动刷库存的逻辑就可以了,如果不先初始化,用户去抽的时候才初始化,按照上面解释的原因,就会造成用户抽奖次数的丢失。
抽奖核心代码如下, 先看抽奖与redis交互取奖品的逻辑
public List<PrizeMetadata> popPrize(Integer gid, int size) {
// 当前抽奖最后的奖品角标
long afterPrizeIndex = incrementPrizeIndex((long) size, gid);
// 当前抽奖最开始的奖品角标, + 1是实际奖品的角标是从1而不是0开始的, 即5个奖品,最后一个奖品角标是5,那么这5个奖品的角标序号是1,2,3,4,5
long beginPrizeIndex = afterPrizeIndex - size + 1;
String[] fields = new String[size];
// 计算这一批抽奖对应的奖品角标
for (long i = beginPrizeIndex; i <= afterPrizeIndex; i++) {
fields[(int) (i - beginPrizeIndex)] = i + "";
}
final String key = getGiftCombinationPrizePoolKey(gid);
// 去拿奖品
final List<String> list = JedisUtil.hmget(key, fields);
if (CollUtil.isEmpty(list)) {
return Collections.emptyList();
}
// 删除掉奖品,这里删掉只是因为用不到了,不删的话,其它线程也不会抽到这一批奖品的,因为抽奖索引早就跳过去了,所以不用担心上面的获取与这里的删除的原子问题,
// 也不会存在并发情况下其它线程获取到这里未删除的数据,这里都是通过索引的原子操作来避免这些问题的。
JedisUtil.hdel(key, fields);
// 因为计算出来的角标实际上不一定真对应上奖品,所以过滤掉,只返回有效的奖品数据
return list.stream().filter(StringUtils::isNotBlank).map(PrizeMetadata::fromPrizeMetadataStr).collect(Collectors.toList());
}
组合逻辑
// 递增礼物每日消耗
final Long giftDailyConsume = compositionStockBombPrizeRepository.incrByGiftDailyConsume(gid, bizTime, totalPrice);
// 得出中奖的奖品结果, 采用节省redis内存方式,这里只能返回中奖的奖品,如果未中奖则不会返回结果,存在prizeMetadataList.size < count的情况,
// 但是因为实现方式,也没办法区分出来是确实缺少的那部分次数没中奖,还是因为奖池原因。所以即使是奖池原因,也没办法去补偿用户权益了,只能尽量避免
// 后面是采用了提前刷库存的方式来避免这个问题,系统稳定运行情况下不会出现的,出现后瞬间可能会影响一些正在抽奖的用户,后续会再次正常
final List<PrizeMetadata> prizeMetadataList = compositionStockBombPrizeRepository.popPrize(gid, count);
// 返回的数据为空有两种情况,一种是这一批次都没抽到奖品,第二种情况是奖池空了。如果是奖池空了的话, 这里要兜底去刷奖池,注意这个兜底已经已经没法完全保证用户的权益了,下面会解释。
// 要注意一个点,上面一行的文字说明, 这个返回的奖品数据为空之后再判断奖池大小其实没有保证原子性, 再获取到的奖池大小已经不能代表本次抽奖
// 的那个瞬时数据的奖池情况了,所以,如果这个奖池的实现是等抽完之后再去刷奖池的话,下面这个写法就存在逻辑问题了,可能同时会有非常多的线程去重新刷库存奖池。
// 但是当前这个功能刷奖池的时机是在奖池消耗掉一部分之后的某个时机去刷的,所以奖池都是提前去刷的。有这个前提的话,下面这种写法就是兜底写法了,一般不会出现的的(除非把奖池库存
// 配置的特别小,但是抽奖的次数又特别大,这种非真实情况的数据不考虑,第二种就是提前刷库存的那个线程异常终止了,也会走到这个兜底)
// 然而无论如何, 如果是这个时候有用户来抽奖,再奖池没有刷新出来,用户是什么都抽不到的, 也无法判定是否要补偿,原因上面解释过
if (CollectionUtils.isEmpty(prizeMetadataList) && compositionStockBombPrizeRepository.isEmptyPrizePool(gid)) {
log.error("[组合库存幸运礼物]-奖池被抽空, 出现兜底刷库存情况,请核查异常情况, gid = {}, uid = {}, count = {}", gid, uid, count);
// 重新刷新全部奖池(这里是最终兜底方案,正常设计逻辑不可能会走到这的,除非全部奖池的库存数量还少于一次抽奖的count, 这在业务上是不可能的)
compositionStockBombPrizeReloadPool.execute(() -> refreshStock(gid));
// 如果真的出现上面极限兜底的情况的话, 会出现一个问题, 目前这种节省内存的方式,
// 就不能像之前在com.banban.qile.zhibo.game.provider.domain.stockbombprize.StockBombPrizeDomainService.bomb
// 这个方法中一样,可以准确判断出到底缺了几次抽奖机会判定,然后等待奖池刷完再去抽了。所以真出现了上面那个情况,没法判定到底缺了几次的奖池抽奖机会,只能舍弃了
}
// 不允许中出的奖品
List<PrizeMetadata> missList = new ArrayList<>();
for (int i = prizeMetadataList.size() - 1; i >= 0; i--) {
// 5000倍不满足条件要重新放入到奖池中
if (prizeMetadataList.get(i).isSupMultipleStock() && giftDailyConsume < giftConfig.getFiveThousandMultipleConsumeLimit()) {
missList.add(prizeMetadataList.get(i));
prizeMetadataList.remove(i);
}
}
// 需要补偿的奖品数量
int compensationSize = missList.size();
int maxTryTimes = 1000;
int currentTryTimes = 0;
while (compensationSize > 0) {
currentTryTimes ++;
final List<PrizeMetadata> currentBatchList = compositionStockBombPrizeRepository.popPrize(gid, compensationSize);
// 上面已经一次性获取了补偿数量的奖品,这里先临时清零补偿数量,如果后续没问题的话,这个循环就终止,说明补偿好了
compensationSize = 0;
for (int i = currentBatchList.size() - 1; i >= 0; i--) {
// 5000倍不满足条件要重新放入到奖池中
if (currentBatchList.get(i).isSupMultipleStock() && giftDailyConsume < giftConfig.getFiveThousandMultipleConsumeLimit()) {
missList.add(currentBatchList.get(i));
currentBatchList.remove(i);
// 拿到了还是不能用的奖品, 补偿数量重新开始累加
compensationSize ++;
}
}
// 详细解释上面已经说过了, 不过这里还出现的话,只能同步等待数据刷出来跳出异常了
if (CollectionUtils.isEmpty(currentBatchList) && compositionStockBombPrizeRepository.isEmptyPrizePool(gid)) {
refreshStock(gid);
}
if (CollUtil.isNotEmpty(currentBatchList)) {
prizeMetadataList.addAll(currentBatchList);
}
// 最大重试次数,避免死循环
if (currentTryTimes >= maxTryTimes) {
break;
}
}
compositionStockBombPrizeReloadPool.execute(() -> {
// 判断是否存在不允许中出的奖品,然后要重新放回到奖池中
if (CollUtil.isNotEmpty(missList)) {
for (PrizeMetadata metadata : missList) {
// 重新刷回奖池中
compositionStockBombPrizeRepository.rePushPrize(gid, metadata);
}
}
// 判断这一批抽中的是否有某个奖品打了需要刷库存的标识,如果有的话,就去刷库存,这样判断,是谁最后抽到了,谁去刷,就避免了竞争不知道该什么什么提前刷库存的问题
prizeMetadataList.stream().filter(PrizeMetadata::isLoadCombination).forEach(currentMetadata -> {
// 也是通过这里来保证奖池中永远都有数据, 但极限情况下如果刷的过程中出错或者任务呗拒绝,则可能会没有数据,所以不存在问题,
// 就算刷失败了,也会有极限兜底重刷全部奖池
// 如果因为抖动这个地方出了问题,系统走到了补偿刷库存的那里,那么如果没完全防住并发刷库存,就会存在提前多刷出来库存的问题,而
// 多刷出来库存带来的问题就是,这里也会存在多个要刷库存的奖品,所以奖池里的库存数量就降不下来了,不过这个问题不关紧要。
// 想要解决的话,就再维护一下系统里目前要刷奖池奖品的数量,只有为1时这里才去刷。
// 一般不要解决,在可控的数量内,这个刷新奖品的标识反而越多越好
refreshStock(gid);
});
});
// 总中奖倍数
int totalMultiple = prizeMetadataList.stream().mapToInt(PrizeMetadata::getMultiple).sum();