1. 如何設計 Feed 流系統(tǒng)?

        共 5856字,需瀏覽 12分鐘

         ·

        2022-11-14 23:47

        說到 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ā)關注,感謝支持。


        推薦閱讀:

        瀏覽 54
        點贊
        評論
        收藏
        分享

        手機掃一掃分享

        分享
        舉報
        評論
        圖片
        表情
        推薦
        點贊
        評論
        收藏
        分享

        手機掃一掃分享

        分享
        舉報
          
          

            1. 中文字幕乱码亚洲无线三区 | 男女无遮挡120秒 | 俺来俺去www色官网 | 天天肏夜夜肏 | 中文字幕无码不卡免费视频 |