抽奖系统的随机性和公平性,往往取决于参与名单的存储结构。Redis 的 Set 类型用哈希表实现,元素唯一、增删和随机抽取都能在 O(1) 到 O(n) 的时间复杂度内完成,非常适合做抽奖池。实现随机抽取主要依赖两个命令:SRANDMEMBER 和 SPOP,前者只读随机元素,后者会同时移除元素。理解这两个命令的差异,是设计抽奖逻辑的第一步。

假设已经有一批用户通过 SADD 加入了抽奖池,取一个随机用户只需一条命令。不过真正的抽奖服务还需要考虑并发、库存、重复中奖等细节,不能只停留在单条命令上。
一、理解 Set 随机抽取的两个核心命令
Redis Set 是无序集合,底层是哈希表,因此 SADD、SREM、SISMEMBER 等操作都很快。SRANDMEMBER 命令可以随机返回集合中的一个或多个元素,但默认不会删除这些元素,适合抽奖结束后用户仍然留在池子里的场景,比如抽取幸运观众但不排除其参与下一轮。
如果传入的 count 参数是正数,返回的元素不会重复;如果传入负数,则返回允许重复的元素列表。这一点容易被忽略。例如 SRANDMEMBER luckydraw:players -3 可能返回三个用户,其中同一个用户可能被抽到两次,这就很适合多位中奖者独立抽奖、允许同一个人获得多个奖项的情况。
SPOP 命令则是随机弹出一个或多个元素,同时把这些元素从集合中删除。它的典型用途是奖品具有唯一性,中奖者不能再参与后续抽奖。使用 SPOP 时集合本身会变小,不需要额外维护已中奖名单,但也要注意弹出的元素会丢失,需要及时把中奖结果写入持久化存储。
# 添加参与用户 SADD luckydraw:players user1001 user1002 user1003 user1004 user1005 # 只读随机抽取 1 个,不删除 SRANDMEMBER luckydraw:players 1 # 允许重复地随机抽取 3 个 SRANDMEMBER luckydraw:players -3 # 弹出 1 个中奖用户,并从集合移除 SPOP luckydraw:players 1 # 查看剩余人数 SCARD luckydraw:players
这两个命令都支持传入单个 key,单次调用是原子操作。但要注意,多个命令组合时,原子性需要额外保证。例如先 SCARD 判断人数,再 SPOP 抽取,中间可能被其他客户端插入或删除,导致判断失效。
二、用 SPOP 实现不重复抽奖与库存扣减
大多数抽奖活动要求同一个用户不能重复中同一个奖品,而且奖品数量有限。如果直接用 SPOP 弹出用户,再单独扣减奖品库存,这两步不是原子的,并发下可能出现奖品发超的情况。比如两个请求同时抽奖,都判断库存为 1,然后各自弹出一个用户,最终两个人都中奖,但库存只减了一次。
Lua 脚本是解决这类问题的常用手段。Redis 会以单线程执行 Lua 脚本,脚本内的多个命令不会被其他命令插入,能够保证查询、抽取、扣库存的整体原子性。下面这段脚本先弹出用户,如果弹出成功就扣减库存,并返回用户 ID;如果集合为空,则返回 nil。
-- KEYS[1] 是抽奖用户集合,KEYS[2] 是奖品库存 key
local user = redis.call('SPOP', KEYS[1])
if user then
local stock = redis.call('DECR', KEYS[2])
if stock < 0 then
-- 库存为负说明超卖,需要回滚
redis.call('SADD', KEYS[1], user)
redis.call('INCR', KEYS[2])
return nil
end
return user
else
return nil
end
脚本中还在库存扣减后做了一次回滚处理。因为可能出现奖品库存提前被其他业务扣成负数的情况,如果 DECR 后小于 0,就把已经弹出的用户重新加回集合,并恢复库存。这种防御性写法在生产环境很有价值。
如果抽奖规则更复杂,比如每个用户只能中奖一次、已经中奖的用户要写入另一个 Set,也可以在 Lua 脚本里用 SISMEMBER 检查中奖名单,避免把同一个用户重复弹出。这样整个抽奖流程仍然保持原子性。
三、可重复抽奖与业务规则设计的细节
有些活动不需要移除参与者,例如转盘抽奖、随机掉落福利、随机展示推荐内容。这种场景用 SRANDMEMBER 就足够了,它只读数据,不会改变集合结构。如果希望批量抽取多个用户,直接传入需要的人数即可。需要再次强调正数和负数参数的区别:正数返回不重复结果,负数返回可能重复的结果。
举个例子,抽奖活动要随机选 10 名用户发放阳光普照奖,但允许同一个用户多次被抽中,那么可以调用 SRANDMEMBER luckydraw:players -10。如果每位用户最多只能中一次,就用 SRANDMEMBER luckydraw:players 10。这两个用法从命令层面已经天然区分了业务语义。
实际开发中,抽奖服务不会只依赖 Redis 命令,还要在应用层做数据备份与结果确认。任何一次 SPOP 之后,中奖用户的信息已经从集合消失,一旦下游发奖失败或者需要人工核对,就必须有持久化记录。建议在抽奖成功后立刻把中奖用户写入 MySQL 或日志队列,最好在同一个事务或至少是可靠消息机制里完成。Redis 的 AOF 持久化也能提供一定保障,但不能代替业务数据落库。
此外,如果抽奖池的用户量非常大,比如百万级,SRANDMEMBER 和 SPOP 的性能依然可以接受,因为它们的时间复杂度是 O(n),其中 n 是返回元素数量,而不是集合大小。但要避免频繁执行 SMEMBERS 拉全量名单到客户端再随机,那样会阻塞 Redis 并占用大量内存。
四、Java 客户端调用与并发控制
以 Java 的 Jedis 客户端为例,调用 Lua 脚本的代码非常直接。脚本的 key 数量和参数需要和脚本中 KEYS 一致,下面示例传入两个 key:抽奖集合和库存 key。返回结果为字符串时就是中奖用户,为 null 时说明抽奖失败。
import redis.clients.jedis.Jedis;
public class LuckyDraw {
private static final String DRAW_SCRIPT =
"local user = redis.call('SPOP', KEYS[1]) " +
"if user then " +
" local stock = redis.call('DECR', KEYS[2]) " +
" if stock < 0 then " +
" redis.call('SADD', KEYS[1], user) " +
" redis.call('INCR', KEYS[2]) " +
" return nil " +
" end " +
" return user " +
"else " +
" return nil " +
"end";
public String draw(Jedis jedis, String poolKey, String stockKey) {
Object result = jedis.eval(DRAW_SCRIPT, 2, poolKey, stockKey);
return result == null ? null : result.toString();
}
}
如果使用 Spring Data Redis 或 Lettuce,也可以用 DefaultRedisScript 封装后执行,思路一致。并发控制的核心在于把多个 Redis 命令塞进同一个 Lua 脚本,而不是依赖客户端侧锁。客户端分布式锁虽然也能防止并发超卖,但会引入锁超时、续期、释放失败等问题,复杂度更高。
最后提醒一点:如果奖品有多个等级,可以在 Redis 中为每个等级维护一个 Set,用户 ID 可以同时出现在不同等级的池子里,但同一个等级的抽取要使用不重复命令或 Lua 脚本加以约束。不要为了省事把所有奖品混在一个 Set 里,否则后续统计和风控会非常麻烦。

