NOTE
2.6 如何设计榜单系统
排行榜系统设计笔记,保留有序集合、幂等更新、热点榜单、本地缓存、分片与对账等通用设计。
这是历史学习笔记,可能存在过时或不完整的理解。
1. 需求是什么
- 需求单
- 原型图
2. 为什么要做这个需求
- 用于解决什么问题
- 需求是不是可以不做
- 能不能简化下需求
3. 需求分析
理解概念需要结合系统举例子
3.1. 原有流程是怎样的
找运营或者产品演示流程 读写流程、B端C端流程
3.2. 新流程是怎样的
3.3. QPS估算
使用变量估算参与者规模和热点对象在线规模,公开版不保留真实业务人数与容量
4. 方案设计
4.1. 存储设计
覆盖:zadd 1_2_3 1 member
增加或者减少:zincrby 1_2_3 5 member
按分数倒序获取:zrevrange 1_2_3 0 -1 withscores
4.2. 接口设计
ReadRankReq {
business_id
rank_scope
start
limit
optional timestamp
optional self_member
optional order_type
}
ReadRankRsp {
rank_list[{member_id, score, rank}]
optional self_rank
optional total
}
UpdateRankReq {
business_id
rank_scope
operations[{member_id, score, op_type}]
request_id // 幂等
}
UpdateRankRsp {
results[{member_id, score}]
}
DeleteRankMemberReq {
business_id
rank_scope
member_ids[]
}
4.2.1. 根据排名拉取榜单详情
- req
- appid
- rankid
- subrankid
- start
- limit
- me
- rsp
- list
- member
- score
- me
- member
- score
- list
4.2.2. 更新榜单
- req
- appid
- rankid
- subrankid
- list
- member
- score
- orderNo(幂等)
- opType(覆盖 加)
- rsp
- list
- member
- score
- list
4.3. 架构设计
4.4. 代码设计
4.4.1. update
pipeline+evalsha lua+兜底eval lua
score是分数+'.'+9 9999 9999 9999-当前时间戳(1 6523 5401 7172)保证相同分数的情况下;先达到的排到前面
function update(rank_key, member, score, request_id, op_type, ttl):
atomically:
if request_id 已处理:
return 已有结果
标记 request_id 已处理并设置过期时间
if op_type == SET:
ZADD rank_key score member
else if op_type == INCR:
ZINCRBY rank_key score member
if rank_key 首次创建:
EXPIRE rank_key ttl
return ZSCORE rank_key member
4.4.2. get
- getByRank
// 按分数逆序排序
zrevrange 1_2_3 start start+limit-1 withscores
// 按分数正序排序
zrange 1_2_3 start start+limit-1 withscores
删除小数
strings.Split("", ".")[0]
- getByMember
function getByMember(rank_key, member, order):
score = ZSCORE(rank_key, member)
if score 不存在:
return not_found
rank = order == ASC ? ZRANK(rank_key, member) : ZREVRANK(rank_key, member)
return rank, score
getMember和getRank也可以封装到Lua中
4.5. 幂等性设计
写操作加个orderNo, 由业务方生成保证写操作重试时幂等 和上榜操作一起封装到Lua脚本中
4.6. 如何应对高并发
首先说下排行榜的业务特点:
- 榜单主要展示头部数据,不需要展示完整榜单;
- 属于读多写少的业务。
高并发可以分成两块,高并发写和高并发读。
4.6.1. 如何应对高并发写
一方面可以使用消息队列削峰,另一方面榜单是读多写少的业务,所以写通常不是优化重点。
4.6.2. 如何应对高并发读
高并发读无非就是用缓存,问题是怎么用。
首先看榜单展示逻辑:从 Redis 中拉取榜单 Top N、个人分数以及排名,再聚合用户头像和昵称。
用户资料适合 read-through 本地缓存:聚合用户资料时先从本地内存中取,没有则从用户资料服务拉取并缓存。考虑到用户资料变化不频繁,可以依靠过期时间更新。
榜单和个人分数变化频繁,缓存命中率和一致性需要单独评估。先对单 Key 压测,如果 Redis 能满足需求,就不需要过度优化。
对于极端热点榜单,再考虑本地热点缓存。
4.7. 热榜单问题
- 如何发现 hot key
- 手动配置。已知热点活动可以提前预知
- 通过存储侧或服务侧监控统计访问 Top N 的 key
- 如何处理 hot key
- 普通热点优先由 Redis 承担
- 极端热点可以双写 Redis 和本地内存,用本地内存抗读流量
综合考虑:
- 不需要榜单的超大热点对象可以预配置屏蔽;
- 需要榜单的超大热点对象,更新先写 Redis,同时通过可重放消息更新本地缓存;
- 节点启动时先从 Redis 加载当前榜单快照,再从事件位点继续重放,追平后才对外提供热点榜单服务;
- 大热点榜单的 Top N 走本地缓存,个人排名仍然回源 Redis;
- 普通榜单直接查询 Redis。
4.8. 大榜单问题
- 如何发现大 key
- 手动配置。已知大榜单可以提前预知
- 监控 member 数目较大的 key
- 如何处理大 key
- 拆分
- 写:对 member 做 hash 得到 suffix,写入不同的 zset 分片
- 读:
- 方案一:并行读取各分片 Top N,在内存聚合排序,实时性高但效率较低
- 方案二:定时聚合各分片 Top N,缓存为只读视图,实时性低但效率较高
- 个人的分数和排名很可能不在榜单前几中,可以单独维护个人分数 / 排名读模型,避免个人查询持续打到大榜单 key
- 拆分
4.9. 对账模块
业务方保留积分变更流水,通用榜单也保留已处理更新流水或 checkpoint。
对账模块周期性比较最近一个安全窗口内的两侧流水;发现差异后重新投递缺失消息,并依靠 request_id 做幂等修复。
5. 工作量评估
- 一个接口评估0.5-2天
6. 开发
7. 测试
- Redis 压测
- update 吞吐
- Top N 查询吞吐
- 个人排名查询吞吐
- 消息队列压测
- 单分片消费吞吐
- 重放速度
- 服务压测
- 本地有序集合查询
- Redis 查询
- 节点启动后的热点榜单重放耗时
8. 发布
9. 运维
9.1. Redis全球复制
参考频控系统中的多地域复制方案。
10. 优化
11. 总结
- 方案对比
- 遇到的问题以及怎么解决的
- 设计中的亮点
- 为什么引入XXX组件
- 痛点梳理与改进措施
- 请求量、数据量扩大N倍怎么处理
- 重构.md
讨论
使用 GitHub 账号参与讨论,评论会保存在 GitHub Issues 中。在 GitHub 查看