大多數Feed流產品都包含兩種Feed流,壹種是基於算法推薦,另壹種是基於關註(好友關系)。例如下圖中的微博和知乎,頂欄的頁卡都包含“關註”和“推薦”這兩種。兩種Feed流背後用到的技術差別會比較大。本文將重點探索壹下“關註”頁卡的後臺實現方式。
圖片來源:知乎
不同於“推薦”頁卡那種千人前面算法推薦的方式,通常“關註”頁卡所展示的內容先後順序都有固定的規則,最常見的規則是基於時間線來排序,也就是展示“我關註的人所發的帖子,根據發帖時間從晚到早依次排列”。
Feed流實現方案介紹
讀擴散也稱為拉模式,這應該是最符合我們直覺的壹種實現方式。如下圖:
每壹個內容發布者都有壹個自己的發件箱(“我發布的內容”),每當我們發出壹個新帖子,都存入自己的發件箱中。當我們的粉絲來閱讀時,系統首先需要拿到粉絲關註的所有人,然後遍歷所有發布者的發件箱,取出他們所發布的帖子,然後依據發布時間排序,展示給閱讀者。
這種設計,閱讀者讀壹次Feed流,後臺會擴散為N次讀操作(N等於關註的人數)以及壹次聚合操作,因此稱為讀擴散。每次讀Feed流相當於去關註者的收件箱主動拉取帖子,因此也得名拉模式。
這種模式的好處是底層存儲簡單,沒有空間浪費。壞處是每次讀操作會非常重,操作非常多。設想壹下如果我關註的人數非常多,遍歷壹遍我所關註的所有人,並且再聚合壹下,這個系統開銷會非常大,時延上可能達到無法忍受的地步。因此讀擴散主要適用系統中閱讀者關註的人沒那麽多,並且刷Feed流並不頻繁的場景。
拉模式還有壹個比較大的缺點就是分頁不方便,我們刷微博或朋友圈,肯定是隨著大拇指在屏幕不斷劃動,內容壹頁壹頁的從後臺拉取。如果不做其他優化,只采用實時聚合的方式,下滑到比較靠後的頁碼時會非常麻煩。
據統計,大多數Feed流產品的讀寫比大概在100:1,也就是說大部分情況都是刷Feed流看別人發的朋友圈和微博,只有很少情況是自己親自發壹條朋友圈或微博給別人看。因此,讀擴散那種很重的讀邏輯並不適合大多數場景。我們寧願讓發帖的過程復雜壹些,也不願影響用戶讀Feed流的體驗,因此稍微改造壹下前面方案就有了寫擴散。寫擴散也稱為推模式,這種模式會對拉模式的壹些缺點做改進。如下圖:
系統中每個用戶除了有發件箱,也會有自己的收件箱。當發布者發表壹篇帖子的時候,除了往自己發件箱記錄壹下之外,還會遍歷發布者的所有粉絲,往這些粉絲的收件箱也投放壹份相同內容。這樣閱讀者來讀Feed流時,直接從自己的收件箱讀取即可。
這種設計,每次發表帖子,都會擴散為M次寫操作(M等於自己的粉絲數),因此成為寫擴散。每篇帖子都會主動推送到所有粉絲的收件箱,因此也得名推模式。
這種模式可想而知,發壹篇帖子,背後會涉及到很多次的寫操作。通常為了發帖人的用戶體驗,當發布的帖子寫到自己發件箱時,就可以返回發布成功。後臺另外起壹個異步任務,不慌不忙地往粉絲收件箱投遞帖子即可。寫擴散的好處在於通過數據冗余(壹篇帖子會被存儲M份副本),提升了閱讀者的用戶體驗。通常適當的數據冗余不是什麽問題,但是到了微博明星這裏,完全行不通。比如目前微博粉絲量Top2的謝娜與何炅,兩個人微博粉絲過億。
設想壹下,如果單純采用推模式,那每次謝娜何炅發壹條微博,微博後臺都要地震壹次。壹篇微博導致後臺上億次寫操作,這顯然是不可行的。另外由於寫擴散是異步操作,寫的太慢會導致帖子發出去半天,有些粉絲依然沒能看見,這種體驗也不太好。
通常寫擴散適用於好友量不大的情況,據悉微信朋友圈正是寫擴散模式。每壹名微信用戶的好友上限為5000人,也就是說妳發壹條朋友圈最多也就擴散到5000次寫操作,如果異步任務性能好壹些,完全沒有問題。
讀寫混合也可以稱作推拉結合。這種方式可以兼具讀擴散和寫擴散的優點。我們首先來總結壹下讀擴散和寫擴散的優缺點:
仔細比較壹下讀擴散與寫擴散的優缺點,不難發現兩者的適用場景是互補的。因此在設計後臺存儲的時候,我們如果能夠區分壹下場景,在不同場景下選擇最適合的方案,並且動態調整策略,就實現了讀寫混合模式。如下圖:
對於那些活躍用戶登錄刷Feed流時,他直接從自己的收件箱讀取帖子即可,保證了活躍用戶的體驗。當壹個非活躍的用戶突然登錄刷Feed流時,我們壹方面需要讀他的收件箱,另壹方面需要遍歷他所關註的大V用戶的發件箱提取帖子,並且做壹下聚合展示。在展示完後,系統還需要有個任務來判斷是否有必要將該用戶升級為活躍用戶。因為有讀擴散的場景存在,因此即使是混合模式,每個閱讀者所能關註的人數也要設置上限,例如新浪微博限制每個賬號最多可以關註2000人。如果不設上限,設想壹下有壹位用戶把微博所有賬號全部關註了,那他打開關註列表會讀取到微博全站所有帖子,壹旦出現讀擴散,系統必然崩潰;即使是寫擴散,他的收件箱也無法容納這麽多的微博。
讀寫混合模式下,系統需要做兩個判斷。壹個是哪些用戶屬於大V,我們可以將粉絲量作為壹個判斷指標。另壹個是哪些用戶屬於活躍粉絲,這個判斷標準可以是最近壹次登錄時間等。這兩處判斷標準就需要在系統發展過程中動態地識別和調整,沒有固定公式了。
可以看出讀寫結合模式綜合了兩種模式的優點,屬於最佳方案。然而他的缺點是系統機制非常復雜,給程序員帶來無數煩惱。通常在項目初期,只有壹兩個開發人員,用戶規模也很小的時候,壹步到位地采用這種混合模式還是要慎重,容易出bug。當項目規模逐漸發展到新浪微博的水平,有壹個大團隊專門來做Feed流時,讀寫混合模式才是必須的。
前文已經敘述了基於時間線的Feed流常見設計方案,但實操起來會比理論要麻煩許多。接下來專門討論壹個困難點——Feed流的分頁。不管是讀擴散還是寫擴散,Feed流本質上是壹個動態列表,列表內容會隨著時間不斷變化。傳統的前端分頁參數使用page_size和page_num,分表表示每頁幾條,以及當前是第幾頁。對於壹個動態列表會有如下問題:
在T1時刻讀取了第壹頁,T2時刻有人新發表了“內容11”,在T3時刻如果來拉取第二頁,會導致錯位出現,“內容6”在第壹頁和第二頁都被返回了。事實上,但凡兩頁之間出現內容的添加或刪除,都會導致錯位問題。
為了解決這壹問題,通常Feed流的分頁入參不會使用page_size和page_num,而是使用last_id來記錄上壹頁最後壹條內容的id。前端讀取下壹頁的時候,必須將last_id作為入參,後臺直接找到last_id對應數據,再往後偏移page_size條數據,返回給前端,這樣就避免了錯位問題。如下圖:
采用last_id的方案有壹個重要條件,就是last_id本身這條數據不可以被硬刪除。設想壹下上圖中T1時刻返回5條數據,last_id為內容6;T2時刻內容6被發布者刪除;那麽T3時刻再來請求第二頁,我們根本找不到last_id對應的數據了,也就無法確認分頁偏移量。通常碰到刪除的場景,我們采用軟刪除方式,只是在內容上置壹個標誌位,表示內容已刪除。由於已經刪除的內容不應該再返回給前端,因此軟刪除模式下,找到last_id並往後偏移page_size條,如果其中有被刪除的數據會導致獲得足夠的數據條數給前端。這裏壹個解決方案是找不夠繼續再往下找,另壹種方案是與前端協商,允許返回條數少於page_size條,page_size只是個建議值。甚至大家約定好了以後,可以不要page_size參數。
實際業務應用
文章最後結合我們自身業務,介紹壹下實際業務場景中碰到的壹個非常特殊的Feed流設計方案。直享直播是壹款直播帶貨工具,主播可以創建壹場未來時刻的直播,到時間後開播賣貨,直播結束後,主播的粉絲可以查看直播回放。這樣,每個直播場次就有三種狀態——預告中(創建壹場直播但還未開播)、直播中、回放。作為觀眾,我可以關註多位主播,這樣從粉絲視角來看,也會有個直播場次的Feed流頁面。這個Feed流最特殊的地方在於它的Feed流排序規則。
Feed流排序規則:
1.我關註的所有主播,正在直播中的場次排在最前;預告中的場次排中間;回放場次排最後
2.多場次都在直播中的,按開播時間從晚到早排序
3.多場次都在預告中的,按預計開播時間從早到晚排序
4.多場次都在回放的,按直播結束時間從晚到早排序
問題分析
本需求最復雜的點在於Feed流內容融入的“狀態”因素,狀態的轉變會直接導致Feed流順序不同。為了更清晰解釋壹下對排序的影響,我們可以用下圖詳細說明:
圖中展示了4個主播的5個直播場次,作為觀眾,當我在T1時刻打開頁面,看到的順序是場次3在最上方,其余場次均在預告狀態,按照預計開播時間從早到晚展示。當我在T2時刻打開頁面,場次5在最上方,其余有三場在預告狀態排在中間,場次3已經結束了所以排在最後。以此類推,直到所有直播都結束,所有場次最終的狀態都會變為回放。
這裏需要註意壹點,如果我在T1時刻打開第壹頁,然後盯著頁面不動,壹直盯到T4時刻再下劃到第二頁,這時上壹頁的last_id,即分頁偏移量很有可能因為直播狀態變化而不知道飛到了什麽位置,這會導致嚴重的錯位問題,以及直播狀態展示不統壹的問題(第壹頁展示的是T1時刻的直播狀態,第二頁展示的是T4時刻的直播狀態)。
直播系統是個單向關系鏈,和微博有些類似,每個觀眾會關註少量主播,每個主播會可能有非常多的關註者。由於有狀態變化的存在,寫擴散幾乎無法實現。因為如果采用寫擴散的方式,每次主播創建直播、直播開播、直播結束這三個事件發生時導致的場次狀態變化,會擴散為非常多次的寫操作,不僅操作復雜,時延上也無法接受。微博之所以可以寫擴散,就是因為壹篇帖子發出後,這篇帖子就不會再有任何影響排序的狀態轉變。在我們場景中,“預告中”與“直播中”是兩個中間態,而“回放”狀態才是所有直播的最終歸宿,壹旦進入回放,這場直播也就不會再有狀態轉變。因此“直播中”與“預告中”狀態可以采用讀擴散方式,“回放”狀態采取寫擴散方式。
最終的方案如下圖所示:
會影響直播狀態的三種事件(創建直播、開播、結束直播)全部采用監聽隊列異步處理。我們為每壹位主播維護壹個直播中+預告中狀態的優先級隊列。每當監聽到有主播創建直播時,將直播場次加入隊列中,得分為開播的時間戳的相反數(負數)。每當監聽到有主播開播時,把這場直播在隊列中的得分修改為開播時間(正數)。每當監聽到有主播結束直播,則異步地將播放信息投遞到每個觀眾的回放隊列中。
這裏有壹個小技巧,前文提到,直播中狀態按照開播時間從大到小排序,而預告中狀態則按照開播時間從小到大排序,因此如果將預告中狀態的得分全部取開播時間相反數,那排序同樣就成為了從大到小。這樣的轉化可以保證直播中與預告中同處於壹個隊列排序。預告中得分全都為負數,直播中得分全都為正數,最後聚合時可以保證所有直播中全都自然排在預告中前面。
另外前文還提到的另壹個問題是T1時刻拉取第壹頁,T4時刻拉取第二頁,導致第壹頁和第二頁直播間狀態不統壹。解決這個問題的辦法是通過快照方式。當觀眾來拉取第壹頁Feed流時,我們依據當前時間,將全部直播中和預告中狀態的場次建立壹份快照,使用壹個session_id標識,每次前端分頁拉取時,我們直接從快照中讀取即可。如果快照中讀取完畢,證明該觀眾的直播中和預告中場次全部讀完,剩下的則使用回放隊列進行補充。照此壹來,我們的Feed流系統,前端分頁拉取的參數壹***有4個:
每當碰到session_id和last_id為空,則證明用戶想要讀取第壹頁,需要重新構建快照。這裏還有壹個衍生問題,session_id的如何取值?如果不考慮同壹個觀眾在多端登錄的情況,其實每壹位觀眾維護壹個快照id即可,也就是直接將系統用戶id設為session_id;如果考慮多端登錄的情況,則session_id中必須包含每個端的信息,以避免多端快照相互影響;如果不心疼內存,也可以每次隨機壹個字符串作為session_id,並設置壹個足夠長的過期時間,讓快照自然過期。
以上設計,其實系統計算量最大的時刻就是拉取第壹頁,構建快照的開銷。目前的線上數據,對於只關註不到10個主播的觀眾(這也是大多數場景),拉取第壹頁的QPS可以達到1.5萬。如果將第二頁以後的請求也算進來,Feed流的綜合QPS可以達到更高水平,支撐目前的用戶規模已經綽綽有余。如果我們拉取第壹頁時只獲取到前10條即可直接返回,將構建快照操作改為異步,也許QPS可以更高壹些,這可能是後續的優化點。
讀擴散、寫擴散、讀寫混合,幾乎所有基於時間線和關註關系的Feed流都逃不開這三種基本設計模式。具體到實際業務中,可能會有更復雜的場景,比如本文所說的狀態流轉影響排序,微博朋友圈場景中也會有廣告接入、特別關註、熱點話題等可能影響到Feed流排序的因素。這些場景就只能根據業務需求,做相對應的變通了。
轉自: /developer/article/1744756