如何設計 Feed 流系統(tǒng)?
說到 Feed 流,大家肯定都不陌生,我們每天刷的朋友圈,還有各種新聞類 APP,都是采用的這種方式。
那這樣的系統(tǒng)應該如何設計呢?在開發(fā)過程中有哪些點需要注意呢?最近正好看到一篇相關的文章,分享給大家。
轉載信息如下:
作者:Finley
鏈接:https://www.cnblogs.com/Finley/p/16857008.html
Feed 流產(chǎn)品在我們手機 APP 中幾乎無處不在,比如微信朋友圈、新浪微博、今日頭條等。只要大拇指不停地往下劃手機屏幕,就有一條條的信息不斷涌現(xiàn)出來。就像給寵物喂食一樣,只要它吃光了就要不斷再往里加,故此得名 Feed(飼養(yǎng))。
Feed 流產(chǎn)品一般有兩種形態(tài),一種是基于算法推薦,另一種是基于關注關系并按時間排列。「關注頁」這種按時間排序的 Feed 流也被為 Timeline。「關注頁」自然的也被稱為「關注 Timeline」, 存放自己發(fā)送過的 Feed 的頁面被稱為「個人 Timeline」 比如微博的個人頁。
就是這么個 Feed 流系統(tǒng)讓「淘金網(wǎng)」的工程師分成兩派吵作一團,一直吵到了小明辦公室。
推與拉之爭
拉模型
一部分工程師認為應該在查詢時首先查詢用戶關注的所有創(chuàng)作者 uid,然后查詢他們發(fā)布的所有文章,最后按照發(fā)布時間降序排列。

使用拉模型方案用戶每打開一次「關注頁」系統(tǒng)就需要讀取 N 個人的文章(N 為用戶關注的作者數(shù)), 因此拉模型也被稱為讀擴散。
拉模型不需要存儲額外的數(shù)據(jù),而且實現(xiàn)比較簡單:發(fā)布文章時只需要寫入一條 articles 記錄,用戶關注(或取消關注)也只需要增刪一條 followings 記錄。特別是當粉絲數(shù)特別多的頭部作者發(fā)布內容時不需要進行特殊處理,等到讀者進入關注頁時再計算就行了。
拉模型的問題同樣也非常明顯,每次閱讀「關注頁」都需要進行大量讀取和一次重新排序操作,若用戶關注的人數(shù)比較多一次拉取的耗時會長到難以接受的地步。
推模型
另一部分工程師認為在創(chuàng)作者發(fā)布文章時就應該將新文章寫入到粉絲的關注 Timeline,用戶每次閱讀只需要到自己的關注 Timeline 拉取就可以了:

使用推模型方案創(chuàng)作者每次發(fā)布新文章系統(tǒng)就需要寫入 M 條數(shù)據(jù)(M 為創(chuàng)作者的粉絲數(shù)),因此推模型也被稱為寫擴散。推模型的好處在于拉取操作簡單高效,但是缺點一樣非常突出。
首先,在每篇文章要寫入 M 條數(shù)據(jù),在如此恐怖的放大倍率下關注 Timeline 的總體數(shù)據(jù)量將達到一個驚人數(shù)字。而粉絲數(shù)有幾十萬甚至上百萬的頭部創(chuàng)作者每次發(fā)布文章時巨大的寫入量都會導致服務器地震。
通常為了發(fā)布者的體驗文章成功寫入就向前端返回成功,然后通過消息隊列異步地向粉絲的關注 Timeline 推送文章。

其次,推模型的邏輯要復雜很多,不僅發(fā)布新文章時需要實現(xiàn)相關邏輯,新增關注或者取消關注時也要各自實現(xiàn)相應的邏輯,聽上去就要加很多班。
在線推,離線拉
在做出最終決定之前我們先來對比一下推拉模型:
| 優(yōu)點 | 缺點 | |
|---|---|---|
| 推 | 讀取操作快 | 邏輯復雜,消耗大量存儲空間,粉絲數(shù)多的時候會是災難 |
| 拉 | 邏輯簡單,節(jié)約存儲空間 | 讀取效率低下,關注人數(shù)多的時候會出現(xiàn)災難 |
雖然乍看上去拉模型優(yōu)點多多,但是 Feed 流是一個極度讀寫不平衡的場景,讀請求數(shù)比寫請求數(shù)高兩個數(shù)量級也不罕見,這使得拉模型消耗的 CPU 等資源反而更高。
此外推送可以慢慢進行,但是用戶很難容忍打開頁面時需要等待很長時間才能看到內容(很長:指等一秒鐘就覺得卡)。因此拉模型讀取效率低下的缺點使得它的應用受到了極大限制。
我們回過頭來看困擾推模型的這個問題「粉絲數(shù)多的時候會是災難」,我們真的需要將文章推送給作者的每一位粉絲嗎?
仔細想想這也沒有必要,我們知道粉絲群體中活躍用戶是有限的,我們完全可以只推送給活躍粉絲,不給那些已經(jīng)幾個月沒有啟動 App 的用戶推送新文章。
至于不活躍的用戶,在他們回歸后使用拉模型重新構建一下關注 Timeline 就好了。因為不活躍用戶回歸是一個頻率很低的事件,我們有充足的計算資源使用拉模型進行計算。

因為活躍用戶和不活躍用戶常常被叫做「在線用戶」和「離線用戶」,所以這種通過推拉結合處理頭部作者發(fā)布內容的方式也被稱為「在線推,離線拉」。
優(yōu)化存儲
在前面的討論中不管是「關注 Timeline」還是關注關系等數(shù)據(jù)我們都存儲在了 MySQL 中。拉模型可以用 SQL 描述為:
SELECT *
FROM articles
WHERE author_uid IN (
SELECT following_uid
FROM followings
WHERE uid = ?
)
ORDER BY create_time DESC
LIMIT 20;
通過查看執(zhí)行計劃我們發(fā)現(xiàn),臨時表去重+Filesort、這個查詢疊了不少 debuff:

至于推模型,關注 Timeline 巨大的數(shù)據(jù)量和讀寫負載就不是 MySQL 能扛得住的。
遇事不決上 Redis
顯然關注 Timeline 的數(shù)據(jù)是可以通過 articles 和 followings 兩張表中數(shù)據(jù)重建的,其存在只是為了提高讀取操作的效率。這有沒有讓你想起另外一個東西?
沒錯!就是緩存,關注 Timeline 實質上就是一個緩存,也就是說關注 Timeline 與緩存一樣只需要暫時存儲熱門數(shù)據(jù)。
我們可以給關注 Timeline 存儲設置過期時間,若用戶一段時間沒有打開 App 他的關注 Timeline 存儲將被過期釋放,在他回歸之后通過拉模型重建即可。
在使用「在線推,離線拉」策略時我們需要判斷用戶是否在線,在為 Timeline 設置了過期時間后,Timeline 緩存是否存在本身即可以作為用戶是否在線的標志。
就像很少有人翻看三天前的朋友圈一樣,用戶總是關心關注頁中最新的內容,所以關注 Timeline 中也沒有必要存儲完整的數(shù)據(jù)只需要存儲最近一段時間即可,舊數(shù)據(jù)等用戶翻閱時再構建就行了。
魯迅有句話說得好 ——「遇事不決上 Redis」,既然是緩存那么就是 Redis 的用武之地了。

Redis 中有序數(shù)據(jù)結構有列表 List 和有序集合 SortedSet 兩種,對于關注 Timeline 這種需要按時間排列且禁止重復的場景當然閉著眼睛選 SortedSet。
將 article_id 作為有序集合的 member、發(fā)布時間戳作為 score, 關注 Timeline 以及個人 Timeline 都可以緩存起來。
在推送新 Feed 的時候只需要對目標 Timeline 的 SortedSet 進行 ZAdd 操作。若緩存中沒有某個 Timeline 的數(shù)據(jù)就使用拉模型進行重建。
在使用消息隊列進行推送時經(jīng)常出現(xiàn)由于網(wǎng)絡延遲等原因導致重復推送的情況,所幸 article_id 是唯一的,即使出現(xiàn)了重復推送 Timeline 中也不會出現(xiàn)重復內容。
這種重復操作不影響結果的特性有個高大上的名字 ——— 冪等性
當 Redis 中沒有某個 Timeline 的緩存時我們無法判斷是緩存失效了,還是這個用戶的 Timeline 本來就是空的。我們只能通過讀取 MySQL 中的數(shù)據(jù)才能進行判斷,這就是經(jīng)典的緩存穿透問題。
對于時間線這種集合式的還存在第二類緩存穿透問題,正如我們剛剛提到的 Redis 中通常只存儲最近一段時間的 Timeline,當我們讀完了 Redis 中的數(shù)據(jù)之后無法判斷數(shù)據(jù)庫中是否還有更舊的數(shù)據(jù)。
這兩類問題的解決方案是一樣的,我們可以在 SortedSet 中放一個 NoMore 的標志,表示數(shù)據(jù)庫中沒有更多數(shù)據(jù)了。對于 Timeline 本來為空的用戶來說,他們的 SortedSet 中只有一個 NoMore 標志:

最后一點:拉取操作要注意保持原子性不要將重建了一半的 Timeline 暴露出去:

總結一下使用 Redis 做關注時間線的要點:
使用 SortedSet 結構存儲,Member 為 FeedID,Score 為時間戳 給緩存設置自動過期時間,不活躍用戶的緩存會自動被清除。使用「在線推,離線拉」時只給 Timeline 緩存未失效的用戶推送即可 需要在緩存中放置標志來防止緩存擊穿
一層緩存不夠再來一層
雖然 Redis 可以方便的實現(xiàn)高性能的關注 Timeline 系統(tǒng),但是內存空間總是十分珍貴的,我們常常沒有足夠的內存為活躍用戶緩存關注 Timeline。
緩存不足是計算機領域的經(jīng)典問題了,問問你的 CPU 它就會告訴你答案 —— 一級緩存不夠用就做二級緩存,L1、L2、L3 都用光了我才會用內存。
只要是支持有序結構的 NewSQL 數(shù)據(jù)庫比如 Cassandra、HBase 都可以勝任 Redis 的二級緩存:

附上一條 Cassandra 的表結構描述:
-- Cassandra 是一個 Map<PartionKey, SortedMap<ClusteringKey, OtherColumns>> 結構
-- 必須指定一個 PartionKey,順序也只能按照 ClusteringKey 的順序排列
-- 這里 PartionKey 是 uid, ClusteringKey 是 publish_time + article_id
-- publish_time 必須寫在 ClusteringKey 第一列才能按照它進行排序
-- article_id 也寫進 ClusteringKey 是為了防止 publish_time 出現(xiàn)重復
CREATE TABLE taojin.following_timelins (
uid bigint,
publish_time timestamp,
article_id bigin,
PRIMARY KEY (uid, publish_time, article_id)
) WITH default_time_to_live = 60 * 24 * 60 * 60;
這里還是要提醒一下,每多一層緩存便要多考慮一層一致性問題,到底要不要做多級緩存需要仔細權衡。
還有一些細節(jié)要優(yōu)化
分頁器
Feed 流是一個動態(tài)的列表,列表內容會隨著時間不斷變化。傳統(tǒng)的 limit + offset 分頁器會有一些問題:

在 T1 時刻讀取了第一頁,T2時刻有人新發(fā)表了 article 11 ,如果這時來拉取第二頁,會導致 article 6 在第一頁和第二頁都被返回了。
解決這個問題的方法是根據(jù)上一頁最后一條 Feed 的 ID 來拉取下一頁:

使用 Feed ID 來分頁需要先根據(jù) ID 查找 Feed,然后再根據(jù) Feed 的發(fā)布時間讀取下一頁,流程比較麻煩。若作為分頁游標的 Feed 被刪除了,就更麻煩了。
筆者更傾向于使用時間戳來作為游標:

使用時間戳不可避免的會出現(xiàn)兩條 Feed 時間戳相同的問題, 這會讓我們的分頁器不知所措。

這里有個小技巧是將 Feed id 作為 score 的小數(shù)部分,比如 article 11 在 2022-10-27 13:55:11 發(fā)布(時間戳 1666850112), 那么它的 score 為 1666850112.11 小數(shù)部分既不影響按時間排序又避免了重復。

大規(guī)模推送
雖然我們已經(jīng)將推送 Feed 的任務轉移給了 MQ Worker 來處理,但面對將 Feed 推送給上百萬粉絲這樣龐大的任務, 單機的 Worker 還是很難處理。而且一旦處理中途崩潰就需要全部重新開始。
我們可以將大型推送任務拆分成多個子任務,通過消息隊列發(fā)送到多臺 MQ Worker 上進行處理。

因為負責拆分任務的 Dispatcher 只需要掃描粉絲列表負擔和故障概率大大減輕。若某個推送子任務失敗 MQ 會自動進行重試,也無需我們擔心。
總結
至此,我們完成了一個關注 Feed 流系統(tǒng)的設計。總結一下本文我們都討論了哪些內容:
基本模型有兩種。推模型:發(fā)布新 Feed 時推送到每個粉絲的 Timeline;拉模型:打開 Timeline 時拉取所有關注的人發(fā)布的 Feed,重新聚合成粉絲的 Timeline。推模型讀取快,但是推送慢,粉絲數(shù)多的時候峰值負載很重。拉模型沒有峰值問題,但是讀取很慢用戶打開 Timeline 時要等待很久,讀極多寫極少的環(huán)境中消耗的計算資源更多。 頭部用戶的幾十上百萬粉絲中活躍用戶比例很少,所以我們可以只將他們的新 Feed 推送給活躍用戶,不活躍用戶等回歸時再使用拉模型重建 Timeline。即通過「在線推、離線拉」的模式解決推模型的峰值問題。 雖然關注 Timeline 數(shù)據(jù)很多但實際上是一種緩存,沒必要全部存儲。我們按照緩存的思路只存儲活躍用戶、最近一段時間的數(shù)據(jù)即可,沒有緩存的數(shù)據(jù)在用戶閱讀時再通過拉模型重建。 Timeline 推薦使用 Redis 的 SortedSet 結構存儲,Member 為 FeedID,Score 為時間戳。給緩存設置自動過期時間,不活躍用戶的緩存會自動被清除。使用「在線推,離線拉」時只給 Timeline 緩存未失效的用戶推送即可。 在 Redis 內存不足時可以使用 Cassandra 作為 Redis 的二級緩存。
以上就是本文的全部內容,如果覺得還不錯的話歡迎點贊,轉發(fā)和關注,感謝支持。
推薦閱讀:
