題幹與適用場景
設計一套競技遊戲的賽季排行榜。一個賽季有 5,000 萬活躍玩家,尖峰每秒接收 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 裡的 5,000 萬個成員拆開。強回答會先保留簡單方案, 再於單 key 的記憶體、寫入或故障復原預算越界時引入應用層分片。
第三項訊號是能否把「5 秒內精確」落實成一致的讀取版本。玩家從舊分數桶移到新分數桶時,先刪後加會 短暫消失,先加後刪會短暫重複;若同時讀取分桶計數與桶內順序,混用兩個時刻的資料也會算錯名次。 可驗證的方案需要讓一次讀取只看同一個已發布版本。
最後看系統是否能實際營運。成績事件要冪等、修正要能降低分數、賽季關閉要凍結一致快照、快取遺失要能 從可信帳本重建。只畫「遊戲服務 → Redis」沒有回答重複事件、熱點、資料遺失、作弊成績撤銷與結算爭議。
回答前需要釐清的問題
- 分數是累計值、最高值還是最新值? 本題取賽季最高分。若是累計值,重複事件會重複加分,事件 ID
與原子增量更關鍵;若允許裁判修正,介面必須接收絕對版本,不能只用 ZINCRBY。
- 同分如何排名? 本題使用 competition ranking:只統計分數嚴格更高的人,同分共享名次。
若要求「先達到者靠前」,排序鍵必須加入可信達成時間,普通 ZSET 的同分字典序不會自動滿足它。
- 精確度與新鮮度分別是多少? 本題要求已發布版本內精確,允許最多 5 秒陳舊。若每次寫入都要線性化後
立即可見,跨分片協調會進入寫入延遲;若允許近似名次,可以用取樣或分位數摘要大幅簡化。
- 哪些榜單必須支援? 主路徑只做全球賽季榜。地區榜可以按地區建立獨立物化檢視;好友榜通常先批次
讀取好友分數再於請求內排序,不應為每位玩家維護一份榜單。
- 讀取請求需要什麼? Top 100、本人名次與附近 20 人。任意深分頁會形成昂貴掃描,應限制視窗,使用
版本化游標,並在版本過期後要求重新定位。
- 成績由誰確認? 只有受信任的對戰結果服務能寫入。用戶端提交只是對戰輸入,不能把用戶端聲稱的分數
直接放進排序索引。
- 賽季何時關閉? 以伺服器事件時間與明確截止點為準。關閉後遲到結果是拒絕、人工複核或進入下一季,
必須由產品契約決定,不能讓背景工作悄悄改動已發獎快照。
- 可用性與持久性邊界是什麼? 排名檢視可以短暫陳舊或重建;可信成績帳本與最終賽季快照不能因快取
故障而遺失。
30 秒回答框架
「我先把名次定義成 1 + 分數嚴格更高的玩家數,所以同分共享名次。受信任的對戰服務寫入帶 event_id 與玩家版本的絕對最高分,持久成績表是事實來源,事件流驅動排行榜物化。規模較小時,一個 Redis Sorted Set 就能用 ZADD、Top-N 與反向 rank 完成;5,000 萬玩家的全球熱 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 page 或基線加增量來重用舊資料,無須每幾秒複製全部 5,000 萬個 成員;對外仍滿足不可變版本語意。epoch 週期、套用延遲與發布延遲的總和必須穩定低於 5 秒;若趕不上,系統 應回報 staleness 並停止宣稱即時,不能發布只完成一半的版本。
第六步:區分全球榜、地區榜與好友榜。
全球榜使用上述分桶索引。地區榜若是固定少量地區,可以讓同一成績事件更新 (season, region) 的獨立 物化檢視;地區來自伺服器維護的玩家資料版本,避免用戶端臨時換區。若地區組合很多,依請求臨時過濾全球 Top-N 會漏掉地區內高排名但全球排名較低的人,應改用獨立索引或明確只支援近似結果。
好友榜通常規模小。先從社交圖服務取得版本化好友 ID,批次讀取同一排行榜版本的分數,在應用服務內依 score DESC, player_id ASC 排序並計算同分名次。為每位玩家維護一份好友 ZSET 會產生巨大的寫入放大: 一位玩家上分要扇出到所有好友的榜單,而且關係變動還要回填。
第七步:估算流量並以測量結果決定分片。
假設一個耐久成績事件連同 envelope 為 128 bytes,尖峰邏輯寫入下界為:
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 必須包含玩家與版本,附近視窗再由分片批次讀取。
分片數不能從文章抄一個常數。用 5,000 萬筆正式環境形狀資料壓測單桶的成員記憶體、ZADD、嚴格大於 計數、範圍讀取與快照建置,記錄 p50/p95/p99、CPU、記憶體、複寫 lag 與復原時間。再用尖峰寫入及故障 餘量反推桶細分與節點數。若單一 ZSET 已在預算內,保留它會比自建分桶協調器更可靠。
第八步:關閉賽季,並讓檢視可重建。
賽季進入 CLOSING 後,截止點前已確認但仍在流中的結果繼續處理,新的不合法結果被拒絕。系統記錄一個 輸入高水位,等全部 materializer 處理到該位置後產生候選終局版本。結算工作核對玩家總數、分桶人數之和、 Top 100、隨機抽樣名次、重複玩家數與每個分片校驗和;通過後把 manifest 標為 FINAL,獎勵服務只讀取 這個不可變版本。之後的爭議進入帶稽核的修正流程,不能靜默改榜。
若排名快取或整個叢集遺失,從目前成績表或不可變帳本依 score_version 重播到新命名空間,建置完整候選 版本並對帳,再原子切換 manifest。重建期間舊版本繼續唯讀;沒有舊版本時回傳明確的暫時不可用或陳舊狀態, 不能把只建一半的榜單視為完整結果。
驗證矩陣包括:同一事件重複與亂序到達;玩家跨桶上分與降分修正;分片在 epoch 中途當機;協調器在 manifest 切換前後當機;一個快取節點遺失;Top 100 邊界出現大量同分;每秒 100 萬次讀取;賽季截止點 附近的遲到結果;全量重建與雙跑比對。持續斷言四個核心不變量,並監控事件到發布的 p99、新舊版本差、 桶傾斜、拒絕事件、重建進度與結果校驗失敗。
高品質示範回答
「我會先確定名次語意。本題同分共享名次,所以一位玩家的 rank 是 1 + score 嚴格更高的人數,Top 100 的顯示位置與業務 rank 要分開。用戶端不能寫可信分數;對戰服務確認結果後,排行榜寫入服務依 event ID 去重,並用玩家 score version 條件寫入絕對最高分。目前成績表是事實來源,提交後的變更流驅動排名檢視。
若規模能裝進一個 Redis 分片,我會先用每個賽季一個 Sorted Set:設定分數、查 Top-N 與按分數統計都很 直接。但 5,000 萬玩家的全球榜是單一熱 key,Redis Cluster 只依 key 分 slot,不會拆開 key 內成員。 本題分數有 0..1,000,000 的界限,因此擴大後依固定分數範圍做 101 個桶。個人名次由較高桶人數前綴、 本桶嚴格高分人數與 1 組成;Top 100 從最高非空桶產生。
跨桶移動涉及兩個 key 與計數,線上原地更新會讓讀取請求看到中間狀態。我會依最多幾秒的 epoch 產生下一版 索引;所有分片套用完冪等變更並通過人數、校驗和對帳後,協調器才原子切換 current manifest。每個回應都 傳回 version 與 as-of,附近查詢沿用同一 version。這樣已發布版本精確,代價是最多 5 秒陳舊。
寫入尖峰若每筆事件以 128 bytes 估算,是 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 萬次讀取會放大扇出 → 修正方法:利用有限分數範圍做分桶計數,或在允許時回傳近似名次。
- 錯誤表現:跨桶先刪後加或先加後刪 → 失敗原因:讀取請求會看到玩家消失或重複,計數也可能偏差 → 修正方法:建置完整版本並透過 manifest 原子發布。
- 錯誤表現:所有成績都用
ZINCRBY→ 失敗原因:重複事件會重複加分,作弊撤銷也無法安全降分 → 修正方法:依事件與玩家版本冪等設定絕對成績。 - 錯誤表現:關閉時間一到就讀取目前快取發獎 → 失敗原因:截止前已確認事件可能仍在流中,快取也不是持久事實來源 → 修正方法:記錄輸入高水位,追平、對帳並凍結 FINAL 版本。
- 錯誤表現:只驗證 Top 100 看起來正確 → 失敗原因:桶計數或單一玩家重複仍可能讓長尾名次全部偏移 → 修正方法:驗證人數守恆、唯一性、抽樣 rank 公式、分片校驗和與全量重建。
追問及應對
追問一:若產品要求成績寫入後立即讀到,還能使用 5 秒快照嗎?
可以把「寫入者 read-your-writes」與「全球已發布名次」分開。寫入回應傳回已提交的玩家新分數與待發布版本; 同一玩家立即重新整理時可以顯示「成績已確認,全球名次更新中」,或在個人卡片顯示新分數,同時仍讓全球 rank 指向最近的完整 manifest。若產品堅持新分數與精確全球名次必須同步回傳,就要讓更新進入全球排序的 同步協調路徑,寫入延遲與故障耦合都會上升,原本的吞吐目標必須重新估算。
追問二:最高分段集中 1,000 萬名玩家,一個桶又成為熱點怎麼辦?
分桶策略屬於版本化中繼資料。先用桶的寫入 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;「工作成功結束」本身不構成 正確性證據。