搜狐技术产品

高性能抽奖系统如何设计

Image

01

前言

不知道你在面试的过程中,有没有被问到如何设计一个分布式抽奖系统?或者你很好奇,线上的这些抽奖活动,背后的原理是什么?这个项目本身并不大,但却涉及很多经典的技术点,因此也在面试中比较常见。

本文根据自己最近做的一个体彩的抽奖活动,讲解了我在设计分布式抽奖系统的几个核心难点——匀速消耗、超卖、少卖等等,希望可以帮助同学们可以在面试中,更加从容地回答这些问题。

ͼƬ1.png

02

抽奖系统的难点

难点一:均匀消耗!!

体彩为了持续导量,抽奖活动会持续大概两周的时间。因此如果奖品在很短的时间内被消耗完,后面的玩家无法再抽中奖励,那就会导致用户体验差,活动到后期没人气,最后必定会找开发背锅。

产品和运营诉求很朴素:希望可以在运营周期内,既要保证都有奖品可抽,同时又要希望满足流量高时多发奖,流量低时少发放奖。

Image
难点二: 防止超卖!!

即同一个奖品发给了不同的用户,在高并发场景下,很可能多个客户同时抽奖,如果不做并发控制,多个客户同时抢到了同一个一等奖。

本来预算已经买好了50个智能音箱,结果你写错程序直接发出去500个。你发不发货,不发货用户直接投诉体彩,出货那么这些钱从哪里出?

最后怎么办,只能让开发进行复盘总结大会,被产品、运营、Leader轮番炮轰。

Image
难点三:不要少卖!!

卖多了不行,卖少了也得背锅。假如奖品10000份,最后奖品却只送出去5000份。

后果是什么?客户会质疑你们的这个平台,说连这点奖品都没有发完,你们平台到底有没有流量?想多给你钱你却接不住,以后还能不能愉快的合作了?最后运维一查,不对啊,我明明导入了100w的用户,奖品还剩下一半,谁来解释下?最后就是:

Image

03

解决方案

上面的问题列出来了,那么怎么设计,以及怎么解决这些问题,就是接下去要考虑的了。我会从我设计的抽奖系统,给大家介绍这几个问题、难点,以及最后如何解决。

问题一:如何保证奖品按照我们设定的速度发放?

固定定速率发放算法,是实现均匀发放最可靠的方法。其核心思想是:将一天的活动时间均匀分割成若干个时间片,每个时间片分配固定数量的奖品。

实施步骤:

划分时间片:将活动总时长(如24小时)除以奖品总数,得到每个奖品对应的“时间片”长度。例如,100个奖品发放24小时,则每个时间片长度为14.4分钟。
确定发放点:在每个时间片内,随机选取一个时间点作为该奖品的确切发放时刻。例如,在第一个14.4分钟内,随机选取第5分钟作为发放点。这增加了不确定性,避免奖品在时间片一开始就被抢光。
如果上一个时间点没有发放成功,则累计到当前时间点。例如上一个时间点奖品未发放,则这个时间点发放数量是2个,即随机两个时间点发放?
判断中奖:当用户抽奖时,系统检查当前时间点是否达到了当前时间片内预设的奖品发放时刻。如果达到,则用户有50%概率中奖(基础概率可配置);否则不中奖。

优点:这种方法能从根本上保证奖品均匀分布在全天,不会因瞬时高并发或随机概率波动导致奖品过早被抽完。

缺点:发放速度一定,每个时间片发放的奖励数量是一样的。当流量很高时,获奖概率会很低。当流量很低时,获奖概率会很高。

那么这个缺点,如何解决呢?每日的流量曲线,我们根据历史的数据,基本可以预测。比如每天那个时间点是流量高峰,那个时间点是流量低值,以及节假日、周六日、工作日的差别。

改进版:取消匀速发送,通过配置指定每个时间片发放奖励的数量。例如晚上7-10点,配置发放比例为奖品总量的50%。通过支持读取配置热更,每天发放的奖品总数量可配置、每个时间段发放的数量可配置,解决流量高概率低的问题。

注意这里是根据的历史流量值,预估的数据,如果流量忽然比预期的高很多或者低很多,该如何处理?答案是还是按照配置执行,除非运营人员希望修改配置。这里不要使用什么算法模型,运营周期比较短,算法模型很难预估的准,这种场景需要百分之百可靠,不要把问题复杂化,大道至简,解决问题最重要。

好了,奖品的发放算法讲完了,那么接下来看我们如何来实现?
既然奖品是每个时间点,出一个,这不就是我们程序中常用的队列吗。

设计队列中的奖品内容格式:1763245513734_2,"_"前面是时间戳、后面是奖励类型(1、一等奖,2、二等奖、3、三等奖),在单个奖励池内,奖励位置整体均匀、时间片内随机。

Image

这里我们的队列,按照小时维度进行拆分。有两个好处,1 是我们可以很方便的查看每个小时的奖品处理情况;2 是当需要热更新时,每个队列因为很小,可以快速更新,减少锁竞争。

这些奖品的分布,在抽奖活动开始前,就可以通过离线任务,把这些奖品信息生成了。等抽奖活动开始时,直接操作这些生成好的数据结构就好了。具体的奖励配置,可以参考如下格式:

version: 1 # 当前版本号
prize_date: 20251127 # 奖励日期
prize_num: 564 # 总数量
prize_list:
  - prize_type: "fridge" # 冰箱
    total: 188 # 数量
    distributions: 
      - [0,0,0,0,0,0,0,0]           # 0-7点   奖品分布
      - [0,16,16,16,16,16,16,12]    # 8-15点  奖品分布
      - [12,12,12,12,12,12,6,2]     # 16-23点 奖品分布
  - prize_type: "earbuds" # 耳机 
    total: 188 # 总数量
    distributions: 
      - [0,0,0,0,0,0,0,0]           # 0-7点   奖品分布
      - [0,16,16,16,16,16,16,12]    # 8-15点  奖品分布
      - [12,12,12,12,12,12,6,2]     # 16-23点 奖品分布
  - prize_type: "phone" # 手机
    total: 188 # 总数量
    distributions: 
      - [0,0,0,0,0,0,0,0]           # 0-7点   奖品分布
      - [0,16,16,16,16,16,16,12]    # 8-15点  奖品分布
      - [12,12,12,12,12,12,6,2]     # 16-23点 奖品分布
 问题二:如何避免超卖?

理解超卖之前,我们先看下抽奖流程:

• 首先通过获取时间戳,来获取当前小时对应的奖池KEY:prize_20251113_hour

• 通过key获取奖池列表的第一个奖品,并比较当前时间和该奖品配置时间

• 如果当前时间小于奖品配置时间,则没有中奖

• 反之,则用户有50%概率中奖(基础概率可配置),如果用户中奖,把该商品从奖励池中取出

• 把用户中奖信息,写入到数据库中

这里就涉及一个问题,在高并发场景,同一时间有很多用户抽奖,大家都同一时间拿到了到期的奖品,一对比奖品时间到了,都获取了同一个奖励,超卖了,咋办?

大家肯定想到了要用事务,但我们可以看到,整个流程比较长,如果用传统的关系型数据库,性能很难扛得住,而且也不支持队列这种数据结构。这就需要关系型的数据库的兄弟,非关系内存数据库Redis来抗了!

Redis 事务的三种核心实现:基础事务(MULTI/EXEC)、Lua 脚本、Pipeline,其中 Lua 脚本是强事务最优解,Pipeline 更偏向效率优化。

lua脚本保证原子性!!

• 中奖操作涉及两个redis操作(判断和弹出),需要通过lua脚本操作,保证原子操作,预防同一个奖品卖给不同的用户

• 使用lua脚本预加载的方式,服务启动时加载到redis集群,减少后续操作的网络

• 单次命令执行耗时500us,单实例支持QPS=2000

• 如果QPS2000达不到要求,可以把队列拆分到不同的redis实例,利用redis集群来提高并发性能

// 检查队列的第一个元素,提取元素中第一个下划线(_)前的部分,与传入的阈值(threshold)比较。
// 若前者大于后者,则弹出并返回该元素;否则(或列表为空、元素格式不符)返回 nil。
const luaScript = `
local listKey = KEYS[1]
local threshold = ARGV[1]
local firstElem = redis.call('LRANGE', listKey, 0, 0)[1]
if not firstElem then
    return nil
end
local firstPart = string.match(firstElem, "^([^_]+)")
if not firstPart then
    return nil
end
if firstPart > threshold then
    return redis.call('LPOP', listKey) -- 从左开始出栈
else
    return nil
end
`

好了,通过redis+lua脚本,我们保证了同一个奖品只能属于一个用户,防止了超卖。但这时,运营又提出了一个新的要求,一个用户只能中一次奖!!!

Image

正常情况下,我们在用户抽奖时,判断一下该用户是否已经中奖,如果中奖了,就直接返回即可。但是这只能防君子,不能防刷客!

如果用户采取作弊手段同时发起多个请求,当其中有一个该用户的请求中奖了,但是该用户的中奖信息,还没有来得及写入到数据库中,此时恰好又收到该用户的另一次抽奖请求,那这时,用户就可以同时获取两个奖品。

有同学可能说啊,这个我会,前面刚讲了,放到事务里面不就可以了。
但是如果再往lua中添加逻辑代码,会导致逻辑复杂,耗时增加,别忘了这个lua脚本是个全局竞争的,也就是如果同一时间又多个用户请求,只有一个用户会执行该lua脚本,其它用户都需要等待。如果lua脚本逻辑越来越多,那么用户等待的时间会越来越长,用户体验受损。

我们都知道,缩小锁的锁范围,会极大提高性能。判断单个用户是否已经中奖,其实跟其他用户没有关系,只跟自己有关,我们是不是只让自己的请求进行等待就可以了?这就是能自己做了,不麻烦别人!

此时就用到了redis经典分布式锁SETNX,它是一个原子性命令,只有当指定的 key 不存在时,才会为该 key 设置值;如果 key 已经存在,该命令不会做任何操作。key我们就设置为用户的账户即可,当用户抽奖时,先判断用户的账号key是否已经在抽奖,如果在抽奖,直接返回失败即可。这样我们就实现了只影响自己,不影响他人,完美!

// 一条命令保证原子性
127.0.0.1:6379> SET lock_15001323xxx 1 EX 300 NX
// 奖品获取完毕后,进行删除
127.0.0.1:6379> Del lock_15001323xxx

当然,setnx有些细节,比如锁过期、释放别人的锁,详细可以参考Java的Redisson库。

我们这里由于key是用户的账号,所以不存在释放别人锁的问题,同时又极大减少了锁的范围。另外针对锁过期,设置锁的过期时间为5分钟,整个执行过程耗时毫秒级,不会存在锁过期的场景。即使极小的概率真的发生,整个流程都有日志记录,可以协商运营进行处理。所以不必为了技术而技术,快速满足需求即可。

问题三:如何避免少卖?
其实看前面的固定速率算法,奖品的时间到了,那么这个奖品就会被发放,因此这个算法基本不会出现奖品少卖的情况。不过有一个场景,那就是真的没有一个用户过来抽奖!如果最后没有用户过来,那这个锅,再怎么甩,都甩不到程序员头上了。
Image

可是上面的奖池分小时的设计,即每小时的奖励是一个独立的奖池队列。如果刚好有一个奖品的到期时间,被设置到了某个小时的最后一秒内最后几毫秒,正好几毫秒,没有用户抽奖,那么还是有概率,奖品没有发出去,留在了上一个小时的队列里面。新的小时开启后,旧的小时奖品队列就被遗弃了。

而且假如运营时间是30天,那么一共就是24*30=720个队列。如果每个队列有一个,那么积累下来,可也是个不小的数据量。怎么办???

剩余奖励更新策略!!

• 每5分钟执行一次定时任务,回溯历史奖品,预防奖品少卖

• 往前回溯8个小时之前的奖池数据(时间可以配置)

• 检测奖池数据,查看是否有剩余奖励未发放

• 未发放奖品从该奖池弹出,并压入当前小时的奖池中。逐个执行,因单个redis命令是原子操作,无并发问题(中间失败会回滚操作)

Image

通过回溯的策略,前一个小时剩余的奖品,会被压入到了当前小时的队列中。因为奖品的时间已经到了,因此只要有用户抽奖,必然会被发放,有效解决了由于上个时间段剩下奖品,导致奖品少卖的问题。

可能这时又有用户问了,那正好最后一个奖池,剩下奖品呢?后面没有队列可以压入了,怎么办?
这种场景就需要跟运营或者产品商量一个策略,一般抽奖写的结束时间比如是下午6点,但实际配置的奖品只到下午5点,后面时间段,就已经不安排奖品。其实到后期运营也不会导量了,活动都快结束了,抽不到也正常了,奖品抽完了嘛。

04

总结

前面文章基本介绍清楚了抽奖系统的三个核心难点,以及相应的解决方案。通过这样的方案设计计,基本能撑住一个完整的抽奖流程,能保证奖品的安全、稳定、可控的发放。

最后大家再看看这个抽奖系统或许会有新的感悟,是不是一个系统真的没有大家想的那么简单,而且我还是有漏掉的细节,比如限流、redis分布式集群主从同步、读写分离,但这些我认为是很独立的模块,在各个业务系统中,都可能会用到,而且每个都会涉及很多的技术点,非一篇文章能讲清楚的,我这里只讲抽奖这些技术点即可。

好了,就写到这里吧,希望本文对大家技术上有帮助,祝大家面试成功!