排行榜与实时计数

返回 高并发

微信运动式「每日步数 + 好友排行」为代表:海量用户、日历日窗口、午夜同步洪峰、读多写频。目标是实时感足够好数据层不被每次上报打穿

对照场景:需要强一致库存、严禁超卖的抢票峰值见 12306 春节高峰


业务约束

维度典型要求
每人每天多次上报(打开小程序、后台同步)
打开即看好友榜、自己的名次
窗口自然日(本地时区 0 点切榜)
一致性步数可略滞后;排名可秒级更新
峰值0 点前后集中同步

总体架构

客户端上报步数
      │
      ▼
┌─────────────┐     快速 200 OK(带 requestId)
│ API 网关     │──── 限流 / 鉴权 / 幂等键
└──────┬──────┘
       │
       ▼
┌─────────────┐     削峰、可重放
│ 消息队列     │──── partition by hash(userId)
└──────┬──────┘
       │
       ▼
┌─────────────┐     合并:同日取 max(steps),更新 Redis
│ 聚合服务     │──── 维护好友关系下的 ZSet 排行
└──────┬──────┘
       │ 批量 / 定时
       ▼
┌─────────────┐     历史、对账、运营查询
│ MySQL 分表   │
└─────────────┘

读榜:客户端 ──► 读服务 ──► Redis ZREVRANGE(不走 MQ)

原则写路径异步化,读路径内存化,持久化滞后。


写路径:为什么不能直连数据库

若每次上报 UPDATE user_steps SET steps=?SELECT 算排名:

  • 午夜 QPS 可达百万~千万(视 MAU),单行更新 + 索引维护扛不住
  • 好友榜需排序,SQL ORDER BY steps 在好友列表上重复执行,成本极高
  • 连接池、锁竞争、主从延迟叠加

因此:上报接口只做校验 + 入队 + 返回

// 伪代码:同步路径极短
public ReportResult report(ReportReq req) {
    if (!rateLimiter.tryAcquire(req.userId())) return ReportResult.rateLimited();
    if (!idempotent.check(req.requestId())) return ReportResult.duplicate();
    mq.send("step.report", partition(req.userId()), req);
    return ReportResult.accepted();
}

步数合并与幂等

规则说明
同日多次上报通常取 max(步数)(防客户端重复累加错误)
幂等键requestId(userId, deviceId, batchId) 去重
乱序旧步数小于已合并值则丢弃
-- Redis 单用户当日步数(String 或 Hash field)
local key = KEYS[1]           -- step:20240601:uid
local new = tonumber(ARGV[1])
local old = tonumber(redis.call('GET', key) or '0')
if new > old then
  redis.call('SET', key, new)
  return 1
end
return 0

消费者根据返回值决定是否刷新排行榜。


排行榜:Redis ZSet

数据结构详解:ZSet

Key 设计

Key结构用途
step:20240601:{userId}String当日步数
rank:friend:{userId}:20240601ZSet好友榜,member=friendId,score=steps
rank:region:{city}:20240601ZSet地区榜(可选,热 key 风险)

更新好友榜

用户 U 步数变为 S 时,需更新所有把 U 当好友的用户的 ZSet(或反向:U 的榜只存好友步数)。

两种模型:

模型写扩散
拉模型(推荐读多)只写 step:date:U打开榜时 ZMScore 批量取好友步数,应用层排序(好友数 ≤ 几百)
推模型更新每个好友的 ZSetZREVRANGE O(log N)

微信好友上限约 5000,但常看榜的好友子集更小;工业界常见:

  • :只更新自己的 step:date:uid
  • :拉好友 ID 列表 → Pipeline GET 步数 → 内存排序 → 返回 Top N

若必须服务端排序且好友很多:

ZADD rank:friend:viewer:20240601 <steps> <friendUid>
ZREVRANGE rank:friend:viewer:20240601 0 99 WITHSCORES

推模型写扩散:用户 U 更新时, fan-out 到 N 个 viewer 的 ZSet —— 适合 N 小或用 MQ 异步 fan-out。

全服榜 / 热 key

全服 Top 100 是超级热 key,单 ZSet 在 Cluster 上单 slot:

  • 读:本地缓存 Top100 + 短 TTL
  • 写:异步合并后 ZADD 全服 key,或分片榜 rank:global:shard:{0..15} 再归并

午夜洪峰(00:00)专项

手段说明
客户端错峰随机延迟 0~300s 再上报(产品文案:正在同步)
网关限流按 userId 令牌桶,超限排队或 429
MQ 分区partition = hash(userId) % P,消费者水平扩展
合并写消费端内存 500ms~1s 窗口合并同用户多次上报
预扩容定时任务提前扩 K8s Pod / Redis 代理连接池
降级只记录步数不刷新榜,白天再补偿
00:00~00:05  上报洪峰 ──► MQ 积压可控 ──► 消费者 lag 监控
00:05~00:30  榜逐渐追上

读路径

GET /rank/friends?date=20240601
  → 读 friend_list(缓存)
  → Pipeline GET step:20240601:{friendId}
  → 堆排序 / 部分排序取 Top 100
  → 可选:附带自己的名次(全量排序一次)

不要读路径触发 MQ 或 DB。历史某日榜单可走 MySQL 冷数据。


数据层落库

时机动作
实时仅 Redis
T+1 或每小时批量 INSERT ... ON DUPLICATE KEY UPDATEuser_daily_steps
对账比对 Redis 与 DB,修复差异

表设计示例:

CREATE TABLE user_daily_steps (
  user_id    BIGINT NOT NULL,
  step_date  DATE   NOT NULL,
  steps      INT    NOT NULL,
  updated_at DATETIME NOT NULL,
  PRIMARY KEY (user_id, step_date)
) PARTITION BY RANGE (TO_DAYS(step_date));

其它实时计数场景

场景结构注意
直播间点赞INCR + 定时刷榜允许近似,合并写
游戏战力榜ZSet + 赛季 key赛季结束冷存储
热搜榜ZSet + 滑动窗口衰减时间窗口设计

相关