← 全部文章

比赛排行榜赛后链路优化方案

1. 赛后结算链路解耦为:Redisson 延时触发 -> 发送 MQ 事件 -> MQ 消费者重建最终榜单。 2. 排行查询补齐 Cache-Aside 回填:Redis 未命中且 DB 命中时,将结果写回 Redis,避免持续回源数据库。

更新于 2026.08.07

本文目录 16 节

1. 业务背景与目标

本方案聚焦比赛结束后的两个核心瓶颈:

  1. 赛后结算链路解耦为:Redisson 延时触发 -> 发送 MQ 事件 -> MQ 消费者重建最终榜单。
  2. 排行查询补齐 Cache-Aside 回填:Redis 未命中且 DB 命中时,将结果写回 Redis,避免持续回源数据库。

必须满足的约束:

  • 重建期间不删除 Redis 中现有排行榜数据与用户题目详情数据。
  • 技术栈保持不变:Spring Boot 3.x + Redis + Redisson + RabbitMQ + MySQL。

2. 当前现状(As-Is)

基于当前后端实现可观察到:

  • 已存在 Redisson 延时队列并可触发比赛结束事件。
  • 当前赛后处理由调度路径直接调用排行榜结算服务,未经过 MQ 解耦。
  • 已结束比赛查询路径为 Redis 优先,未命中后回源 DB。
  • DB 回源结果直接返回,没有写回 Redis。
  • 结算完成后会在事务提交后删除 Redis 排行 key 与用户题目详情 key。

在高并发下的影响:

  • 调度线程可能被重型结算逻辑长期占用。
  • 热门结束比赛会因重复 miss 回源导致 DB 持续高压。
  • 重建窗口内若数据源切换不当,可能出现读链路抖动。

3. 目标架构(To-Be)

3.1 组件级架构图

是 否 Redisson 延时队列 END 事件 结束调度器 发布结算事件到 RabbitMQ 排行榜结算消费者 重放提交并在 Redis 构建新版本榜单 持久化最终榜单到 MySQL 原子切换 Redis 活跃版本指针 排行榜查询 API Redis 是否命中 直接返回 Redis 分页结果 查询 MySQL 最终榜单 加锁/SingleFlight 回填 Redis 返回响应

3.2 赛后重建时序

END:contestId 获取比赛结束分布式锁 发布 ContestFinalizeEvent(taskId, contestId, endTime) 设置 rankFinalizeStatus=QUEUED 消费结算事件 按 taskId 做幂等校验 设置 rankFinalizeStatus=BUILDING 构建 contest:rank:build:{contestId}:{version} upsert contest_ranking 最终快照 切换 activeVersion -> new version 设置 rankFinalizeStatus=DONE Redisson 延时队列 Scheduler RabbitMQ Finalize Consumer MySQL Redis

3.3 已结束榜单查询时序(Cache-Aside)

alt [命中] [未命中] 查询比赛最终榜 key/page 返回榜单数据 查询 contest_ranking 分页 返回分页数据 获取回填锁(短 TTL) 二次检查是否仍 miss 发布缓存回填事件(或小规模场景同步回填) 先返回 DB 结果 Ranking API Redis MySQL RabbitMQ

4. 核心设计决策

4.1 重建期间不删在线数据

采用版本化 key + 指针切换,替代“先删后建”:

  • 构建榜 key:contest:rank:build:{contestId}:{version}
  • 构建详情 key:contest:detail:build:{contestId}:{version}:{userId}
  • 活跃版本指针:contest:rank:active:version:{contestId}

规则:

  1. 查询始终读取活跃版本指针指向的数据。
  2. 消费者在后台构建新版本。
  3. DB 结算成功后再原子切换指针。
  4. 老版本通过 TTL 或延迟清理任务回收。

效果:重建期间无榜单空窗,不需要强制删除在线缓存。

4.2 赛后结算 MQ 解耦

引入专用事件:

  • 事件名:ContestFinalizeEvent
  • 唯一标识:taskId + contestId + expectedEndTime + retryCount
  • 队列:contest.finalize.queue(带 DLQ)

保障:

  • 至少一次投递语义 + 消费幂等。
  • 调度线程保持轻量、可预测。
  • 重试与死信可观测、可告警。

4.3 DB 回源后的缓存回填策略

针对已结束比赛:

  1. Redis miss -> DB fallback。
  2. 先将 DB 结果返回给用户。
  3. 通过加锁 + 双检触发回填:
  • 锁 key:contest:rank:backfill:lock:{contestId}
  • 锁 TTL:10s 到 30s
  1. 回填方式:
  • 小比赛同步回填;
  • 大比赛通过 MQ 异步回填。

建议阈值:

  • 同步回填阈值:参赛人数 <= 2000。
  • 异步回填阈值:参赛人数 > 2000。

4.4 结算状态机

定义显式状态,提升可观测性与查询保护能力:

  • 0:IDLE
  • 1:QUEUED
  • 2:BUILDING
  • 3:DONE
  • 4:FAILED

查询策略:

  • BUILDING 且已有 active pointer:读取 active 版本数据。
  • BUILDING 且无 active pointer:返回统一“排行榜生成中”响应。

5. 高并发与性能权衡

5.1 预期收益

  • 结束调度线程耗时:由重型同步逻辑降为 $O(1)$ 级事件发布。
  • 已结束榜单 DB 回源 QPS:通过回填 + singleflight 显著下降。
  • 重建期间读稳定性:通过版本指针切换显著提升。

5.2 Trade-offs 分析

  1. 收益:降低延迟与 DB 压力。 代价:组件与状态流转变多(MQ + 状态机 + 版本化 key)。

  2. 收益:重建期间无删除空窗。 代价:版本重叠期 Redis 会出现短时双份内存占用。

  3. 收益:链路解耦更彻底、重试可观测。 代价:必须严格落实幂等治理与 DLQ 运维策略。

5.3 建议 SLO 指标

  • 榜单查询 P95 <= 120ms(Redis 命中)
  • 榜单查询 P95 <= 350ms(DB fallback 路径)
  • 5 万提交量下赛后结算完成时间 <= 3 分钟
  • 重复结算副作用 = 0(幂等可证明)

6. 关键风险与控制措施

  1. 重复发布/重复消费。 控制:taskId 幂等键 + DB 唯一约束 + 状态 CAS 更新。

  2. 回填风暴(击穿放大)。 控制:短锁 + 双检 + 异步回填聚合。

  3. DB 未成功即切换指针。 控制:严格顺序,必须 DB 成功后再切换指针。

  4. 版本重叠导致 Redis 内存上涨。 控制:仅保留 N=2 个版本并设置过期策略。

评论