Redis 高并发应用场景(二):直播福袋
上一篇介绍了 Redis 在榜单、直播点赞和新闻热点中的应用,涉及排序、计数和热点访问。这一篇以直播福袋为例,看看 Redis 如何支撑一场活动中的高并发报名、候选资格维护和开奖。
业务分析
福袋的流程很简单:主播发起活动,用户参与,倒计时结束后选出中奖用户。但在热门直播间,压力会集中在几个时刻:活动发布后,大量用户同时报名;开奖时,需要从几十万参与者中选出少量赢家;开奖后,用户又会集中查询结果。下面沿着这条链路,介绍候选池选型、数据分片和开奖算法。
关键设计
前一篇的榜单使用 ZSet 维护业务分数,再通过分片 Top-N 合并得到全局排名。福袋也可以沿用这一思路:参与时为用户生成随机分数,开奖时选出有效候选中分数最高的 N 人。
-
Set 保存通过入池筛选的参与名单,用于成员判断。
-
ZSet 维护候选分数与资格,供资格变更和开奖使用。
-
String 保存用户参与状态和个人中奖结果,供用户查询。
-
Redis 部署方式根据容量、吞吐和可用性要求选择。单主能够承载时,可采用主从复制,并通过 Sentinel 实现自动故障转移;需要横向扩展数据和写入压力时,再采用 Cluster。Cluster 按 Key 分配槽位,单个大集合仍需要在业务层拆分。
-
候选池按
shard = userId & 15拆成 16 个逻辑分片,同一活动的用户分散到 16 个集合 Key 中。逻辑分片数不等于物理节点数;Key 分配到不同 Redis 主节点后,才能分摊节点上的写入压力。 -
Set 与 ZSet 的选型考量:
-
Set 支持
SRANDMEMBER随机取样。但本场景还要维护任务、离房等候选资格;如果资格单独保存,抽样后还需要检查和补抽。使用 ZSet 可以把随机分数与资格标记放在同一个候选池中,开奖时按有效分数范围读取各分片的 Top-N。 -
ZSet 按用户 ID 去重,用随机分数和资格标记维护候选池。开奖时从每个分片读取 Top-N,再合并得到全局 Top-N。
-
用户参与状态和个人中奖结果分别使用独立的 String Key,均按“活动 ID + 用户 ID”组织。这样可避免个人查询集中访问同一个活动集合;是否分散到不同节点,还取决于实际 Key 路由。
-
技术链路解读
文中 Redis Key 的 <...> 表示变量占位符,替换为具体值后不保留尖括号;本文所有 Key 均不使用 Redis Cluster Hash Tag。例如 lb:candidate:<activityId>:<shard> 对应实际 Key lb:candidate:12345:7。
主播发起福袋
主流程
主播提交奖品、中奖名额、参与条件和倒计时
│
▼
① 准备配置并开启活动
按需保存参与任务、预留奖品资源
MySQL 事务:保存活动与奖品配置
审核通过或免审 → 待开启 → 开启
检查房间活动冲突,确定 startAt、drawAt
MySQL:更新活动状态,同步运行所需数据
│
▼
② 更新 Redis 缓存
SET lb:activity:<activityId> 活动详情
HSET lb:room:<roomId> activityId 1
标记该活动在房间中进行
│
▼
③ 通知展示与调度开奖
├─ 活动开始 MQ → 消费者 → 房间 IM
│ 展示入口和倒计时
└─ 延迟开奖 MQ → 到期消费 → 开奖链路
定时扫库补偿到期未开奖的活动
步骤说明
① 准备配置并开启活动
MySQL 保存活动名额、任务关联、倒计时、状态及奖品配置。审核通过或按配置规则免审后,活动进入待开启状态;开启时确定实际开始、开奖时间,更新活动状态并同步运行所需数据。
② 更新 Redis 缓存
| Redis Key | 操作 | 作用 |
|---|---|---|
lb:activity:<activityId>,String |
创建后、开启时 SET 活动 JSON |
保存活动配置与当前状态,供后续查询使用 |
lb:prize:<activityId>,Hash |
创建后 HSET prizeId 奖品JSON |
按活动保存奖品配置 |
lb:room:<roomId>,Hash |
开启时 HSET activityId 1 |
保存房间与活动关系;开奖后将该字段更新为 0 |
③ 通知展示与调度开奖
活动开始 MQ 由下游消费后推送房间 IM;延迟开奖 MQ 按 drawAt - 当前时间 计算延迟,定时任务负责补偿到期未开奖的活动。
用户参与福袋
主流程
用户点击参与,提交活动、用户、设备及任务信息
│
▼
① 同步登记参与请求
任务系统:按配置同步上报参与任务
启动后台任务,投递参与 MQ
Redis:SET lb:join:request:<activityId>:<userId> 1 EX 1800
│
├─ 返回前端:参与请求已受理
│
└─ 后台:参与 MQ → 消费者
│
▼
② 异步校验与去重
Redis:SET lb:join:lock:<activityId>:<userId> token NX EX 3
本地缓存 / Redis:读取活动、奖品和任务配置
MySQL 主库:SELECT 用户在该活动下的参与记录
已有记录则跳过重复插入
│
▼
③ 保存参与记录与用户状态
MySQL:INSERT participation
保存用户、活动、任务完成状态、设备及幂等标识
Redis:SET lb:user:state:<activityId>:<userId> 状态JSON EX 86400
│
▼
④ 写入分片参与名单与候选池
shard = userId & 15,共 16 片
score:任务完成 → 随机分;未完成 → -2
通过入池筛选后:
SADD lb:partake:<activityId>:<shard> userId
ZADD lb:candidate:<activityId>:<shard> score userId
EXPIRE 两个 Key:剩余开奖时间 + 7 天
│
▼
⑤ 更新人数与处理标记
Redis:选择一个计数 Key 执行 INCR
Redis:SET lb:join:done:<activityId>:<userId> 1 EX 1800
参与状态 MQ → 下游更新用户端展示
步骤说明
① 同步登记参与请求
lb:join:request:<activityId>:<userId> 保存请求标记,有效期 30 分钟。异步记录尚未写入时,查询接口可结合该标记和任务配置维持“已参与”的展示。
这里的“已参与”是请求受理后的展示状态。最终参与记录和候选资格仍由异步处理结果决定,不能把入口返回等同于用户已经进入有效候选池。
② 异步校验与去重
消费者校验活动状态、时间和房间,判定任务状态。Redis 用户活动锁有效期 3 秒,减少同一用户并发处理;MySQL 主库查询已有参与记录,数据库重复键错误也按已处理返回。
③ 保存参与记录与用户状态
MySQL 参与表保存用户、活动、任务完成状态、设备信息及幂等标识。
Redis lb:user:state:<activityId>:<userId> 保存记录 ID、任务完成标记等聚合状态,有效期 24 小时,供用户查询使用。详细子任务进度由任务系统维护。
④ 写入分片参与名单与候选池
两个集合均按 userId & 15 分为 16 片:
| Redis Key | 操作 | 作用 |
|---|---|---|
lb:partake:<activityId>:<shard>,Set |
SADD userId |
保存通过入池筛选的参与用户,后续用 SISMEMBER 判断用户是否在参与名单里 |
lb:candidate:<activityId>:<shard>,ZSet |
ZADD score userId |
保存候选分,供资格变更和开奖使用 |
普通随机分通过安全随机数生成:
r = [0, 999999] 内的随机整数
score = r / 1000000
无任务,或任务状态为已完成时,生成随机分;未完成时,score = -2。
参与计数超过 70 万后,仅 score ≥ 0.99 的用户写入参与 Set 和候选 ZSet,限制候选池规模。未通过筛选的用户仍保存 MySQL 参与流水和 Redis 用户状态,参与人数仍加一。
为什么超过 70 万后,只允许 score ≥ 0.99 入池?
目的是控制 Redis 大 Key 的增长。参与人数超过 70 万后,对生成均匀随机分的新增用户,仅允许 score ≥ 0.99 的用户入池。约 1%的新增随机评分用户能通过筛选,从而减少参与 Set 和候选 ZSet 的写入量,减缓存储增长。
已有候选继续保留,高分用户也能继续加入,因此候选人数仍可超过 70 万。例如,理想情况下再增加 40 万个随机评分用户,约有 4,000 人通过分数筛选。
对于按分数从高到低取 Top-N 的开奖分支,这样裁剪还有算法上的理由:
- 假设前 70 万人都生成均匀随机分,
score ≥ 0.99的人数期望约为 7,000。 - 普通单奖品的中奖名额上限为 1,000。
- 只要开奖时有效高分候选足够,低于
0.99的新增用户就不会进入最终 Top-N,提前过滤可以减少候选池负担。
7,000 是理论期望,不是保底人数。 这一估算以 70 万个均匀随机评分用户为前提,实际有效高分人数可能因离房等资格变化而减少。容量阈值和分数门槛应结合活动规模、中奖名额及压测结果确定。
⑤ 更新人数与处理标记
每次只选择一个 Key 执行 INCR,计数分片编号为 countShard = userId % 6:
| 参与计数 | 计数 Key |
|---|---|
| ≤ 阈值 T | lb:count:<activityId> |
| > 阈值 T | lb:count:<activityId>:shard:<countShard> |
T 为参与人数计数的拆分阈值,由配置控制,默认 10 万。读取人数时,汇总基础 Key 和所有计数分片 Key 的值。
候选资格维护
参与记录与候选资格分别保存。进离房流程调整候选分数,参与记录和累计人数保持原值;是否允许回房恢复资格,由活动规则决定。
用户离房
主流程
核心动作是:把用户在候选 ZSet 中的分数改为 -1,标记离房,而不是删除用户或撤销参与记录。
用户离开直播间
│
▼
① 离房事件异步消费
房间系统 → EventBus → 离房事件消费者
│
▼
② 定位进行中的活动
本地缓存优先;未命中时读取 Redis:
HGETALL lb:room:<roomId>
筛选状态为 1 的活动
GET lb:activity:<activityId>
确认活动进行中且未到开奖时间
│
▼
③ 读取用户候选状态
shard = userId & 15
Redis:ZRANK lb:candidate:<activityId>:<shard> userId
成员不存在 → 结束
Redis:ZSCORE 同一个 Key userId
score 为 -2 或 -1 → 结束
│
▼
④ 保存离房前的分数(可选)
Redis:SET lb:candidate:stored:<activityId>:<userId>
原score EX 剩余开奖秒数
注:要求参与后一直待在直播间,则省略此步骤
│
▼
⑤ 标记用户已离房
Redis:ZADD lb:candidate:<activityId>:<shard> -1 userId
覆盖分数,保留成员及原有 TTL
步骤说明
② 定位进行中的活动
lb:room:<roomId> 保存房间与活动的关系,读取值为 1 的活动;lb:activity:<activityId> 保存活动详情,用于确认当前状态。
活动切流识别的反查在缓存未命中时可能读取 MySQL 活动表;候选资格更新不写 MySQL 参与表。
③ 读取用户候选状态
沿用参与时的 16 分片,对 lb:candidate:<activityId>:<shard> 依次执行:
| 操作 | 作用 |
|---|---|
ZRANK userId |
确认用户已在候选池中,后续只更新已有成员 |
ZSCORE userId |
读取当前分数;任务未完成(-2)或已经离房(-1)时跳过 |
④ 保存离房前的分数(可选)
lb:candidate:stored:<activityId>:<userId> 是 String,保存用户离房前的随机分,供回房时恢复。TTL 为剩余开奖时间,开奖后自动过期。
要求参与后持续留在直播间时,省略这一步。
⑤ 标记用户已离房
通过 ZADD … -1 userId 覆盖候选分数,使离房期间的用户被开奖查询排除。候选成员及原有 TTL 保留。
用户进房
主流程
进房事件不会自动报名。 若活动要求参与后持续留在直播间,离房后被标记为 -1 的分数不会因回房而恢复。若活动允许回房恢复资格,则可以读取保存的原随机分进行恢复,避免反复进离房重新抽分。
以下流程展示允许恢复资格时的可选设计,是否启用由活动规则决定。
用户重新进入直播间
│
▼
① 进房事件异步消费
房间系统 → EventBus → 进房事件消费者
│
▼
② 定位进行中的活动
本地缓存优先;未命中时读取 Redis:
HGETALL lb:room:<roomId>
筛选状态为 1 的活动
GET lb:activity:<activityId>
确认活动进行中且未到开奖时间
│
▼
③ 读取用户候选状态
shard = userId & 15
Redis:ZRANK lb:candidate:<activityId>:<shard> userId
成员不存在 → 结束
Redis:ZSCORE 同一个 Key userId
仅 score = -1 时进入恢复步骤
│
▼
④ 恢复离房前的分数(可选)
Redis:GET lb:candidate:stored:<activityId>:<userId>
原分不存在 → 结束
ZADD lb:candidate:<activityId>:<shard> 原score userId
写入成功后 DEL 原分 Key
注:要求参与后一直待在直播间,则省略此步骤
步骤说明
② 定位进行中的活动
lb:room:<roomId> 用于定位房间中的进行中活动;lb:activity:<activityId> 用于确认活动状态。查询方式与离房流程一致。
③ 读取用户候选状态
对 lb:candidate:<activityId>:<shard> 执行 ZRANK 确认成员存在,再用 ZSCORE 读取分数。任务未完成(-2)时直接结束。
④ 恢复离房前的分数(可选)
读取 lb:candidate:stored:<activityId>:<userId>,用保存的原分更新候选 ZSet,恢复该用户的候选资格。
恢复成功后再删除原分 Key,候选 ZSet 的 TTL 保持原值。恢复使用原随机分,避免反复进离房重新抽分。
要求参与后持续留在直播间时,省略这一步,回房后的分数仍为 -1。
这两条流程中的 MySQL 参与流水、Redis 用户状态、参与 Set 和累计参与人数保持原值。
福袋开奖
主流程
延迟开奖 MQ 到期消费
定时任务补偿到期未开奖的活动
│
▼
① 获取活动锁与状态
Redis:SET lb:draw:lock:<activityId> token NX EX 600
MySQL 主库:SELECT activity,确认尚未开奖
│
▼
② 读取候选与选人
MySQL 运行库:BEGIN
Redis:并发读取 16 个候选分片
key = lb:candidate:<activityId>:<shard>
ZREVRANGEBYSCORE key 1 0 WITHSCORES LIMIT 0 N
合并结果,按 score 降序排序、按 userId 去重
取全局前 N 人,不足 N 时全部选中
│
▼
③ 保存开奖结果
MySQL:SELECT 中奖用户的参与记录
批量 INSERT win_record
Redis:SET lb:win:period:<userId>
DEL + HMSET lb:win:list:<activityId>
按节点 Pipeline SET lb:win:user:<activityId>:<userId>
MySQL:UPDATE activity 为已结束、已开奖
COMMIT
│
▼
④ 刷新活动与房间状态
Redis:SET lb:activity:<activityId> 最新活动JSON
HSET lb:room:<roomId> activityId 0
活动结束 MQ → 下游更新房间 IM 状态
MySQL 管理库:按配置同步中奖记录和活动状态
│
▼
⑤ 启动异步个人通知
├─ 开奖接口返回,按 token 匹配释放锁
└─ 后台:
先发送中奖结果 MQ
MySQL 分页读取参与记录,排除中奖者
分批发送未中奖结果 MQ
消费者 → 用户 IM 通知
步骤说明
① 获取活动锁与状态
lb:draw:lock:<activityId> 防止同一活动并发开奖,有效期 10 分钟,处理结束时按 token 匹配释放。
MySQL 活动表的已开奖状态用于拦截重复执行。延迟 MQ 和定时补开共用这套入口。
② 读取候选与选人
这一步先并发读取 16 个 Redis 候选分片,再合并、排序,选出全局前 N 人。N 是福袋设置的中奖人数。
N = 福袋设置的中奖人数
│
▼
启动 16 路并发,分别读取 shard 0~15
key = lb:candidate:<activityId>:<shard>
│
▼
每片执行一条命令:
ZREVRANGEBYSCORE key 1 0 WITHSCORES LIMIT 0 N
│
├─ 有效成员 ≥ N → 返回最高分的 N 人
├─ 有效成员 < N → 返回全部有效成员
└─ 没有有效成员 / Key 不存在 → 返回空列表
│
▼
等待各片查询完成,合并用户 ID 和分数
最多得到 16 × N 个候选
│
▼
按 score 降序排序,按 userId 去重
│
▼
取全局前 N 人
有效候选不足 N 时,全部选中
1. 每个候选分片保存什么
lb:candidate:<activityId>:<shard> 是 ZSet:
- member:用户 ID。
- score:参与时生成的随机分,或后续写入的资格状态标记。
- shard:
userId & 15,范围为0~15,与参与、进离房流程保持一致。
开奖读取已有分数,不重新生成随机分。
2. 取数命令如何工作
ZREVRANGEBYSCORE key 1 0 WITHSCORES LIMIT 0 N
| 参数 | 含义 |
|---|---|
key |
当前活动的一个候选分片 |
1 0 |
查询分数在 [0, 1] 内的成员,包含边界,按分数降序返回 |
WITHSCORES |
同时返回用户 ID 和分数,供服务端合并排序 |
LIMIT 0 N |
从第一条匹配结果开始,最多返回 N 条 |
普通随机分在 0.000000~0.999999 之间。离房分数 -1、任务未完成分数 -2 不满足分数范围,查询时就被排除。
例如 N=500,一个分片共有 10,000 个成员,其中只有 120 人的分数在 [0, 1] 内,这条命令就返回 120 人,其余成员不会返回。
3. 为什么每片都取 N 人
最终中奖用户可能集中在同一个分片,因此不能把名额平均分给 16 片。
假设 N=500:
分片 A:保存了全局最高分的 500 人
其他片:所有人的分数都低于这 500 人
如果每片只取约 31 人,就会漏掉分片 A 中本应中奖的用户;每片取 500 人才能覆盖这种情况。
同时,每片取 N 人已经足够:排在该片第 N+1 名的用户,前面已有 N 个分数不低于他的候选,继续读取更低分成员不会提高全局前 N 名的分数。
4. 分片之间怎样并发
服务端为 16 个分片分别启动 goroutine,每个 goroutine 执行一次查询,取得 {userId, score} 列表。
主流程使用 WaitGroup 等待查询结束,再统一处理结果。不计重试,共执行 16 条取数命令,分片之间并发。
N=500 时:
每片最多返回 500 人
↓
16 片合计最多 8,000 人
↓
服务端合并、排序
↓
选出最终 500 人
这样把 Redis 返回量和服务端排序规模控制在 16 × N 以内,无需把整个候选池读入内存。
5. 合并后如何选出赢家
合并所有分片结果,按 score 降序排序、按用户 ID 去重,再取前 N 人;不足 N 时全部选中。
同分时,服务端稳定排序保留合并顺序,不额外生成随机分。具体名单还受分片返回顺序和合并顺序影响。
③ 保存开奖结果
MySQL 只查询最终中奖用户的参与流水,生成中奖记录,并按 每批 200 条写入中奖表;活动状态在同一运行库事务中更新。
| Redis Key | 操作 | 作用 |
|---|---|---|
lb:win:period:<userId>,String |
SET winRecordId,TTL 默认 3 小时 |
供后续参与流程判断用户是否处于中奖频控周期 |
lb:win:list:<activityId>,Hash |
DEL 后 HMSET winRecordId 中奖摘要JSON |
保存活动中奖名单,供名单查询 |
lb:win:user:<activityId>:<userId>,String |
按节点 Pipeline SET 中奖记录JSON |
供用户查询个人中奖结果 |
活动中奖名单和用户中奖信息在此写入时不设置 TTL。
个人结果查询应以已提交的持久化结果为准,区分“尚未开奖”“开奖处理中”“已中奖”和“已确认未中奖”。开奖结果尚未确认提交时,返回处理中状态;缓存与持久化结果不一致时,以持久化结果为准,并修复缓存。不能仅凭个人缓存命中或缺失,判断最终中奖结果。
查询接口可以缓存已确认的未中奖结果,减少高峰期重复回源。活动状态查询也需要缓存或合并请求,避免大量未命中请求继续集中访问活动 Key 或 MySQL。
中奖缓存在 MySQL 事务提交前写入,Redis 写入不会随 MySQL 事务回滚。
④ 刷新活动与房间状态
lb:activity:<activityId> 更新为已结束、已开奖;lb:room:<roomId> 中对应活动的值改为 0,后续进离房流程不再把它作为进行中活动处理。
这两类 Redis 更新失败时,通过补偿 MQ 重试。管理库同步受配置控制,失败时另行投递补偿消息。
⑤ 异步个人通知
先投递中奖通知,再分页读取参与记录,排除中奖者后分批发送未中奖通知。大规模参与时,使用分片并行或 MQ 分发处理。
开奖结果持久化完成后,个人通知仍可能继续发送。 通知送达时间包含分页、限速、消息消费和设备接收耗时。
总结
直播福袋的高并发压力集中在报名、开奖和结果查询三个阶段,需要分别控制输入、计算规模和热点访问。
报名阶段,通过异步处理和数据分片承接流量。 MQ 缓冲瞬时请求,参与名单、候选池和计数按业务维度拆分。异步处理后的消费能力仍要跟上,入口受理成功与最终候选入池也需要区分。
开奖阶段,通过分片 Top-N 控制计算规模。 用户参与时生成随机分数,资格变化时更新候选状态;开奖时每片读取 N 人,再合并得到全局前 N 名。16 个分片最多返回 16 × N 个候选,使取数和排序规模主要由中奖名额决定。
结果查询阶段,通过个人缓存和异步通知分散压力。 用户状态与中奖结果按活动、用户拆分保存,查询时区分尚未开奖、中奖和未中奖,通知则独立异步发送。
从榜单到福袋,ZSet 都承担了持续维护顺序的工作:榜单维护业务分数,福袋维护随机分数与候选资格。配合分片和有限取数,可以将高峰时刻的集中计算分散到活动过程中。