排行榜与实时计数
→ 返回 高并发
以微信运动式「每日步数 + 好友排行」为代表:海量用户、日历日窗口、午夜同步洪峰、读多写频。目标是实时感足够好、数据层不被每次上报打穿。
对照场景:需要强一致库存、严禁超卖的抢票峰值见 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}:20240601 | ZSet | 好友榜,member=friendId,score=steps |
rank:region:{city}:20240601 | ZSet | 地区榜(可选,热 key 风险) |
更新好友榜
用户 U 步数变为 S 时,需更新所有把 U 当好友的用户的 ZSet(或反向:U 的榜只存好友步数)。
两种模型:
| 模型 | 写扩散 | 读 |
|---|---|---|
| 拉模型(推荐读多) | 只写 step:date:U | 打开榜时 ZMScore 批量取好友步数,应用层排序(好友数 ≤ 几百) |
| 推模型 | 更新每个好友的 ZSet | 读 ZREVRANGE 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 UPDATE 到 user_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 + 滑动窗口衰减 | 见 时间窗口设计 |
相关
- 12306 春节高峰 — 强一致库存型高并发
- 时间窗口设计
- 缓冲与流量塑形
- Redis — ZSet、Lua、热 key
- 消息队列