大型抽奖活动方案演进

804 阅读8分钟

不论是抽奖活动,还是秒杀活动,在业界都是一个老生常谈的问题,仿佛每个互联网公司都在搞秒杀,都在搞活动。其实本质上是同一个问题,即如何避免库存超发。但所处的场景可能不尽相同,比如有的是限时秒杀,涉及高并发问题;有的是长期的任务赏金,然后抽奖,并发度相对低。

因为这种需求太常见了,以至于网上对类似问题的讨论很多。本文会基于一个真实的抽奖场景,按照不断演进改善的关系描述几个方案,以及背后的思考。其中最后一个方案被应用在了我司某春节活动上,经受了大流量高并发的考验。

场景假设

假设我们的场景是一个长期的抽奖活动(一个月吧):

  1. 运营设置了N种不同的奖品,每种奖品的数量,以及每种奖品中奖的概率
  2. 用户通过做某些任务可以获得一次抽奖机会
  3. 拥有抽奖机会的用户可以参与一次抽奖,只可能抽中还有库存的奖品
  4. 所有用户抽中的奖品数,不能超过每个奖品种类的库存,即不能超发

问题分析

一般的抽奖活动在前端交互上会有特效,比如转盘,开箱什么的。这些花里胡哨的东西可以不管,对服务端来说就是提供一个抽奖接口。这个接口需要考虑防作弊风控,中奖概率计算,库存扣减等功能。我们从以下几个角度看看,我们提供的接口需要考虑哪些问题。

接口时效

正如前面说的,前端在用户抽奖时,会弹出抽奖特效,给用户一种抽奖在进行中的感觉。因此,抽奖接口的时效性显得并不那么重要。

并发请求

首先看不同用户之间的并发,即同时有多个用户发起抽奖。这种情况是很正常的,也是我们需要考虑的。所以在涉及到库存操作时,需要做并发控制,比如库存的获取,库存的扣减等。

接下来看相同用户间的并发,因为在抽奖场景中,用户可能会疯狂点击抽奖按钮,如果是秒杀场景,几乎所有人都会狂点。如果用户疯狂点击抽奖按钮会发生什么事呢?那就是服务端会收到这个用户多次的抽奖请求,如果每次抽奖请求都成功,就可能会导致重复发奖。所以我们需要考虑抽奖幂等,可能我们会和前端协商,在抽奖时回传一个token,表示同一次抽奖,服务端对同一个token的抽奖,发奖以及扣减库存要做幂等。但结合实际场景来看,我们没必要支持相同用户的并发请求,同一时间只要确保有一个请求进入抽奖逻辑即可,其他多余的请求直接拒绝掉。这样我们就不用考虑幂等的问题了。

方案演进

方案重点讨论的是并发场景下如何避免超发,其他的问题不在本文讨论的范围。

方案一 redis分布式锁+redis奖券+mysql乐观锁

flowchart TD
id1([抽奖])
id2[获取用户抽奖redis锁]
id3{成功获取锁?}
id4([返回用户未中奖])
id5(redis获取用户剩余抽奖劵数量)
id6{剩余抽奖券大于0?}
id7(redis decr奖券数量)
id8(根据中奖概率计算奖品)
id9{中奖?}
id10(select stock as cur_stock from stockDB where type=中奖奖品)
id11{奖品剩余库存cur_stock>0?}
subgraph 可考虑事务更新
id12(update stockDb set stock=stock-1 where type=中奖奖品 and stock=cur_stock)
id13([affected rows>0?])
id14("insert (用户,奖品,中奖时间) into db")
end
id15{插入成功?}
id16([返回用户中奖])

id1-->id2-->id3
id3--Yes-->id5
id5-->id6
id6--Yes-->id7
id7-->id8
id8-->id9
id9--Yes-->id10
id10-->id11
id11--Yes-->id12
id12-->id13
id13-->id14
id14-->id15
id15--Yes-->id16

id15--No-->id4
id13--No-->id4
id9--No-->id4
id11--No-->id4
id6--No-->id4
id3--No-->id4

这个方案在用户进行抽奖时加了一个用户维度的redis分布式锁,实际上后面的方案也是会用redis锁来处理,原因如下:

  1. 正常的用户不会同一时间发起多个抽奖请求,除非恶意用户
  2. 加锁能简化后面的逻辑,比如后面的扣减用户抽奖券,不需要在考虑并发
  3. 加锁能起到限流的作用,至少避免了同一个用户大量的并发请求,没有获取到锁的请求直接返回了

我们将用户未消耗的抽奖券存在了redis中,其实也就是一个数字,用户获得抽奖劵就incr,消耗抽奖劵就decr。后面的方案也是使用redis来存用户的抽奖券,原因如下:

  1. 用redis来记录抽奖券的数量操作上很简单,且是原子操作
  2. redis能扛住高并发,没有抽奖券的用户根本走不到后面的逻辑,因为后面会有db的操作,直接到db大概率扛不住

最关键的是奖品扣减库存如何避免超发。这里借鉴了mvcc的思想,先从db中查出中奖奖品的剩余库存,如果库存够再进行库存的扣减。所以这里要求我们事先将每个奖品的库存先写到db中。库存的扣减使用的就是mysql的update,限定奖品的库存必须和之前查出来的一致。如果在查库存和扣减库存这两个操作之间,奖品库存发生了变化,说明此时有其他用户也中了这个奖品。这种情况直接返回未中奖就好了。这样,我们就巧妙的利用了奖品库存值做到了数据的无锁更新。

这时候可能会有同学质疑说这种处理方式有问题,按照算法用户明明已经中奖了,只是在扣减库存的时候失败了而已,对用户是不公平的。其实没必要纠结这个点,对我们来说只要把奖品全部发出去就好了,当然这里说的是要发给正常用户,作弊的不算。至于发给谁,我们根本不关心。完全没必要纠结这个点,毕竟抽奖的随机算法也只是伪随机。

奖品库存扣减完,最后就是记录用户的奖品和中奖时间了,毕竟活动结束后总是要发奖的嘛。这个方案选择的是记录到db中。由于扣减奖品库存和记录中奖信息是两次db操作,所以这里是需要进行事务操作的,要么都成功要么都失败。否则,可能出现奖品库存扣减成功,但是用户的中奖信息没有被记录上。这种情况带来的影响就是奖品发不完,因为库存少了,而实际上奖品没有给到用户。当然这种影响对比库存超发来说影响小很多,所以在可接受的情况下,可以不加事务,只是做个监控点。在发生的时候手动修复一下库存,重新把库存加回去。

最后回顾一下这个方案,其实是有DB操作的。但是流量经过层层削减(1.获取到redis锁 2.有抽奖券 3.中奖),最终能打到DB的也只有中奖的流量。在大部分情况下,这个方案都是能适用的。但如果奖券发放很猛,中奖概率设置的很高,那么打到DB的流量就有可能很大。此时,我们需要考虑另外一种方案。

方案二 redis分布式锁+redis奖券+消息队列(MQ)

flowchart TD
id1([抽奖])
id2[获取用户抽奖redis锁]
id3{成功获取锁?}
id4([返回用户未中奖])
id5(redis获取用户剩余抽奖劵数量)
id6{剩余抽奖券大于0?}
id7(redis decr奖券数量)
id8(根据中奖概率计算奖品)
id9{中奖?}
id10("redis mget (奖品上限,奖品消耗)")
id11{奖品上限>奖品消耗}
id12{"奖品上限>redis.incr(奖品消耗)"}
id13("消息队列(MQ)上报用户中奖信息:(用户,奖品,中奖时间)")
id15([返回用户中奖])

id1-->id2-->id3
subgraph 单用户频控
id3--Yes-->id5
id5-->id6
id6--Yes-->id7
id7-->id8
end

subgraph 多用户并发
id8-->id9
id9--Yes-->id10
id10-->id11
id11--Yes-->id12
id12--Yes-->id13
id13-->id15
end

id12--No-->id4
id9--No-->id4
id11--No-->id4
id6--No-->id4
id3--No-->id4

方案二在涉及到奖品库存和消耗的地方都使用redis来操作。每个奖品在redis中会对应两个key,分别为此奖品的总数和此奖品当前的消耗。有的同学可能会比较疑惑,为什么在redis mget(奖品上限,奖品消耗)判断奖品是否已经消耗完成后,还要再奖品上限>redis.incr(奖品消耗)判断一次。这里其实是有并发的存在,比如奖品库存只剩一个的时候,有两个请求同时redis mget(奖品上限,奖品消耗)判断奖品是否还有库存时都成立了。然后都走到了后面的redis.incr进行奖品发放并且都发放成功了,此时就会导致奖品超发。但如果加上奖品上限>redis.incr(奖品消耗)这个判断,就利用了redis原子操作的特性,避免了奖品超发。

中奖信息的上报和方案一也是有区别的,这里用了mq来收集中奖信息。所以抽奖流程实际上是消息的生产者,这里还需要一个消费者来将中奖信息落库。本文就不详细描述这部分了。

综合来看,方案二全程都是redis操作,基本上能适用于所有场景了,并且实现简单,实施起来也容易。

总结

两个方案的优缺点不做总结了,上面的描述已经说的比较清楚。理解之后可以对方案做一些修改来适用自己的场景。最后是一些心得:

  1. 线上的业务逻辑,尤其是涉及到资产的业务(包括现金,非现金激励),不要设计的太灵活复杂!不要太灵活复杂!不要太灵活复杂!越“聪明”的方案,越容易出错
  2. 不要考虑多余的扩展性,不要问产品这里要不要预留扩展,那里要不要做成动态配置,问就是要
  3. 研发也要考虑用户体验和需求的合理性;对业务要有自己的理解和思考,不要做需求的翻译机器;多挑战产品的想法,但不要抬杠