广告位 · 页头(后台-模块管理-新建模块后替换此区域)

如何用Redis Set实现高效抽奖随机抽取?

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

如何用Redis Set实现高效抽奖随机抽取?

假设已经有一批用户通过 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 里,否则后续统计和风控会非常麻烦。

广告位 · 文章底部(后台-模块管理替换此区域)