不能直接用sadd+srandmember做亿级抽奖,因srandmember在亿级set上时间复杂度近o(n)、不保证去重、无状态记录且易阻塞主线程;应采用分片set+sscan+lua原子脚本+异步落库方案。

为什么不能直接用 SADD + SRANDMEMBER 做亿级抽奖
直接用 SRANDMEMBER users 1000 抽一万人,在亿级 Set 上会阻塞 Redis 主线程,且结果不可控:重复抽中、无法去重校验、无中奖状态记录。更关键的是,SRANDMEMBER 底层是随机采样,当集合过大时,实际时间复杂度接近 O(N),不是常数时间。
真实场景要求:每个用户仅中一次、支持分批次开奖、可追溯中奖记录、不影响在线写入吞吐。
- 亿级用户数据不能全塞进一个
Set—— 内存占用高,单 key 过大易触发 Redis 阻塞或主从同步延迟 -
SRANDMEMBER不保证不重复(除非加count参数且远小于集合大小),但抽奖必须严格去重 - 中奖后要立刻标记状态,否则并发请求可能重复中奖
用分片 Set + SSCAN 渐进式遍历替代全量随机
把亿级用户按 ID 哈希分片到多个 Set,例如 lottery:users:0 ~ lottery:users:999,每片约百万级。这样单个 Set 大小可控,SSCAN 可分批迭代,避免阻塞。
抽奖逻辑不再依赖“一次性随机”,而是:遍历每个分片 → 对每个分片用 SRANDMEMBER 尝试抽若干个 → 检查是否已中奖(查另一个 Set 或 Hash)→ 成功则 SREM + SADD lottery:winners + 记录详情。
- 分片数建议取 100~1000,需权衡
SSCAN的游标管理复杂度和单Set大小 - 务必用
SSCAN而非SMEMBERS,后者会一次性加载全部成员,内存爆炸 - 每次
SSCAN设置COUNT为 1000~5000,太小遍历轮次多,太大单次耗时高 - 遍历过程中需用 Lua 脚本保证
检查-中奖-移除原子性,否则并发下仍可能重复中奖
用 Lua 脚本封装原子中奖逻辑,避免竞态
核心动作——“检查用户是否未中奖、若未中则标记并移出待抽池”——必须原子执行。Redis 单线程特性下,Lua 是唯一可靠手段。
Redis 缓存和数据结构管理技能。通过自然语言操作 Redis,支持 String、Hash、List、Set、ZSet、Stream 等数据结构操作。当用户提到 Redis、缓存、消息队列、会话存储时使用此技能。
local uid = KEYS[1]
local pool_key = KEYS[2]
local winner_key = KEYS[3]
if redis.call('SISMEMBER', winner_key, uid) == 0 then
redis.call('SREM', pool_key, uid)
redis.call('SADD', winner_key, uid)
return 1
else
return 0
end
调用时传入用户 ID、当前分片 key、中奖池 key。返回 1 表示成功中奖,0 表示已中过或不在池中。
- 不要在客户端做“先查再删”——网络延迟 + 多客户端并发必然导致重复中奖
- 脚本里禁止使用
redis.call('SRANDMEMBER', ...),因为SRANDMEMBER在 Lua 中不保证幂等,且无法控制是否已中奖 - 该脚本只处理单个用户,批量中奖需由外层循环调用,配合
SSCAN游标推进
中奖结果落库与一致性兜底怎么做
Redis 只做高速筛选,最终中奖名单必须落地 MySQL 或 TiDB。但不能等全部抽完再写库——失败重试成本高、无法实时展示。
推荐做法:每成功中奖 100 人,就异步批量写入数据库,并记录当前分片游标位置。若进程崩溃,可从最后游标恢复,避免漏抽或重抽。
- Redis 中的
lottery:winnersSet 仅作临时缓存,不作为唯一事实源;它可能因故障丢失,但数据库有完整记录 - 分片游标必须持久化(如写入另一个 Redis key 或 DB),否则重启后无法续抽
- 对已中奖用户,除了
SADD lottery:winners,建议额外用HSET lottery:detail:<uid> prize_id 123 draw_time 1717023456</uid>存明细,方便查单个用户 - 别忽略超时控制:整个抽奖任务设总超时(如 30 分钟),防止某一分片卡死拖垮全局
真正难的不是抽,是让每一次中奖都可验证、可回溯、可中断续抽。分片、游标、Lua、异步落库这四点缺一不可,少一个就会在千万级并发下暴露问题。










