题干与适用场景
设计一套竞技游戏的赛季排行榜。一个赛季有 5000 万活跃玩家,峰值每秒接收 20 万次成绩更新, 读取峰值为每秒 100 万次。玩家需要查看全球前 100 名、自己的精确名次,以及自己前后各 10 名。 成绩提交后 5 秒内出现在排行榜,读取 p99 目标小于 100 毫秒。分数是 0..1,000,000 的整数, 每个玩家只保留赛季最高分。同分玩家共享名次,因此排名 1、2、2 之后是 4;同分玩家在页面内按 player_id 稳定展示,但显示顺序不改变名次。
题目里的规模、延迟和分数范围都是面试假设。客户端不能直接决定可信成绩;对局服务完成反作弊与结果 确认后,才产生排行榜可以消费的成绩事件。本题关注软件工程和系统设计岗位中的排序索引、读写分离、 热点分片、一致性、可重建性和赛季生命周期,不讨论具体反作弊模型。
一份公开技术面试准备指南把“设计游戏排行榜”列为系统设计示例,并明确考察排序、读写性能和实时更新。 2026 年发布的 Redis 实时排行榜教程也展示了用 Sorted Set 更新分数、查询 Top-N 和个人名次的基础路径。 公开资料没有可靠公司归属,因此本文把它作为代表性系统设计题,companyName 留空。
面试官考察点
第一项信号是能否先定义“名次”。高分优先容易说清,同分是共享名次、按先到先得,还是用玩家 ID 打破平局,会直接改变数据模型。若连这个契约都没有,两个服务可能对同一批玩家给出不同名次。
第二项信号是是否知道一个 Sorted Set 只解决单个有序集合。Redis 的 ZADD 更新和 ZREVRANK 查名次都是 O(log N),很适合中等规模排行榜;但 Redis Cluster 按 key 分配 hash slot,一个全球榜 如果仍是一个 key,增加集群节点不会自动把这个 key 内的 5000 万成员拆开。强回答会先保留简单方案, 再在单 key 的内存、写入或故障恢复预算越界时引入应用层分片。
第三项信号是能否把“5 秒内精确”落实成一致的读版本。玩家从旧分数桶移到新分数桶时,先删后加会短暂 消失,先加后删会短暂重复;如果同时读取分桶计数和桶内顺序,混用两个时刻的数据也会算错名次。 可验证的方案需要让一次读取只看同一个已发布版本。
最后看系统是否可运营。成绩事件要幂等、纠错要能降低分数、赛季关闭要冻结一致快照、缓存丢失要能从 可信账本重建。只画“游戏服务 → Redis”没有回答重复事件、热点、数据丢失、作弊成绩撤销和结算争议。
回答前需要澄清的问题
- 分数是累计值、最高值还是最新值? 本题取赛季最高分。若是累计值,重复事件会重复加分,事件 ID
与原子增量更关键;若允许裁判修正,接口必须接收绝对版本,不能只用 ZINCRBY。
- 同分怎样排名? 本题使用 competition ranking:只统计分数严格更高的人,同分共享名次。
若要求“先达到者靠前”,排序键必须加入可信达成时间,普通 ZSET 的同分字典序不能自动满足它。
- 精确度和新鲜度分别是多少? 本题要求已发布版本内精确,允许最多 5 秒陈旧。若每次写入都要线性化后
立即可见,跨分片协调会进入写入延迟;若允许近似名次,可以用采样或分位数摘要大幅简化。
- 哪些榜单必须支持? 主路径只做全球赛季榜。地区榜可以按地区建立独立物化视图;好友榜通常先批量
读取好友分数再在请求内排序,不应为每个玩家维护一份榜单。
- 读请求需要什么? Top 100、本人名次和附近 20 人。任意深分页会形成昂贵扫描,应限制窗口,使用
版本化游标,并在版本过期后要求重新定位。
- 成绩由谁确认? 只有受信任的对局结果服务能写。客户端提交只是对局输入,不能把客户端声称的分数
直接放进排序索引。
- 赛季何时关闭? 以服务端事件时间和明确截止点为准。关闭后迟到结果是拒绝、人工复核还是进入下一季,
必须由产品契约决定,不能让后台任务悄悄改动已发奖快照。
- 可用性与持久性边界是什么? 排名视图可以短暂陈旧或重建;可信成绩账本和最终赛季快照不能因缓存
故障丢失。
30 秒回答框架
“我先把名次定义为 1 + 分数严格更高的玩家数,所以同分共享名次。受信任的对局服务写入带 event_id 和玩家版本的绝对最高分,持久成绩表是事实源,事件流驱动排行榜物化。规模较小时,一个 Redis Sorted Set 就能用 ZADD、Top-N 和反向 rank 完成;5000 万玩家的全球热 key 超过单分片预算后, 我按固定分数区间分桶。个人名次等于更高分桶人数之和,加本桶中分数更高的人数,再加一。
跨桶移动不能边改边让读请求混看,因此 materializer 每几秒提交一个版本:所有桶、人数前缀和 Top 100 准备完成后,原子切换当前 manifest。响应返回 leaderboardversion 和 asof。写路径按事件与版本幂等, 缓存可从成绩账本重建;赛季关闭时冻结同一版本,再对人数、Top-N 和抽样名次做对账后发奖。”
分步骤深入解答
第一步:把 API 和排序契约固定下来。
内部写入与外部读取分开:
POST /internal/v1/seasons/{season_id}/scores:apply
GET /v1/seasons/{season_id}/leaderboard/top?limit=100
GET /v1/seasons/{season_id}/players/{player_id}/rank
GET /v1/seasons/{season_id}/players/{player_id}/neighbors?radius=10&version=...写请求包含 eventid、matchid、playerid、绝对 bestscore、score_version 和可信完成时间。 服务只接受对局结果服务的身份。读取响应包含 leaderboardversion、asof、分数、名次和成员;附近查询 必须沿用第一次响应的版本,否则翻页期间名次变化会造成重复或跳项。
核心不变量有四个:每个 (seasonid, playerid) 在一个版本中只出现一次;已发布版本中的个人分数与 排序索引一致;名次始终等于 1 + count(score > my_score);一个 manifest 只引用全部完成的分片版本。
第二步:让可信成绩表成为事实源。
对局结算服务先完成权限、比赛状态和反作弊检查,然后写入不可变结果账本。排行榜写服务在数据库事务中 按 event_id 去重,并对玩家行执行版本条件更新:新版本较旧就拒绝;同版本重试返回原结果;正常上分只在 newscore > bestscore 时改变最高分。若运营方撤销作弊成绩,则写入更高的 score_version 和修正后的 绝对分数,哪怕分数降低也能收敛。
事务提交后通过 outbox 或等价的耐久变更流发布:
ScoreChanged {
season_id, player_id, old_score, new_score,
score_version, event_id, committed_at
}事件按玩家键分区,materializer 只应用大于当前玩家版本的变更。这样重复投递不会重复加分,乱序旧事件 不会覆盖新分数。排行榜是可丢弃物化视图;账本与当前成绩表才是重建依据。
第三步:先给出单 Sorted Set 的简单方案。
在榜单成员、峰值写入、内存和恢复时间都能装进一个分片时,每个赛季一个 ZSET 是合理起点:
key = leaderboard:{season_id}
member = player_id
score = best_scoreZADD 为已有成员设置新分数并调整位置,Top-N 用倒序范围,个人位置用 ZREVRANK。Redis 文档给出的 复杂度分别是更新 O(log N)、固定大小范围 O(log N + M) 和 rank O(log N)。整数分数在本文的 0..1,000,000 范围内远低于 double 能精确表示的 2^53 边界。
但同分成员按 member 的二进制字典序排列,这只适合“同分共享名次、ID 仅控制展示顺序”的契约。 不能把毫秒时间和任意大分数随意塞进一个浮点 score 来假装拥有复合排序。个人共享名次也不能直接拿 ZREVRANK + 1:同分成员会得到不同位置。正确的业务公式是 1 + count(score > my_score);在单个 ZSET 内可用 ZCOUNT key (my_score +inf 统计,其中 ( 表示排除同分。Top 页面显示位置应与业务名次分开。
第四步:证明为什么规模扩大后必须应用层分片。
Redis Cluster 把 key 映射到 hash slot,稳定时一个 slot 由一个主节点服务。leaderboard:{season} 仍是一个 key,因此它的成员、写入和恢复压力仍集中在一个 slot 的主节点。把玩家按 hash(player_id) 分片虽然写得均匀,却无法快速回答全球名次:每次查询都要从所有分片合并或统计。
本题分数有明确有限范围,因此按固定分数区间分桶。例如每 10,000 分一个桶,共 101 个桶:
bucket_id = floor(score / 10,000)
rank(player) = 1
+ count(all buckets with a higher bucket_id)
+ count(score > player_score inside the player's bucket)每个桶内部仍是有序集合,桶 key 可以分散到不同 slot。额外维护每个版本的桶人数和从高到低的前缀和, 个人名次只需定位玩家、读取一个前缀值,再在一个桶内做严格大于计数。Top 100 只扫描最高的非空桶,并 单独缓存物化结果。固定范围会在高分段聚集时产生热桶;可以把热桶进一步细分,但切分点要写进版本化 manifest,不能让读写双方各自猜测。
第五步:用版本化发布解决跨桶原子性。
玩家从 39,000 分升到 51,000 分时要离开桶 3、进入桶 5,同时改变两个桶人数。跨 Redis slot 的删除、 新增和计数更新不是一个普通原子操作。让在线读取直接看这些中间状态,会出现重复、消失或全局名次偏一。
materializer 因此按短 epoch 提交逻辑快照。每个 rank shard 从当前已发布版本应用一批幂等变更,生成下一 版本的有序索引与人数;协调器等所有分片报告完成后,校验总人数、变更数和分片校验和,再把 current_manifest 从版本 v 原子指向 v+1。读取先取得 manifest,后续所有查询都携带这个版本。 未完成版本对外不可见,失败分片可以重试,旧版本保留到在途请求结束。
逻辑快照可用 MVCC、copy-on-write 页或基线加增量复用旧数据,无需每几秒复制全部 5000 万成员;对外仍 满足不可变版本语义。epoch 周期、应用延迟和发布延迟的总和必须稳定低于 5 秒;若赶不上,系统应报告 staleness 并停止宣称实时,而不是发布一半完成的版本。
第六步:区分全球榜、地区榜和好友榜。
全球榜使用上述分桶索引。地区榜若是固定少量地区,可以让同一成绩事件更新 (season, region) 的独立 物化视图;地区来自服务器维护的玩家资料版本,避免客户端临时换区。若地区组合很多,按请求临时过滤全球 Top-N 会漏掉地区内高排名但全球排名较低的人,应改用独立索引或明确只支持近似结果。
好友榜通常规模小。先从社交图服务取得版本化好友 ID,批量读取同一排行榜版本中的分数,在应用服务内按 score DESC, player_id ASC 排序并计算同分名次。为每个玩家维护一份好友 ZSET 会产生巨大的写放大: 一个玩家上分要扇出到所有好友的榜单,而且关系变化还要回填。
第七步:估算流量并按测量结果定分片。
假设一个耐久成绩事件连同 envelope 为 128 字节,峰值逻辑写入下界为:
200,000 events/s × 128 bytes = 25.6 MB/s
25.6 MB/s × 86,400 s = 2.21184 TB/day
三副本日志写入下界 = 6.63552 TB/day这是未压缩下界,不含索引、协议、批次、重试和副本恢复。实际只有提高最高分或纠错的事件会改变排序, 但所有可信事件仍需在账本侧去重与审计。100 万次读取每秒也不能全部穿透到 rank shard:Top 100 按版本 缓存,个人结果可以短 TTL 缓存但 key 必须包含玩家与版本,附近窗口再由分片批量读取。
分片数不能从文章里抄一个常数。用 5000 万条生产形状数据压测单桶的成员内存、ZADD、严格大于计数、 范围读取和快照构建,记录 p50/p95/p99、CPU、内存、复制 lag 与恢复时间。再用峰值写入和故障余量反推 桶细分与节点数。若单 ZSET 已在预算内,保留它比自建分桶协调器更可靠。
第八步:关闭赛季,并让视图可重建。
赛季进入 CLOSING 后,截止点之前已确认但仍在流中的结果继续处理,新的非合法结果被拒绝。系统记录一个 输入高水位,等所有 materializer 处理到该位置后生成候选终局版本。结算任务核对玩家总数、分桶人数之和、 Top 100、随机抽样名次、重复玩家数和每个分片校验和;通过后把 manifest 标为 FINAL,奖励服务只读取 这个不可变版本。之后的纠纷进入带审计的修正流程,不能静默改榜。
若排名缓存或整个集群丢失,从当前成绩表或不可变账本按 score_version 重放到新命名空间,构建完整候选 版本并对账,再原子切换 manifest。重建期间旧版本继续只读;没有旧版本时返回明确的暂时不可用或陈旧状态, 不能把半建榜单当成完整结果。
验证矩阵包括:同一事件重复和乱序到达;玩家跨桶上分与降分纠错;分片在 epoch 中途崩溃;协调器在 manifest 切换前后崩溃;一个缓存节点丢失;Top 100 边界出现大量同分;100 万读 QPS;赛季截止点附近的 迟到结果;全量重建和双跑比对。持续断言四个核心不变量,并监控事件到发布的 p99、新旧版本差、桶倾斜、 拒绝事件、重建进度和结果校验失败。
高质量示范回答
“我会先确定名次语义。本题同分共享名次,所以一个玩家的 rank 是 1 + score 严格更高的人数,Top 100 的展示位置和业务 rank 要分开。客户端不能写可信分数;对局服务确认结果后,排行榜写服务按 event ID 去重,并用玩家 score version 条件写入绝对最高分。当前成绩表是事实源,提交后的变更流驱动排名视图。
如果规模能装进一个 Redis 分片,我会先用每赛季一个 Sorted Set:设置分数、查 Top-N 和按分数统计都很 直接。但 5000 万玩家的全球榜是单一热 key,Redis Cluster 只按 key 分 slot,不会拆开 key 内成员。 本题分数有 0..1,000,000 的界限,因此扩大后按固定分数范围做 101 个桶。个人名次由更高桶人数前缀、 本桶严格高分人数和 1 组成;Top 100 从最高非空桶生成。
跨桶移动涉及两个 key 和计数,在线原地更新会让读请求看到中间状态。我会按最多几秒的 epoch 生成下一版 索引;所有分片应用完幂等变更并通过人数、校验和对账后,协调器才原子切换 current manifest。每个响应都 返回 version 和 as-of,附近查询沿用同一 version。这样已发布版本精确,代价是最多 5 秒陈旧。
写入峰值若每条事件按 128 字节估算,是 25.6 MB/s 逻辑流量和每天约 2.21 TB,三副本下界约 6.64 TB; 索引节点和桶数仍要用生产形状数据压测决定。Top 100 按版本缓存,好友榜批量取好友分数后局部排序,避免 每次上分向所有好友扇出。
赛季结束时我会记录输入高水位,等所有分片追平后冻结一个候选终局版本,核对总人数、分桶和、Top 100、 抽样名次和分片校验和,之后才让奖励服务读取 FINAL manifest。缓存丢失可从可信成绩表或账本重建。故障 测试会覆盖重复乱序事件、跨桶纠错、分片和协调器崩溃、大量同分、截止点迟到与全量重建。”
常见错误
- 错误表现:直接接受客户端分数 → 失败原因:排序存储无法判断成绩是否合法 → 修正方法:只消费受信任对局服务确认并可审计的结果。
- 错误表现:同分时直接使用
ZREVRANK + 1→ 失败原因:ZSET 会按 member 字典序区分同分位置,与共享名次契约冲突 → 修正方法:用1 + count(score > my_score)计算业务名次。 - 错误表现:把时间戳随意拼进浮点 score → 失败原因:double 有精度边界,复合键也可能反转主次顺序 → 修正方法:先定义平局规则,再使用可证明的整数编码或支持复合排序的索引。
- 错误表现:认为加 Redis Cluster 节点会拆分一个全球榜 key → 失败原因:Cluster 按 key 的 hash slot 分配,单 key 仍由一个 slot 主节点服务 → 修正方法:先测单 key 边界,越界后按业务可合并维度分片。
- 错误表现:按玩家哈希分片后逐分片合并每次名次查询 → 失败原因:查询成本随分片数增长,100 万读 QPS 会放大扇出 → 修正方法:利用有限分数范围做分桶计数,或在允许时返回近似名次。
- 错误表现:跨桶先删后加或先加后删 → 失败原因:读请求会看到玩家消失或重复,计数也可能偏差 → 修正方法:构建完整版本并通过 manifest 原子发布。
- 错误表现:所有成绩都用
ZINCRBY→ 失败原因:重复事件会重复加分,作弊撤销也无法安全降分 → 修正方法:按事件和玩家版本幂等设置绝对成绩。 - 错误表现:关闭时间一到就读取当前缓存发奖 → 失败原因:截止前已确认事件可能仍在流中,缓存也不是持久事实源 → 修正方法:记录输入高水位,追平、对账并冻结 FINAL 版本。
- 错误表现:只验证 Top 100 看起来正确 → 失败原因:桶计数或单个玩家重复仍可能让长尾名次全部偏移 → 修正方法:验证人数守恒、唯一性、抽样 rank 公式、分片校验和与全量重建。
追问及应对
追问一:如果产品要求成绩写入后立即读到,还能用 5 秒快照吗?
可以把“写入者读己之写”和“全局已发布名次”分开。写响应返回已提交的玩家新分数与待发布版本;同一玩家 立即刷新时可以显示“成绩已确认,全球名次更新中”,或在个人卡片显示新分数,同时仍让全局 rank 指向最近 完整 manifest。若产品坚持新分数与精确全球名次必须同步返回,就要让更新进入全局排序的同步协调路径, 写延迟和故障耦合都会上升,原来的吞吐目标必须重估。
追问二:最高分段集中 1000 万玩家,一个桶又成了热点怎么办?
分桶策略属于版本化元数据。先用桶的写 QPS、成员数、CPU 和 p99 证明热点,再把该分数范围切成更窄子桶; 共享名次只取决于严格更高分数,因此同一具体分数不能随意按玩家拆开后再用局部 rank 相加,必须聚合该分数 的总人数。迁移时双建新布局,对同一输入高水位核对人数与抽样名次,随后发布引用新布局的 manifest。
追问三:如果同分规则改成“先达到该分数者靠前”呢?
名次不再只由分数计数决定。排序键变为 (score DESC, achievedat ASC, playerid ASC),其中 achieved_at 必须来自可信结算。普通 ZSET 对同分只按 member 字典序;可以使用经过精度证明的固定宽度 整数编码和反向 member 编码,但维护风险较高。规模较大时我更倾向支持复合键和 order statistics 的有序 索引分片,并在测试中覆盖相同毫秒、重试和时间纠错。
追问四:反作弊系统在发奖后撤销冠军成绩怎么办?
技术系统不能替产品决定是否追回奖励。它应接受更高 score_version 的负向纠正,保留原事件、证据、审批 人和时间,生成新的修正版 manifest,而不改写原 FINAL 快照。奖励服务按运营政策执行冻结、追回或顺延, 并把奖励版本与排行榜版本关联。这样可以解释“当时为何发奖”和“之后为何修正”。
追问五:怎样证明从账本重建出的排行榜与在线榜一致?
在隔离命名空间按事件版本重放到同一输入高水位,比较玩家数、各分数桶人数、分数总和与哈希、Top 100、 大量分层抽样玩家的 1 + count(higher score),以及每个 shard 的确定性校验和。再让新旧读取路径影子双跑, 统计任何分数、名次和邻居窗口差异。全部门槛通过后才切 manifest;“任务成功退出”本身不构成正确性证据。