面試官:如何設(shè)計一個億級熱門視頻排行榜?
大家好,我是秀才。今天我們又來拆解一個系統(tǒng)設(shè)計面試?yán)锝?jīng)常出現(xiàn)的高頻題:如果要實現(xiàn)一個類似抖音這樣的熱門視頻榜單,該怎么設(shè)計?
乍一看,這似乎沒什么難度,直覺上就是按照播放量排個序而已。但一旦把場景放大到抖音這種量級,再加上實時更新、多時間窗口統(tǒng)計等限制,問題就會變得極具挑戰(zhàn)。不僅要求我們理解基礎(chǔ)的數(shù)據(jù)結(jié)構(gòu)(比如堆、排序),還會牽涉到海量數(shù)據(jù)流處理、系統(tǒng)水平擴(kuò)展、故障恢復(fù)以及成本權(quán)衡 等方方面面。
接下來秀才還是以面試維度展開,帶大家把思路打開,從最簡單的單機(jī)實現(xiàn)切入,逐步暴露瓶頸、逐一化解,最后演進(jìn)出一個可以抗住億級數(shù)據(jù)沖擊、同時保證穩(wěn)定和實時性的排行榜架構(gòu)。
1. 需求梳理
假設(shè)現(xiàn)在有一個數(shù)據(jù)量很大的抖音視頻觀看數(shù)據(jù)流(本質(zhì)上就是源源不斷的 VideoID 記錄)。在任意時間點,要求我們能夠準(zhǔn)確統(tǒng)計出某個時間窗口內(nèi)(比如過去 1 小時、1 天、1 個月,或者整個歷史周期)播放次數(shù)最多的前 K 個視頻,并拿到它們的計數(shù)。這里我們對性能再極致夸張一點,假設(shè)這里面試官明確給視頻的播放量計,B站某些短視頻每天的播放量高達(dá)700 億次,并且每秒都會有超過 1 小時的新視頻被上傳。在這種情況下,我們?nèi)绾卧O(shè)計這個排行榜來實現(xiàn)前面的要求呢?
對于系統(tǒng)設(shè)計問題,第一步還是老規(guī)矩,根據(jù)面試官的要求梳理出功能需求和非功能需求
1.1 功能需求
- 核心需求
- 客戶端能夠查詢指定時間周期內(nèi),排名前K(比如最多1000個)的視頻榜單。
- 支持的時間周期是固定的,比如:最近1小時、最近1天、最近1個月,以及全時段總榜。
- 非核心需求(本次設(shè)計暫不考慮)
- 不支持查詢?nèi)我馄鹬箷r間段的榜單。
- 所有查詢都默認(rèn)是從當(dāng)前時刻向前回溯。
1.2 非功能需求
- 核心需求
- 數(shù)據(jù)延遲:從用戶觀看行為發(fā)生,到這個行為被統(tǒng)計進(jìn)排行榜,延遲不能超過1分鐘。
- 精確性:結(jié)果必須是精確的,不允許使用近似計算。
- 高吞吐:系統(tǒng)需要能夠處理海量的視頻觀看事件。
- 海量視頻:系統(tǒng)需要支持海量的視頻總數(shù)。
- 低延遲:查詢排行榜接口的響應(yīng)時間,需要在幾十毫秒(比如10-100ms)內(nèi)。
- 成本可控:系統(tǒng)應(yīng)當(dāng)是經(jīng)濟(jì)高效的,不能依賴無限堆砌機(jī)器來解決問題。
這里我們主要是要梳理出一些性能需求,在非功能性需求中明確這些量化指標(biāo),對后續(xù)的技術(shù)決策至關(guān)重要。特別是幾十毫秒內(nèi)返回結(jié)果,這個要求基本排除了所有查詢時實時計算的方案,我們必須優(yōu)先考慮預(yù)計算的方案。最后,我們可以將需求整理成一個清晰的表格
image
1.3 規(guī)模估算
現(xiàn)在,我們來估算兩個對設(shè)計至關(guān)重要的指標(biāo):每秒的觀看次數(shù)(TPS/QPS)和系統(tǒng)的總視頻數(shù)量。前者可以幫助我們理解系統(tǒng)的寫入吞吐量,后者則決定了存儲空間的量級。
首先來看吞吐量:
# 700億次日播放 / (約10萬秒/天)
70B views/day / (100k seconds/day) = 700k tps70萬TPS!這個量級非常巨大,我們從一開始就必須考慮將流量分片到多臺機(jī)器上處理。接下來估算存儲。首先要確定視頻總量:
# (每秒上傳1小時內(nèi)容 / 平均每個視頻6分鐘) * 每天約10萬秒 = 每天100萬新視頻
Videos/Day = 1 hour content/second / (6 minutes content/video) * (100k seconds/day) = 1M videos/day
# 每天100萬 * 每年365天 * 假設(shè)平臺運營10年 = 約36億視頻
Total Videos = 1M videos/day * 365 days/year * 10 years ≈ 3.6B videos基于這個視頻總量,我們可以估算一下,如果只是簡單地存儲每個視頻ID和它的計數(shù)值,需要多大的空間:
# 約40億視頻 * (每個ID 8字節(jié) + 每個計數(shù)值 8字節(jié)) = 64 GB
Naive Storage = 4B videos * (8 bytes/ID + 8 bytes/count) = 64 GB64GB的數(shù)據(jù)量,如果設(shè)計得當(dāng),完全可以放在內(nèi)存中進(jìn)行處理,特別是當(dāng)我們采用多機(jī)分布式部署時。通過一些數(shù)據(jù)量的估算,我們就對設(shè)計方案的可行性做出判斷。比如,哪些架構(gòu)因此成為可能,哪些又被直接排除,這才是系統(tǒng)設(shè)計中最關(guān)鍵的思考。
2. 底層設(shè)計
梳理完需求之后,我們就可以跟面試官介紹接下來的底層設(shè)計了。
2.1 核心實體定義
在這個排行榜系統(tǒng)中,核心的業(yè)務(wù)實體非常清晰:
- 視頻 (Video):被排行統(tǒng)計的主體。
- 觀看 (View):用戶的觀看行為事件。
- 時間窗口 (Time Window):我們統(tǒng)計排行榜的時間范圍,如1小時、1天等。
從概念層面看,這個問題非常直接,我們甚至可以跳過這部分,把更多時間留給更有挑戰(zhàn)的架構(gòu)設(shè)計部分。
2.2 系統(tǒng)接口設(shè)計
API的設(shè)計將引導(dǎo)我們后續(xù)的討論。在這個場景下,API也相當(dāng)基礎(chǔ),我們只需要一個接口來獲取Top K視頻榜單。
// 發(fā)起GET請求,查詢指定時間窗口(window)的Top K(k)視頻
GET /views/top?window={WINDOW}&k={K}
// 響應(yīng)體
Response:
{
"videos": [
{
"videoId": "...", // 視頻ID
"views": "..." // 觀看次數(shù)
}
// ... more videos
]
}這個部分其實沒有過多的點好設(shè)計,都是一些常規(guī)操作。在面試的時候,也不必跟面試官過多的討論這個環(huán)節(jié)。我們可以簡單介紹下即可。然后重點跟面試官討論接下來的上層設(shè)計部分
3. 上層設(shè)計
面試官:“實體和接口定義清楚了。那么從架構(gòu)層面看,你會如何著手解決這個問題?”
這個時候常規(guī)的設(shè)計思路是這樣,先設(shè)計一個最小可行性系統(tǒng),滿足最基本的功能需求,然后再逐步優(yōu)化到性能,最終滿足我們前面分析的非功能需求。你可以這樣回復(fù)
- 首先,為最簡單的‘全時段總榜’設(shè)計一個基礎(chǔ)但不可擴(kuò)展的單機(jī)解決方案。
- 然后,分析這個基礎(chǔ)方案存在的核心問題,并逐步解決它們,比如單點故障和寫入擴(kuò)展性問題。
- 接著,在可擴(kuò)展的架構(gòu)之上,增加對‘滑動時間窗口’(如1小時、1天)的支持。
- 最后,深入探討剩余的瓶頸,直到時間耗盡。
3.1 基礎(chǔ)解決方案(單機(jī)版)
我們先從一個簡單的、運行在單個服務(wù)器上的全時段總榜解決方案開始。我們可以在內(nèi)存中維護(hù)兩個核心數(shù)據(jù)結(jié)構(gòu):
- 一個巨大的哈希表(
HashMap),Key是VideoID,Value是它的觀看次數(shù)。 - 一個最小堆(
Min-Heap),容量為K(比如1000),用來實時維護(hù)當(dāng)前觀看次數(shù)最多的Top K個視頻。
遍歷40億個視頻來找出最大值是不可行的,所以維護(hù)一個Top-K堆至關(guān)重要。絕大多數(shù)觀看事件都不會觸及這個堆,因為它們的計數(shù)值會遠(yuǎn)低于Top 1000的門檻。處理流程如下:
- 觀看事件(View)從數(shù)據(jù)流(如Kafka)中被消費。
- 對于每一個
VideoID,我們以原子方式在哈希表中將其計數(shù)值加一。 - 獲取更新后的計數(shù)值,并與堆頂元素(即Top K中的最小值)進(jìn)行比較。
- 如果新計數(shù)值大于堆頂元素,就將堆頂元素移除,并將當(dāng)前視頻ID和新計數(shù)值插入堆中,然后調(diào)整堆結(jié)構(gòu)。
- 客戶端查詢時,直接從這個堆中獲取數(shù)據(jù)。
image-1
上面這個基礎(chǔ)版本的方案在單機(jī)上實現(xiàn)非常簡單。但它存在兩個致命問題:
- 吞吐量瓶頸:單機(jī)處理能力遠(yuǎn)低于我們估算的70萬TPS。
- 單點故障:如果這臺主機(jī)宕機(jī),整個服務(wù)就不可用了,所有內(nèi)存數(shù)據(jù)都會丟失
下面我們來逐一分析,看看可以如何優(yōu)化。
3.2 解決單點故障問題
為了讓系統(tǒng)可靠,我們需要優(yōu)雅地處理節(jié)點故障。這里主要有以下三種方案。
3.2.1 數(shù)據(jù)庫計數(shù)
我們可以將哈希表和堆的狀態(tài)持久化到數(shù)據(jù)庫中。這樣,我們的計算服務(wù)就變成了無狀態(tài)的,如果主機(jī)故障,可以簡單地啟動一個新實例,從數(shù)據(jù)庫加載狀態(tài)并繼續(xù)處理。
image-2
這個方案看似簡單,但是仍然沒有完全解決問題,只是把問題轉(zhuǎn)移到了數(shù)據(jù)庫。
- 性能瓶頸:每次計數(shù)更新都需要至少一次數(shù)據(jù)庫往返,這對于70萬TPS的場景是完全不可接受的。
- 并發(fā)問題:更新計數(shù)值和更新堆這兩個操作,在數(shù)據(jù)庫層面很難做到高效的原子性,很容易出現(xiàn)數(shù)據(jù)競爭。
- 索引開銷:為了快速找到Top K,我們需要在數(shù)據(jù)庫的40億條記錄上維護(hù)一個基于計數(shù)值的索引,每次寫入都需要更新它,開銷巨大。
這個方案因為性能問題,基本可以排除。
3.2.2 多副本策略
針對上面的問題,一個簡單的思路就是我們可以為計數(shù)器服務(wù)維護(hù)多個副本。每個副本都消費完整的數(shù)據(jù)流,獨立計算和維護(hù)自己的哈希表與堆。
image-3
老規(guī)矩,在介紹完方案之后,最好對比分析下方案的優(yōu)缺點,突出思考的全面性。
優(yōu)點:
- 讀取可擴(kuò)展:客戶端可以從任意一個副本讀取數(shù)據(jù),分擔(dān)讀取壓力。
- 高可用:當(dāng)一個副本故障時,可以將其從負(fù)載均衡器中移除,服務(wù)不中斷。
缺點:
- 資源浪費:每個副本都處理全量數(shù)據(jù),硬件成本成倍增加。
- 恢復(fù)困難:如果一個實例徹底宕機(jī),我們需要啟動一個新實例,并讓它從數(shù)據(jù)流的最開始進(jìn)行消費,才能追上進(jìn)度,這個“追趕”過程可能會非常漫長。
3.2.3 帶快照的副本
這是方案二的優(yōu)化版。我們依然維護(hù)多個副本,但會定期對每個副本的內(nèi)存狀態(tài)(哈希表和堆)進(jìn)行快照,并存儲到持久化的對象存儲中。
image-4
這樣的話當(dāng)一個新實例啟動時,它首先從對象存儲加載最新的快照到內(nèi)存,然后從Kafka中該快照對應(yīng)的時間點開始消費數(shù)據(jù)流,直到追上實時進(jìn)度。
雖然快照極大地縮短了恢復(fù)時間,但我們依然需要關(guān)注“追趕”期間的吞吐量。如果系統(tǒng)處理能力是140萬TPS,而實時流入是70萬TPS,那么每積壓1秒的數(shù)據(jù),就需要1秒的時間來恢復(fù)。此外,快照本身可能是非常大的文件(數(shù)GB),執(zhí)行快照操作的開銷,以及保證快照內(nèi)部數(shù)據(jù)的一致性,也是需要考慮的技術(shù)細(xì)節(jié)。
副本+快照的機(jī)制,已經(jīng)使我們的系統(tǒng)容錯能力大大提升了。接下來,要解決寫入吞吐量的問題了。
3.3 擴(kuò)展寫入吞吐量
面試官:“副本解決了高可用的問題,但每個副本依然在處理全量的70萬TPS數(shù)據(jù),這在單機(jī)上還是不現(xiàn)實。寫入瓶頸該如何突破?”
寫入擴(kuò)展,其實首選的方案就是分片或分區(qū)。
3.3.1 按ID固定分區(qū)
最基礎(chǔ)的思路是,我們創(chuàng)建P個分片(Shard),每個分片只負(fù)責(zé)處理一部分視頻的數(shù)據(jù)。我們可以用一個簡單的哈希函數(shù),比如 shard_index = hash(video_id) % P,來決定一個觀看事件應(yīng)該被路由到哪個分片處理。
這樣,每個分片都只處理 700k / P 的流量。每個分片內(nèi)部,還是我們之前設(shè)計的“哈希表+堆”的結(jié)構(gòu),并且也采用“副本+快照”的模式來保證高可用。
但是,現(xiàn)在我們有了P個獨立的Top-K堆,客戶端該如何獲取全局的Top-K呢?我們需要引入一個新的聚合服務(wù)(Top-K Service)。這個服務(wù)負(fù)責(zé)查詢所有P個分片的Top-K堆,然后在內(nèi)存中進(jìn)行合并排序,最后返回全局的Top-K結(jié)果給客戶端。
image-5
這種方案仍然存在以下兩個問題:
- 擴(kuò)展性受限:由于分區(qū)是固定的(
% P),如果我們想增加分片的數(shù)量(比如從10個增加到20個),就需要進(jìn)行大規(guī)模的數(shù)據(jù)遷移,非常復(fù)雜。- 聚合開銷:如果P值很大,聚合服務(wù)需要進(jìn)行大量的RPC調(diào)用,這本身可能成為瓶頸。
3.3.2 彈性分區(qū)(推薦)
為了支持動態(tài)擴(kuò)縮容,我們可以使用一致性哈希來代替簡單的取模運算。每個分片在一致性哈希環(huán)上負(fù)責(zé)一個區(qū)段。不同于固定分區(qū)參數(shù),我們可以將其設(shè)為可變參數(shù)以實現(xiàn)彈性擴(kuò)縮容。當(dāng)需要增加容量時,新分片會啟動并從兩個不同的快照(在一致性哈希環(huán)中左右相鄰)讀取數(shù)據(jù),過濾出屬于自己的那部分?jǐn)?shù)據(jù)來完成初始化。
image-6
這個方案的問題在于需要一個服務(wù)注冊與發(fā)現(xiàn)中心(比如 ZooKeeper 或 etcd),來維護(hù)分片與哈希環(huán)的映射關(guān)系,以便聚合服務(wù)和數(shù)據(jù)流消費者知道該查詢哪些分片。增減分片時也需要相應(yīng)的協(xié)調(diào)機(jī)制。
到這里,我們其實已經(jīng)設(shè)計出了一個具備容錯能力和可擴(kuò)展性的全時段總榜解決方案。但我們還沒有滿足所有的功能需求。
4. 擴(kuò)展性設(shè)計
4.1 處理時間窗口
面試官:“OK?,F(xiàn)在我們有了一個可擴(kuò)展的總榜方案。但是最終我們還需要支持最近1小時、1天、1個月的榜單。這種滑動時間窗口的需求,你打算如何實現(xiàn)?”
滑動窗口確實是這類系統(tǒng)里最麻煩的地方。對于全時段總榜,我們只需要不斷累加計數(shù)。但對于時間窗口,我們還需要在數(shù)據(jù)過期時,將它的計數(shù)減掉。這個問題比較復(fù)雜,最佳方案是從一個基礎(chǔ)但可能不夠完善的方案入手,分析其問題,再逐步優(yōu)化。
4.1.1 微桶策略
我們可以為每個視頻,維護(hù)以分鐘為單位的計數(shù)桶。比如,一個Map<[VideoID, MinuteTimestamp], Count>。
當(dāng)需要計算最近1小時的榜單時,我們就將這個視頻在過去60個分鐘桶的計數(shù)值相加。然后,我們?yōu)槊總€時間窗口(1小時、1天、1個月)都維護(hù)一個獨立的Top-K堆。
image-7
但是仔細(xì)思考一下,這個方案顯然是行不通的,問題太多:
- 堆數(shù)據(jù)陳舊:一個視頻可能在上個小時是Top K,但這個小時沒有任何觀看了。它會一直占據(jù)著堆的位置,除非有新的、計數(shù)值更高的視頻把它擠出去。
- 計算成本高昂:為了計算一個視頻一個月的觀看量,我們需要累加
30 * 24 * 60 = 43200個分鐘桶的數(shù)據(jù),開銷巨大。 - 內(nèi)存爆炸:為每個視頻存儲過去一個月的所有分鐘桶,內(nèi)存消耗會急劇膨脹。
4.1.2 刷新堆數(shù)據(jù)
我們可以嘗試修正上面微桶方案的缺陷。
- 解決堆數(shù)據(jù)陳舊:我們可以在查詢前,先刷新堆里的數(shù)據(jù)。遍歷堆里的K個元素,重新計算它們在當(dāng)前時間窗口內(nèi)的準(zhǔn)確計數(shù)值,并根據(jù)新值調(diào)整堆。這個方案非常笨重,讀取性能會很差。
- 解決計算成本:我們可以維護(hù)多種時間粒度的數(shù)據(jù)。除了分鐘桶,我們再額外存儲小時桶、天桶。當(dāng)計算月榜時,我們就可以用天桶來聚合,大大減少需要累加的數(shù)據(jù)量。
- 解決內(nèi)存膨脹:我們可以定期清理掉那些非常古老的數(shù)據(jù)(比如超過1個月的分鐘桶)。
image-8
但是這樣一來,這個優(yōu)化方案變得異常復(fù)雜。雖然我們引入了多層數(shù)據(jù)聚合,但在讀取時可能仍需重建其大部分內(nèi)容,并且仍然需維持整月內(nèi)每分鐘的粒度,并不是一個較優(yōu)的方案。
4.1.3 雙指針法(推薦)
面試官:“你說的這個方法確實可以一定程度解決堆數(shù)據(jù)陳舊問題,但是過于復(fù)雜了。有沒有更簡潔、更貼合我們需求的方案?”
既然我們的數(shù)據(jù)源是像Kafka這樣可以持久化、并支持從任意偏移量(Offset)消費的流,我們可以巧妙地利用這個特性。我們可以為每個時間窗口(1小時、1天、1個月)都維護(hù)獨立的計數(shù)值哈希表和Top-K堆。對于每一個時間窗口,我們都啟動兩組消費者(或者說兩個指針):
- 上升沿指針(Leading Edge):消費實時的數(shù)據(jù)流。每消費一條觀看記錄,就將對應(yīng)視頻在所有時間窗口(1小時、1天、1個月、總榜)的計數(shù)值加一。
- 下降沿指針(Trailing Edge):對于“1小時”榜,這個指針消費的是1小時前的數(shù)據(jù)流;對于“1天”榜,它消費的是1天前的數(shù)據(jù)流,以此類推。每消費一條記錄,就將對應(yīng)視頻在相應(yīng)時間窗口的計數(shù)值減一。
image-9
通過這種一加一的方式,每個時間窗口的計數(shù)值哈希表里,永遠(yuǎn)都精確地存儲著該窗口內(nèi)的總觀看次數(shù)。Top-K堆的數(shù)據(jù)也永遠(yuǎn)不會陳舊。假設(shè)有如下觀看序列:
00:05: 視頻A被觀看00:20: 視頻B被觀看00:40: 視頻B再次被觀看
我們來看1小時榜的計數(shù)值變化:
00:05,{A:1}。(上升沿處理)00:20,{A:1, B:1}。(上升沿處理)00:40,{A:1, B:2}。(上升沿處理)01:05,此時,下降沿指針正好消費到00:05那條A的觀看記錄,于是它執(zhí)行減一操作。計數(shù)值變?yōu)?/span>{A:0, B:2},即{B:2}。01:20,下降沿指針消費到00:20那條B的記錄,計數(shù)值變?yōu)?/span>{B:1}。01:40,下降沿指針消費到00:40那條B的記錄,計數(shù)值變?yōu)?/span>{B:0},即{}(空)。
這個過程完美地維護(hù)了滑動窗口內(nèi)的精確計數(shù)。存在的問題主要有以下幾點:
- 存儲成本:這個方案要求我們在Kafka中至少保留1個月的數(shù)據(jù)。這會顯著增加Kafka集群的存儲成本。
- 消費負(fù)載:我們的總消費負(fù)載增加了(1個上升沿 + 3個下降沿 = 4倍讀?。?/span>
- 堆容量:由于視頻計數(shù)值有增有減,一個視頻可能會頻繁地進(jìn)入和離開Top-K。為了防止頻繁的堆調(diào)整,我們需要將堆的實際容量設(shè)置得比K更大一些(比如2K),查詢時只返回前K個。
雖然這種方案也存在一些問題,但是綜合考量,其仍然是一個可行性較高的方案。
4.2 海量讀請求處理
目前,我們主要討論了如何處理大量瀏覽/寫入操作,但讀取操作呢?考慮到從瀏覽發(fā)生到統(tǒng)計完成有 1 分鐘間隔,最自然的解決方案是添加緩存。我們可以為緩存設(shè)置 1 分鐘的 TTL(生存時間),確保結(jié)果永遠(yuǎn)不會比我們的要求更陳舊。當(dāng)請求到達(dá)時,我們可以直接從緩存中提供服務(wù),或者查詢該請求對應(yīng)堆的所有計數(shù)器,然后將合并后的值重新存儲到緩存中。
image-10
5. 小結(jié)
回顧我們的整個設(shè)計方案,從最初直觀的單機(jī)方案,到多副本快照解決容災(zāi),再到分片架構(gòu)支撐高吞吐,最后利用雙指針法精確維護(hù)滑動時間窗口,并且加上緩存層來優(yōu)化讀取性能??梢钥吹?,一個看似播放量排序的小問題,放到億級視頻量這樣的的場景下,要設(shè)計出一個可行性方法,需要數(shù)據(jù)結(jié)構(gòu)、分布式擴(kuò)展、流式處理、緩存機(jī)制和系統(tǒng)容錯等多方面的綜合考量。它不僅是對技術(shù)細(xì)節(jié)的考察,更是對架構(gòu)思維和權(quán)衡能力的全面檢驗。
































