基于本文回答

播面 播面

文图音视,全方位拆解八股文
0
评论

游戏中有一个“抽奖”功能,如何用Redis的Set实现“保证每个人只能中奖一次”和“按权重/概率抽取”

知识点图片

在Redis中,Set(集合) 自身具备“去重”的特性,非常适合用来实现“保证每个人只能中奖一次”。但是,Redis的原生 Set 并不支持“权重”概念(它里面的元素是无序且平权的)。

要同时实现这两个需求,业界通常有两种方案。第一种是“奖池预分配法”(充分利用Set),第二种是“代码权重计算+Redis Lua原子扣减”(最主流、最灵活)。

下面为你详细拆解这两种实现方案。


方案一:奖池预分配法(纯Redis Set实现,适合库存有限的抽奖)

这个方案的核心思路是:用数量来代替权重。既然Set不能设权重,那我们就把奖品按权重比例生成唯一凭证,全部扔进一个 Set 里。

1. 初始化奖池(模拟权重)

假设有三种奖品,权重/库存如下:一等奖1个,二等奖2个,未中奖(阳光普照)7个。
我们在 Redis 中通过加入带编号的元素来保证唯一性,存入名为 lottery:pool 的 Set 中:

plaintext
# 格式:奖品类型:唯一编号
SADD lottery:pool prize1:1 
SADD lottery:pool prize2:1 prize2:2
SADD lottery:pool none:1 none:2 none:3 none:4 none:5 none:6 none:7

此时,prize1 被抽中的概率是 10%,prize2 是 20%,未中奖是 70%。

2. 用户抽奖流程

我们需要另一个 Set 来记录已经中过奖的用户:lottery:winners

当用户(假设ID为 user:1001)来抽奖时,执行以下逻辑:

  1. 检查是否已参与/中奖:

    plaintext
    SADD lottery:winners user:1001
    • 如果返回 1:说明用户之前没中过奖,允许抽奖
    • 如果返回 0:说明用户已经在集合里了,直接拦截,提示“您已中过奖”。
  2. 从奖池中随机抽取:
    如果上一步返回 1,则从奖池随机弹出一个奖品:

    plaintext
    SPOP lottery:pool
    • 假设返回 prize2:1,说明中了二等奖。代码里截取 prize2 发放即可。
    • 假设返回 none:4,说明没中奖。如果是“不限制参与次数直到中奖为止”,你需要把该用户从 lottery:winnersSREM 删掉,让他可以继续抽;如果是“每个人只能抽一次,没中拉倒”,就不用管了。

优点: 逻辑极其简单,纯 Redis 命令实现,完美解决超卖问题。
缺点: 如果“未中奖”的概率是 99.99%,或者参与人数达到千万级,你不可能往 Set 里塞一千万个 none:xxx,会极其消耗内存。


方案二:应用层算权重 + Redis Lua 脚本保并发(大厂主流方案)

这是生产环境中最常用的方案。权重计算放在代码(应用层)里做,Redis 的 Set 仅用来做“用户唯一性”校验,Hash 用来扣库存。

1. 数据结构设计

  • 权重配置:放在代码的配置文件或数据库中(例如:一等奖 1%,二等奖 5%,未中奖 94%)。
  • 已中奖用户名单 (Set)lottery:winners
  • 奖品库存 (Hash)lottery:stock -> { "prize1": 10, "prize2": 50 }

2. 应用层:先按概率/权重“假抽”

当用户发起请求时,代码里先根据权重生成一个随机数,决定他中什么奖。

  • 比如代码计算出他中了 prize1
  • 注意:此时还没真正中奖,因为库存可能没了,或者他之前已经中过了。

3. Redis 层:Lua 脚本原子校验与扣减

把“查用户是否已中奖”、“查库存”、“扣库存”、“记录用户”这四步封装在一个 Lua 脚本中,交给 Redis 原子执行,绝不超发。

Lua 脚本 (draw.lua):

plaintext
local user_id = KEYS[1]
local prize_id = KEYS[2]
local winners_key = "lottery:winners"
local stock_key = "lottery:stock"

-- 1. 检查用户是否已经中过奖 (利用 Set 的 SISMEMBER)
if redis.call("SISMEMBER", winners_key, user_id) == 1 then
    return -1 -- 错误码:已经中过奖了
end

-- 2. 检查库存是否充足
local stock = tonumber(redis.call("HGET", stock_key, prize_id) or "0")
if stock <= 0 then
    return -2 -- 错误码:奖品被抽完了
end

-- 3. 扣减库存
redis.call("HINCRBY", stock_key, prize_id, -1)

-- 4. 将用户加入已中奖名单 (利用 Set 保证唯一性)
redis.call("SADD", winners_key, user_id)

return 1 -- 成功

4. 代码执行流程

  1. 用户 user:1001 请求抽奖。
  2. 业务代码根据权重算法(例如 alias method 或简单的随机数区间)算出他抽中 prize1
  3. 如果抽中的是“未中奖”,直接返回结果,结束。
  4. 如果抽中的是“一等奖”,Java/Go 代码调用上述 Lua 脚本,传入 user:1001prize1
  5. 解析 Lua 脚本返回值:
    • 返回 1:恭喜,真正中奖了!
    • 返回 -1:提示用户“您已经中过奖啦”。
    • 返回 -2:奖品刚才被别人抢光了,此时需要做降级处理(通常降级为“未中奖”或“阳光普照奖”返回给用户)。

优点:

  1. 内存占用极小(Set里只存真正中奖的人)。
  2. 支持无限多的参与人数和极端的概率设置。
  3. Lua脚本保证了高并发下的绝对安全(不会超卖,每个人绝对只中一次)。

总结与建议

  • 如果你的活动是一个小型闭门活动(比如公司年会,一共1000人,奖品总数也是1000个,抽完为止),直接用方案一。利用 SPOPSADD 极其简单高效。
  • 如果你的活动是一个互联网对外活动(比如双十一抽奖,千万级UV,奖品只有几百个,99%都是未中奖),必须用方案二。代码算概率,Redis Lua 脚本结合 SetHash 做最终的防刷防超卖拦截。
00:00
00:00