#go#后端#Redis

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 都承担了持续维护顺序的工作:榜单维护业务分数,福袋维护随机分数与候选资格。配合分片和有限取数,可以将高峰时刻的集中计算分散到活动过程中。