游戏中有一个“抽奖”功能,如何用Redis的Set实现“保证每个人只能中奖一次”和“按权重/概率抽取”
在Redis中,Set(集合) 自身具备“去重”的特性,非常适合用来实现“保证每个人只能中奖一次”。但是,Redis的原生 Set 并不支持“权重”概念(它里面的元素是无序且平权的)。
要同时实现这两个需求,业界通常有两种方案。第一种是“奖池预分配法”(充分利用Set),第二种是“代码权重计算+Redis Lua原子扣减”(最主流、最灵活)。
下面为你详细拆解这两种实现方案。
方案一:奖池预分配法(纯Redis Set实现,适合库存有限的抽奖)
这个方案的核心思路是:用数量来代替权重。既然Set不能设权重,那我们就把奖品按权重比例生成唯一凭证,全部扔进一个 Set 里。
1. 初始化奖池(模拟权重)
假设有三种奖品,权重/库存如下:一等奖1个,二等奖2个,未中奖(阳光普照)7个。
我们在 Redis 中通过加入带编号的元素来保证唯一性,存入名为 lottery:pool 的 Set 中:
# 格式:奖品类型:唯一编号
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)来抽奖时,执行以下逻辑:
检查是否已参与/中奖:
plaintextSADD lottery:winners user:1001- 如果返回
1:说明用户之前没中过奖,允许抽奖。 - 如果返回
0:说明用户已经在集合里了,直接拦截,提示“您已中过奖”。
- 如果返回
从奖池中随机抽取:
如果上一步返回1,则从奖池随机弹出一个奖品:plaintextSPOP lottery:pool- 假设返回
prize2:1,说明中了二等奖。代码里截取prize2发放即可。 - 假设返回
none:4,说明没中奖。如果是“不限制参与次数直到中奖为止”,你需要把该用户从lottery:winners里SREM删掉,让他可以继续抽;如果是“每个人只能抽一次,没中拉倒”,就不用管了。
- 假设返回
优点: 逻辑极其简单,纯 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):
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. 代码执行流程
- 用户
user:1001请求抽奖。 - 业务代码根据权重算法(例如 alias method 或简单的随机数区间)算出他抽中
prize1。 - 如果抽中的是“未中奖”,直接返回结果,结束。
- 如果抽中的是“一等奖”,Java/Go 代码调用上述 Lua 脚本,传入
user:1001和prize1。 - 解析 Lua 脚本返回值:
- 返回
1:恭喜,真正中奖了! - 返回
-1:提示用户“您已经中过奖啦”。 - 返回
-2:奖品刚才被别人抢光了,此时需要做降级处理(通常降级为“未中奖”或“阳光普照奖”返回给用户)。
- 返回
优点:
- 内存占用极小(Set里只存真正中奖的人)。
- 支持无限多的参与人数和极端的概率设置。
- Lua脚本保证了高并发下的绝对安全(不会超卖,每个人绝对只中一次)。
总结与建议
- 如果你的活动是一个小型闭门活动(比如公司年会,一共1000人,奖品总数也是1000个,抽完为止),直接用方案一。利用
SPOP和SADD极其简单高效。 - 如果你的活动是一个互联网对外活动(比如双十一抽奖,千万级UV,奖品只有几百个,99%都是未中奖),必须用方案二。代码算概率,Redis Lua 脚本结合
Set和Hash做最终的防刷防超卖拦截。