如果一场比赛的第 60–120 秒排行榜在第 120 秒立刻封榜,一条第 69 秒发生、但第 121 秒才入站的得分应该去哪?忽略它能让榜单快速稳定,却会让账本与排行榜对不上。排行榜设计首先需要决定:读的是实时暂定榜,还是可纠错的最终榜。

本题假设每个被验证的得分事件 event_id 对同一窗口只计一次;窗口采用事件时间半开区间 [60,120) 秒。相同分数采用并列密集排名,展示顺序再按玩家 ID 字典序稳定排序。删除作弊得分也是修正事件,不是默默改计数。只有权限通过的已确认事件进入榜单,未验证事件不参与计算。

先把榜单合同写全

教学 SLO:暂定榜近 30 天 99% 请求在入口 200 毫秒内返回,且带 computed_through 位点;已受理得分在 5 秒内进入暂定榜(处理完成时间减本地持久化时间的 99%)。终榜在窗口结束后等待 2 分钟迟到宽限,再按相同事件集批量重算并公布版本。以上都是目标,不是实验测出的性能。更晚到达的事件放到修正队列,由业务规则决定是否发新版本。跨赛季汇总、奖金发放和反作弊算法不在本题范围内;有奖金时不能把该暂定榜当财务最终排名。

假设每秒 2 万条原始事件、每条 120 B,保留 30 天,单份事件流存储约 20000×120 B×86400×30≈6.22 TB(十进制),三份复制约 18.7 TB,未计索引压缩。峰值系数 5 对应 10 万事件/秒,若热点玩家占事件的 1%,该玩家是 1000 更新/秒;热点占比升至 20%,同一键就是 2 万更新/秒,而集群总量不变,单键成为第一瓶颈。若保存的是每分钟一个玩家的聚合值,数量应按实际活跃玩家数另算,不能直接将原始事件大小代入。窗口从 60 秒增长到 3600 秒,同速率下保留在内存的原始事件数也增加 60 倍;必要时只保留增量和可审计的原始日志。

POST /scores 含 event_id, player_id, points, occurred_at, proof_ref,返回持久化位点而非实时名次。GET /leaderboards/{window}?limit=20&cursor=... 返回 window_start,window_end,version,computed_through,items(rank,player_id,score);版本改变时游标必须报冲突或从头读取,不能把旧版本偏移量混进新榜。数据模型分三层:events(event_id UNIQUE,occurred_at,player_id,points,validation_state) 原始账本;window_counts(window_start,player_id,score,version) 暂定聚合;published_board(window_start,version,finalized_at) 只读快照。多写入者下 event_id 去重需要真实存储唯一约束/原子合并;本篇小实验只在单进程集合里完成去重,没有验证数据库并发写入。

flowchart LR
    S[得分接入与校验] --> E[(不可变事件账本)]
    E --> I[实时增量和去重]
    I --> V[暂定榜 + 位点]
    E --> B[窗口结束后批量重算]
    B --> F[(带版本的终榜)]
    F --> Q[读榜 API]
    V --> Q

查询前 20 名可按 score DESC, player_id ASC 排序,但排序行号不是名次;并列名次可以用 SQL DENSE_RANK() OVER (ORDER BY score DESC),不把 player_id 放进窗口排序键,否则同分被拆开。SQLite 官方窗口函数文档 明确区分 rank() 与 dense_rank() 的并列及跳号行为。本文 Python 实验只输出分数及稳定排序,未计算密集名次,也未运行该 SQL。

sequenceDiagram
    participant E as 事件账本
    participant I as 增量榜
    participant B as 批量重算
    participant C as 读者
    E->>I: hot×4、a×2、b×2
    I-->>C: 暂定 hot=4,a=b=2
    E->>I: 重复 h1,按 ID 忽略
    E-->>B: 窗口已封后到达 a3,事件时间 69 秒
    B->>E: 读取完整窗口并按 ID 去重
    B-->>C: 发布新版本 hot=4,a=3,b=2
    C->>B: 用旧游标继续读取
    B-->>C: 版本冲突,重新取第一页

增量更新方案减少读时排序延迟,但重复、热点及迟到修正都要付写入与纠错成本;全量按账本重算更容易审计,却受窗口数据量与重算频率限制。采用“实时暂定 + 窗口终榜”可明确两类服务目标。近似计数草图适合看热点候选或趋势而非决定前 20 名与奖金:不同键碰撞会高估,临界名次仍需回原始账本精算。本次没有实现草图或测量近似误差,只验证精确计数和热点局部累加;选择近似结构前须另定误差预算。

同一事件集的两次计算

python3 -B examples/system-design/labs/29/ranking.py 使用八条到达记录,实验窗口为 [0,10) 秒,与上方产品场景的分钟窗口分开。重复 a 只计一次;bob 在最大事件时间 8 之后收到时间 5 的迟到事件,恰好处于三秒容忍边界,增加三分;窗口外 old 被拒绝。增量与同一事件集按 ID 去重的批量计算均为 bob=10、carol=10、alice=7,并列分数按玩家 ID 排序。跳过去重的反例令 alice=12。另生成同一热点玩家的 1000 条唯一事件,轮转八份局部计数得到各 125,总和 1000;这是成本分摊模型,不是分布式原子性或吞吐实验。版本、输入、输出和退出码见 examples/system-design/evidence/29/RUN.md。

恢复时先暂停发布新终榜,记录原始流消费位点和已公布版本,从事件账本用 event_id 重放,再比对各玩家计数与事件总数;核对失败则保留旧版并标记暂定,不能用近似值覆盖终榜。热点键可以按事件 ID 分桶并行局部累加、在封窗时合并,但去重须在分桶之前或保持确定的同一分桶路由。面试追问:迟到 24 小时的作弊撤销如何纠正旧榜?并列时怎样保证稳定分页?奖金榜可否读近似计数?这三个答案决定是否必须发布可重算、带版本的精确快照。

[PATTERN] 先选窗口、迟到政策与排名合同,再决定聚合形态;近似值是成本工具,不是正确性来源。

实验源码 examples/system-design/labs/29/ranking.py,证据 examples/system-design/evidence/29/RUN.md。系列导读;本篇是自拟工程扩展题。

实验附件:权威实验源码;运行记录;版本与源码哈希;本地原始结果。