今天我們又來拆解一個系統設計面試裏經常出現的高頻題:如果要實現一個類似抖音這樣的熱門視頻榜單,該怎麼設計?

乍一看,這似乎沒什麼難度,直覺上就是按照播放量排個序而已。但一旦把場景放大到抖音這種量級,再加上實時更新多時間窗口統計等限制,問題就會變得極具挑戰。不僅要求我們理解基礎的數據結構(比如堆、排序),還會牽涉到海量數據流處理、系統水平擴展、故障恢復以及成本權衡 等方方面面。

接下來秀才還是以面試維度展開,帶大家把思路打開,從最簡單的單機實現切入,逐步暴露瓶頸、逐一化解,最後演進出一個可以抗住億級數據衝擊、同時保證穩定和實時性的排行榜架構。

1. 需求梳理

假設現在有一個數據量很大的抖音視頻觀看數據流(本質上就是源源不斷的 VideoID 記錄)。在任意時間點,要求我們要能夠準確統計出某個時間窗口內(比如過去 1 小時、1 天、1 個月,或者整個歷史週期)播放次數最多的前 K 個視頻,並拿到它們的計數。這裏我們對性能再極致誇張一點,假設這裏面試官明確給視頻的播放量計,B站某些短視頻每天的播放量高達700 億次,並且每秒都會有超過 1 小時的新視頻被上傳。在這種情況下,我們如何設計這個排行榜來實現前面的要求呢?

對於系統設計問題,第一步還是老規矩,根據面試官的要求梳理出功能需求和非功能需求

1.1 功能需求

  1. 核心需求
  • 客户端能夠查詢指定時間週期內,排名前K(比如最多1000個)的視頻榜單。
  • 支持的時間週期是固定的,比如:最近1小時、最近1天、最近1個月,以及全時段總榜。
  1. 非核心需求(本次設計暫不考慮)

  • 不支持查詢任意起止時間段的榜單。



  • 所有查詢都默認是從當前時刻向前回溯。


1.2 非功能需求

  1. 核心需求
  • 數據延遲:從用户觀看行為發生,到這個行為被統計進排行榜,延遲不能超過1分鐘。
  • 精確性:結果必須是精確的,不允許使用近似計算。
  • 高吞吐:系統需要能夠處理海量的視頻觀看事件。
  • 海量視頻:系統需要支持海量的視頻總數。
  • 低延遲:查詢排行榜接口的響應時間,需要在幾十毫秒(比如10-100ms)內。
  • 成本可控:系統應當是經濟高效的,不能依賴無限堆砌機器來解決問題。

這裏我們主要是要梳理出一些性能需求,在非功能性需求中明確這些量化指標,對後續的技術決策至關重要。特別是幾十毫秒內返回結果,這個要求基本排除了所有查詢時實時計算的方案,我們必須優先考慮預計算的方案。最後,我們可以將需求整理成一個清晰的表格

如何設計一個億級熱門視頻排行榜?_ide

1.3 規模估算

現在,我們來估算兩個對設計至關重要的指標:每秒的觀看次數(TPS/QPS)和系統的總視頻數量。前者可以幫助我們理解系統的寫入吞吐量,後者則決定了存儲空間的量級。

首先來看吞吐量

# 700億次日播放 / (約10萬秒/天)
70B views/day / (100k seconds/day) = 700k tps

70萬TPS!這個量級非常巨大,我們從一開始就必須考慮將流量分片到多台機器上處理。接下來估算存儲。首先要確定視頻總量:

# (每秒上傳1小時內容 / 平均每個視頻6分鐘) * 每天約10萬秒 = 每天100萬新視頻
Videos/Day = 1 hour content/second / (6 minutes content/video) * (100k seconds/day) = 1M videos/day

# 每天100萬 * 每年365天 * 假設平台運營10年 = 約36億視頻
Total Videos = 1M videos/day * 365 days/year * 10 years ≈ 3.6B videos

基於這個視頻總量,我們可以估算一下,如果只是簡單地存儲每個視頻ID和它的計數值,需要多大的空間:

# 約40億視頻 * (每個ID 8字節 + 每個計數值 8字節) = 64 GB
Naive Storage = 4B videos * (8 bytes/ID + 8 bytes/count) = 64 GB

64GB的數據量,如果設計得當,完全可以放在內存中進行處理,特別是當我們採用多機分佈式部署時。通過一些數據量的估算,我們就對設計方案的可行性做出判斷。比如,哪些架構因此成為可能,哪些又被直接排除,這才是系統設計中最關鍵的思考。

2. 底層設計

梳理完需求之後,我們就可以跟面試官介紹接下來的底層設計了。

2.1 核心實體定義

在這個排行榜系統中,核心的業務實體非常清晰:

  1. 視頻 (Video):被排行統計的主體。
  2. 觀看 (View):用户的觀看行為事件。
  3. 時間窗口 (Time Window):我們統計排行榜的時間範圍,如1小時、1天等。

從概念層面看,這個問題非常直接,我們甚至可以跳過這部分,把更多時間留給更有挑戰的架構設計部分。

2.2 系統接口設計

API的設計將引導我們後續的討論。在這個場景下,API也相當基礎,我們只需要一個接口來獲取Top K視頻榜單。

// 發起GET請求,查詢指定時間窗口(window)的Top K(k)視頻
GET /views/top?window={WINDOW}&k={K}

// 響應體
Response:
{
  "videos": [
    {
      "videoId": "...", // 視頻ID
      "views": "..."   // 觀看次數
    }
    // ... more videos
  ]
}

這個部分其實沒有過多的點好設計,都是一些常規操作。在面試的時候,也不必跟面試官過多的討論這個環節。我們可以簡單介紹下即可。然後重點跟面試官討論接下來的上層設計部分

3. 上層設計

面試官:“實體和接口定義清楚了。那麼從架構層面看,你會如何着手解決這個問題?”

這個時候常規的設計思路是這樣,先設計一個最小可行性系統,滿足最基本的功能需求,然後再逐步優化到性能,最終滿足我們前面分析的非功能需求。你可以這樣回覆

  1. 首先,為最簡單的‘全時段總榜’設計一個基礎但不可擴展的單機解決方案。
  2. 然後,分析這個基礎方案存在的核心問題,並逐步解決它們,比如單點故障和寫入擴展性問題。
  3. 接着,在可擴展的架構之上,增加對‘滑動時間窗口’(如1小時、1天)的支持。
  4. 最後,深入探討剩餘的瓶頸,直到時間耗盡。

3.1 基礎解決方案(單機版)

我們先從一個簡單的、運行在單個服務器上的全時段總榜解決方案開始。我們可以在內存中維護兩個核心數據結構:

  1. 一個巨大的哈希表(HashMap),KeyVideoIDValue是它的觀看次數。
  2. 一個最小堆(Min-Heap),容量為K(比如1000),用來實時維護當前觀看次數最多的Top K個視頻。

遍歷40億個視頻來找出最大值是不可行的,所以維護一個Top-K堆至關重要。絕大多數觀看事件都不會觸及這個堆,因為它們的計數值會遠低於Top 1000的門檻。處理流程如下:

  1. 觀看事件(View)從數據流(如Kafka)中被消費。
  2. 對於每一個VideoID,我們以原子方式在哈希表中將其計數值加一。
  3. 獲取更新後的計數值,並與堆頂元素(即Top K中的最小值)進行比較。
  4. 如果新計數值大於堆頂元素,就將堆頂元素移除,並將當前視頻ID和新計數值插入堆中,然後調整堆結構。
  5. 客户端查詢時,直接從這個堆中獲取數據。

如何設計一個億級熱門視頻排行榜?_數據_02

上面這個基礎版本的方案在單機上實現非常簡單。但它存在兩個致命問題:

  1. 吞吐量瓶頸:單機處理能力遠低於我們估算的70萬TPS。
  2. 單點故障:如果這台主機宕機,整個服務就不可用了,所有內存數據都會丟失

下面我們來逐一分析,看看可以如何優化

3.2 解決單點故障問題

為了讓系統可靠,我們需要優雅地處理節點故障。這裏主要有以下三種方案

3.2.1 數據庫計數

我們可以將哈希表和堆的狀態持久化到數據庫中。這樣,我們的計算服務就變成了無狀態的,如果主機故障,可以簡單地啓動一個新實例,從數據庫加載狀態並繼續處理。

如何設計一個億級熱門視頻排行榜?_數據_03

這個方案看似簡單,但是仍然沒有完全解決問題,只是把問題轉移到了數據庫。

  • 性能瓶頸:每次計數更新都需要至少一次數據庫往返,這對於70萬TPS的場景是完全不可接受的。
  • 併發問題:更新計數值和更新堆這兩個操作,在數據庫層面很難做到高效的原子性,很容易出現數據競爭。
  • 索引開銷:為了快速找到Top K,我們需要在數據庫的40億條記錄上維護一個基於計數值的索引,每次寫入都需要更新它,開銷巨大。

這個方案因為性能問題,基本可以排除。

3.2.2 多副本策略

針對上面的問題,一個簡單的思路就是我們可以為計數器服務維護多個副本。每個副本都消費完整的數據流,獨立計算和維護自己的哈希表與堆。

如何設計一個億級熱門視頻排行榜?_ide_04

老規矩,在介紹完方案之後,最好對比分析下方案的優缺點,突出思考的全面性

優點:

  • 讀取可擴展:客户端可以從任意一個副本讀取數據,分擔讀取壓力。
  • 高可用:當一個副本故障時,可以將其從負載均衡器中移除,服務不中斷。

缺點:

  • 資源浪費:每個副本都處理全量數據,硬件成本成倍增加。
  • 恢復困難:如果一個實例徹底宕機,我們需要啓動一個新實例,並讓它從數據流的最開始進行消費,才能追上進度,這個“追趕”過程可能會非常漫長。

3.2.3 帶快照的副本

這是方案二的優化版。我們依然維護多個副本,但會定期對每個副本的內存狀態(哈希表和堆)進行快照,並存儲到持久化的對象存儲中。

如何設計一個億級熱門視頻排行榜?_ide_05

這樣的話當一個新實例啓動時,它首先從對象存儲加載最新的快照到內存,然後從Kafka中該快照對應的時間點開始消費數據流,直到追上實時進度。

雖然快照極大地縮短了恢復時間,但我們依然需要關注“追趕”期間的吞吐量。如果系統處理能力是140萬TPS,而實時流入是70萬TPS,那麼每積壓1秒的數據,就需要1秒的時間來恢復。此外,快照本身可能是非常大的文件(數GB),執行快照操作的開銷,以及保證快照內部數據的一致性,也是需要考慮的技術細節。

副本+快照的機制,已經使我們的系統容錯能力大大提升了。接下來,要解決寫入吞吐量的問題了。

3.3 擴展寫入吞吐量

面試官:“副本解決了高可用的問題,但每個副本依然在處理全量的70萬TPS數據,這在單機上還是不現實。寫入瓶頸該如何突破?”

寫入擴展,其實首選的方案就是分片或分區

3.3.1 按ID固定分區

最基礎的思路是,我們創建P個分片(Shard),每個分片只負責處理一部分視頻的數據。我們可以用一個簡單的哈希函數,比如 shard_index = hash(video_id) % P,來決定一個觀看事件應該被路由到哪個分片處理。

這樣,每個分片都只處理 700k / P 的流量。每個分片內部,還是我們之前設計的“哈希表+堆”的結構,並且也採用“副本+快照”的模式來保證高可用。

但是,現在我們有了P個獨立的Top-K堆,客户端該如何獲取全局的Top-K呢?我們需要引入一個新的聚合服務(Top-K Service)。這個服務負責查詢所有P個分片的Top-K堆,然後在內存中進行合併排序,最後返回全局的Top-K結果給客户端。

如何設計一個億級熱門視頻排行榜?_數據_06

這種方案仍然存在以下兩個問題

  • 擴展性受限:由於分區是固定的(% P),如果我們想增加分片的數量(比如從10個增加到20個),就需要進行大規模的數據遷移,非常複雜。
  • 聚合開銷:如果P值很大,聚合服務需要進行大量的RPC調用,這本身可能成為瓶頸。

3.3.2 彈性分區(推薦)

為了支持動態擴縮容,我們可以使用一致性哈希來代替簡單的取模運算。每個分片在一致性哈希環上負責一個區段。不同於固定分區參數,我們可以將其設為可變參數以實現彈性擴縮容。當需要增加容量時,新分片會啓動並從兩個不同的快照(在一致性哈希環中左右相鄰)讀取數據,過濾出屬於自己的那部分數據來完成初始化。

如何設計一個億級熱門視頻排行榜?_數據庫_07

這個方案的問題在於需要一個服務註冊與發現中心(比如 ZooKeeper 或 etcd),來維護分片與哈希環的映射關係,以便聚合服務和數據流消費者知道該查詢哪些分片。增減分片時也需要相應的協調機制。

到這裏,我們其實已經設計出了一個具備容錯能力和可擴展性的全時段總榜解決方案。但我們還沒有滿足所有的功能需求。

4. 擴展性設計

4.1 處理時間窗口

面試官:“OK。現在我們有了一個可擴展的總榜方案。但是最終我們還需要支持最近1小時、1天、1個月的榜單。這種滑動時間窗口的需求,你打算如何實現?”

滑動窗口確實是這類系統裏最麻煩的點過。對於全時段總榜,我們只需要不斷累加計數。但對於時間窗口,我們還需要在數據過期時,將它的計數減掉。這個問題比較複雜,最佳方案是從一個基礎但可能不夠完善的方案入手,分析其問題,再逐步優化。

4.1.1 微桶策略

我們可以為每個視頻,維護以分鐘為單位的計數桶。比如,一個Map<[VideoID, MinuteTimestamp], Count>

當需要計算最近1小時的榜單時,我們就將這個視頻在過去60個分鐘桶的計數值相加。然後,我們為每個時間窗口(1小時、1天、1個月)都維護一個獨立的Top-K堆。

如何設計一個億級熱門視頻排行榜?_數據_08

但是仔細思考一下,這個方案顯然是行不通的,問題太多:

  1. 堆數據陳舊:一個視頻可能在上個小時是Top K,但這個小時沒有任何觀看了。它會一直佔據着堆的位置,除非有新的、計數值更高的視頻把它擠出去。
  2. 計算成本高昂:為了計算一個視頻一個月的觀看量,我們需要累加 30 * 24 * 60 = 43200 個分鐘桶的數據,開銷巨大。
  3. 內存爆炸:為每個視頻存儲過去一個月的所有分鐘桶,內存消耗會急劇膨脹。

4.1.2 刷新堆數據

我們可以嘗試修正上面微桶方案的缺陷。

  • 解決堆數據陳舊:我們可以在查詢前,先刷新堆裏的數據。遍歷堆裏的K個元素,重新計算它們在當前時間窗口內的準確計數值,並根據新值調整堆。這個方案非常笨重,讀取性能會很差。
  • 解決計算成本:我們可以維護多種時間粒度的數據。除了分鐘桶,我們再額外存儲小時桶、天桶。當計算月榜時,我們就可以用天桶來聚合,大大減少需要累加的數據量。
  • 解決內存膨脹:我們可以定期清理掉那些非常古老的數據(比如超過1個月的分鐘桶)

如何設計一個億級熱門視頻排行榜?_數據_09

但是這樣一來,這個優化方案變得異常複雜。雖然我們引入了多層數據聚合,但在讀取時可能仍需重建其大部分內容,並且仍然需維持整月內每分鐘的粒度,並不是一個較優的方案。

4.1.3 雙指針法(推薦)

面試官:“你説的這個方法確實可以一定程度解決堆數據陳舊問題,但是過於複雜了。有沒有更簡潔、更貼合我們需求的方案?”

既然我們的數據源是像Kafka這樣可以持久化、並支持從任意偏移量(Offset)消費的流,我們可以巧妙地利用這個特性。我們可以為每個時間窗口(1小時、1天、1個月)都維護獨立的計數值哈希表和Top-K堆。對於每一個時間窗口,我們都啓動兩組消費者(或者説兩個指針):

  1. 上升沿指針(Leading Edge):消費實時的數據流。每消費一條觀看記錄,就將對應視頻在所有時間窗口(1小時、1天、1個月、總榜)的計數值加一
  2. 下降沿指針(Trailing Edge):對於“1小時”榜,這個指針消費的是1小時前的數據流;對於“1天”榜,它消費的是1天前的數據流,以此類推。每消費一條記錄,就將對應視頻在相應時間窗口的計數值減一

如何設計一個億級熱門視頻排行榜?_數據庫_10

通過這種一加一的方式,每個時間窗口的計數值哈希表裏,永遠都精確地存儲着該窗口內的總觀看次數。Top-K堆的數據也永遠不會陳舊。假設有如下觀看序列:

  • 00:05: 視頻A被觀看
  • 00:20: 視頻B被觀看
  • 00:40: 視頻B再次被觀看

我們來看1小時榜的計數值變化:

  • 00:05{A:1}。(上升沿處理)
  • 00:20{A:1, B:1}。(上升沿處理)
  • 00:40{A:1, B:2}。(上升沿處理)
  • 01:05,此時,下降沿指針正好消費到00:05那條A的觀看記錄,於是它執行減一操作。計數值變為{A:0, B:2},即{B:2}
  • 01:20,下降沿指針消費到00:20那條B的記錄,計數值變為{B:1}
  • 01:40,下降沿指針消費到00:40那條B的記錄,計數值變為{B:0},即{}(空)。

這個過程完美地維護了滑動窗口內的精確計數。存在的問題主要有以下幾點

  • 存儲成本:這個方案要求我們在Kafka中至少保留1個月的數據。這會顯著增加Kafka集羣的存儲成本。
  • 消費負載:我們的總消費負載增加了(1個上升沿 + 3個下降沿 = 4倍讀取)。
  • 堆容量:由於視頻計數值有增有減,一個視頻可能會頻繁地進入和離開Top-K。為了防止頻繁的堆調整,我們需要將堆的實際容量設置得比K更大一些(比如2K),查詢時只返回前K個。

雖然這種方案也存在一些問題,但是綜合考量,其仍然是一個可行性較高的方案

4.2 海量讀請求處理

目前我們主要討論瞭如何處理大量瀏覽/寫入操作,但讀取操作呢?考慮到從瀏覽發生到統計完成有 1 分鐘間隔,最自然的解決方案是添加緩存。我們可以為緩存設置 1 分鐘的 TTL(生存時間),確保結果永遠不會比我們的要求更陳舊。當請求到達時,我們可以直接從緩存中提供服務,或者查詢該請求對應堆的所有計數器,然後將合併後的值重新存儲到緩存中。

如何設計一個億級熱門視頻排行榜?_數據_11

5. 小結

回顧我們的整個設計方案,從最初直觀的單機方案,到多副本快照解決容災,再到分片架構支撐高吞吐,最後利用雙指針法精確維護滑動時間窗口,並且加上緩存層來優化讀取性能。

可以看到,一個看似播放量排序的小問題,放到億級視頻量這樣的的場景下,要設計出一個可行性方法,需要數據結構、分佈式擴展、流式處理、緩存機制和系統容錯等多方面的綜合考量。

它不僅是對技術細節的考察,更是對架構思維和權衡能力的全面檢驗。