NOTE

2.6 如何设计榜单系统

排行榜系统设计笔记,保留有序集合、幂等更新、热点榜单、本地缓存、分片与对账等通用设计。

系统设计创建于 更新于 约 3 分钟读完historical

这是历史学习笔记,可能存在过时或不完整的理解。

1. 需求是什么

  • 需求单
  • 原型图

2. 为什么要做这个需求

  • 用于解决什么问题
  • 需求是不是可以不做
  • 能不能简化下需求

3. 需求分析

理解概念需要结合系统举例子

3.1. 原有流程是怎样的

找运营或者产品演示流程 读写流程、B端C端流程

3.2. 新流程是怎样的

  1. 系统中的角色以及每个角色可以做什么
  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

4.2.2. 更新榜单

  • req
    • appid
    • rankid
    • subrankid
    • list
      • member
      • score
      • orderNo(幂等)
      • opType(覆盖 加)
  • rsp
    • list
      • member
      • score

4.3. 架构设计

综合考虑安全性、高并发、高可用、可维护

  1. 拆分微服务.md
  2. 画出应用架构图
  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. 如何应对高并发

首先说下排行榜的业务特点:

  1. 榜单主要展示头部数据,不需要展示完整榜单;
  2. 属于读多写少的业务。

高并发可以分成两块,高并发写和高并发读。

4.6.1. 如何应对高并发写

一方面可以使用消息队列削峰,另一方面榜单是读多写少的业务,所以写通常不是优化重点。

4.6.2. 如何应对高并发读

高并发读无非就是用缓存,问题是怎么用。

首先看榜单展示逻辑:从 Redis 中拉取榜单 Top N、个人分数以及排名,再聚合用户头像和昵称。

用户资料适合 read-through 本地缓存:聚合用户资料时先从本地内存中取,没有则从用户资料服务拉取并缓存。考虑到用户资料变化不频繁,可以依靠过期时间更新。

榜单和个人分数变化频繁,缓存命中率和一致性需要单独评估。先对单 Key 压测,如果 Redis 能满足需求,就不需要过度优化。

对于极端热点榜单,再考虑本地热点缓存。

4.7. 热榜单问题

  • 如何发现 hot key
    1. 手动配置。已知热点活动可以提前预知
    2. 通过存储侧或服务侧监控统计访问 Top N 的 key
  • 如何处理 hot key
    • 普通热点优先由 Redis 承担
    • 极端热点可以双写 Redis 和本地内存,用本地内存抗读流量

综合考虑:

  1. 不需要榜单的超大热点对象可以预配置屏蔽;
  2. 需要榜单的超大热点对象,更新先写 Redis,同时通过可重放消息更新本地缓存;
  3. 节点启动时先从 Redis 加载当前榜单快照,再从事件位点继续重放,追平后才对外提供热点榜单服务;
  4. 大热点榜单的 Top N 走本地缓存,个人排名仍然回源 Redis;
  5. 普通榜单直接查询 Redis。

4.8. 大榜单问题

  • 如何发现大 key
    1. 手动配置。已知大榜单可以提前预知
    2. 监控 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. 发布

  1. 服务发布Checklist
  2. 上线部署

9. 运维

9.1. Redis全球复制

参考频控系统中的多地域复制方案。

10. 优化

11. 总结

  1. 方案对比
  2. 遇到的问题以及怎么解决的
  3. 设计中的亮点
    • 为什么引入XXX组件
  4. 痛点梳理与改进措施
    • 请求量、数据量扩大N倍怎么处理
    • 重构.md

12. 参考

讨论

使用 GitHub 账号参与讨论,评论会保存在 GitHub Issues 中。在 GitHub 查看