Feed 流系統雜談

發表於 2021-10-10

什麼是 Feed 流

Feed 流是社交和資訊類應用中常見的一種形態, 比如微博知乎的關注頁、微信的訂閱號和朋友圈等。Feed 流源於 RSS 訂閱, 使用者將自己感興趣的網站的 RSS 地址登記到 RSS 閱讀器中, 在閱讀器裡聚合成的列表就是 Feed 流。

Feed 流的本質是 M 個使用者訂閱了 \(N_i\) 個資訊源形成的多對多關係, Feed 流系統需要聚合使用者訂閱的 \(N_i\) 個 資訊源產生的資訊單元(Feed), 在按照一定順序排列後推送給使用者。接下來我們以關注頁為例來介紹 Feed 流的實現。

Feed 流有兩種基本實現模式:

  • 推模式:當釋出新的 Feed 後(比如某大V發了條微博),將這條內容插入到所有粉絲的 Feed 流中。
  • 拉模式:當使用者上線後遍歷他的關注關係,實時拉取他關注的人釋出的內容並聚合成 Feed 流。

兩種實現方式各有優缺:

  • 推模式的優點在於使用者關注人數較多時, 推模型效能較好。但粉絲數較多的大V釋出內容時需要插入的 Feed 流過多,開銷很大。
  • 拉模式的優點在於粉絲數較多的大V釋出內容時系統不需要短時間內執行大量操作。但使用者關注人數較多時,構建 Feed 流耗時很長。

推模式的 Feed 流系統在使用者釋出新的 Feed 後需要執行較多插入操作,通常需要使用 MQ 來非同步地進行。

在實際應用中我們通常採取推拉結合的實現方式。

Feed 流的儲存

Feed 流系統中需要儲存的資料有 3 部分:

  1. 作者釋出的 Feed 列表,比如個人釋出的所有微博、某個答主回答的所有問題或者某人釋出的朋友圈。這些資料需要可靠的持久化儲存,通常採用 MySQL 等關係型資料庫即可。因為很可能需要按照發布時間排序, 若要使用 NoSQL 最好使用支援有序儲存的資料庫。
  2. 使用者和作者之間的關注關係。同樣需要可靠的持久化儲存,採用 MySQL 等關係型資料庫或者 KV 結構的 NoSQL 資料庫均可。
  3. 使用者的 Feed 流。Feed 流可以根據 Feed 資料庫和關注關係構建,因此可以不做持久化儲存。

最輕量的解決方案是使用 Redis 儲存 Feed 流。在資料量較大 Redis 記憶體不夠用時,也可以採用一些持久化的儲存方案。

有序集合 SortedSet 是非常適合儲存 Feed 流的資料結構,一般以 Feed 的 ID 作為 SortedSet 的 member,時間戳或者熱度值、推薦值作為 score 進行排序。SortedSet 保證了 Feed 不會重複,且插入過程執行緒安全,無論是推拉模式實現起來都非常方便。

為了避免 Redis 中快取的 Feed 流佔用過多記憶體,通常需要給 Feed 流設定 TTL.

Feed 的具體內容儲存可以在 MySQL 中,同時在 Redis 中做一層快取。關注關係可以儲存在 MySQL 中,因為有些大V的粉絲數較多所以不推薦做快取。

持久化儲存

一個使用者的 Feed 流大小是他所有關注者釋出的 Feed 數總和,資料量巨大且增長迅速。在使用者量較大的系統中將所有 Feed 流儲存在 Redis 中需要消耗巨量的記憶體。在必要的時候可以利用持久化儲存作多級快取,比如:將當日活躍使用者的Feed 流資料儲存在 Redis 中, 當月活躍使用者的 Feed 流持久化到資料庫中,長期未活躍的使用者則在他重新登入後使用 MySQL 中儲存的關注關係重新構建 Feed 流。

持久化儲存 Feed 流的資料庫需要有較大的資料容量和吞吐量並且支援排序(Order By 查詢)。鑑於這兩個原因不建議使用資料容量較小的 MySQL 或者不支援排序的 KV 資料庫。

作者推薦使用 Cassandra 來持久化 Feed 流。使用使用者的 UID 作為 Partition Key, Feed 時間戳在前 Feed ID 在後, 共同作為 Clustering Key 用於排序和去重。Cassandra 支援 TTL 可以用來自動清除冷資料。Feed 流資料屬於只追加不修改,與 Cassandra 使用的 LSM 結構非常契合,可以有效減少 Cassandra 進行 Compaction 的負擔。

Feed 流系統優化

線上推 離線拉

一個擁有 10 萬粉絲的大V在釋出微博時,他的粉絲中可能只有 1 千人線上。因此我們常用的優化策略是:對於線上的粉絲採用推模式,將新的 Feed 直接插入到粉絲的資訊流中;對於離線的粉絲採用拉模式,在粉絲登入時遍歷他的關注關係重新構建 Feed 流。

線上推的部分需要計算粉絲和線上使用者的交集,然後進行插入操作。因為線上使用者數和粉絲數都比較大,所以計算交集的過程需要分批進行。比如說每次查詢 100 個粉絲,然後去查詢這 100 個使用者中有多少線上(取交集),直到遍歷完粉絲列表。這個過程類似於將兩個表做 join, 同樣適用小表驅動大表的原則以減少取交集操作的次數, 大多數情況下使用數量較少的粉絲表作為驅動表。(不要問我什麼情況下用線上使用者表做驅動表🙂)

分頁器

由於 Feed 流通常比較大客戶端不可能將所有內容拉取到本地,所以一般需要支援分頁的查詢。若使用常見的 Limit + Offset 分頁器時,可能使用者在瀏覽過程中他關注的使用者釋出了新的 Feed 導致原來的某個 Feed 從第 1 頁擠到了第 2 頁,客戶端在拉取第 2 頁時就會再次拉到這個 Feed。於是客戶端上顯示了兩條相同的內容, 非常影響使用者體驗。

解決重複問題最簡單的方法是使用 LastId + Limit 式的分頁器。以使用 SortedSet 儲存 Feed 流為例,客戶端載入下一頁時不使用序號而是使用本地最後一個 Feed 的時間戳作為遊標,服務端使用 ZRangeByScore 命令獲得更早的 Feed 流, 只要時間戳不重複服務端就不會下發重複的 Feed.

一個簡單實用的避免重複的方法是:以釋出時間作為 score 的整數部分,Feed ID 作為小數部分。這樣 Feed ID 不會干擾排序,此外 Feed ID 不會重複所以 score 也不會重複。

深度分頁

由於 Feed 流比較大而大多數使用者大多數時候只瀏覽最新的內容,因此通常不需要快取全部 Feed 流只需要快取最新的部分即可。由於我們無法阻止使用者繼續向下瀏覽未快取的內容,所以還是得想辦法支援深度分頁。

我們在實踐中採用的解決方案是: 預設快取最近一個月的資料,當使用者快瀏覽完快取內容時則採用拉模式構建最近一年的 Feed 流快取起來。當使用者快讀完最近一年的內容時,繼續構建更舊的 Feed 流直至構建了完整 Feed 流。在追加 Feed 流快取的同時減少它的 TTL, 以避免過大的 Feed 流長期佔據記憶體。