0. 前言

常說的 TimeLine 也就是按照時間序排列用戶的動態,一般分為兩種

  • 我的關注頁列表: zhihu.com/follow —— 我關注的所有人的所有動作以時間序排列在 feed 流里
  • 某個人動態列表: zhihu.com/people/yang-h —— 被查看的用戶,所有動作以時間序排列在 feed 流里

個人動態頁的實現比較簡單,只要存一個列表,響應請求翻頁就好了;關注頁的實現比較複雜,需要把所有好友的動態合併成一個列表,然後對這個列表進行翻頁。

最簡單的作法:類似於個人動態頁為每個用戶的關注頁存儲一張列表,好友產生動態後,將動態追加到列表中 —— 即推模型。

顯而易見,這種模型最大的問題就是浪費資源:

  1. 用戶 A 關注了 10000 個活躍用戶,那麼需要為他頻繁的追加列表,自然也會佔用很多存儲,但有可能 A 已經很久不用知乎了……
  2. 用戶 B 是個大 V, 有 100000 個粉絲,每產生一個動態,需要向這 100000 份存儲 中追加動態。

同時,列表可編輯性比較脆弱:

  1. 用戶 A 取消關注了用戶 B,需要在 A 的列表中刪除 B 的動態
  2. 用戶 A 增加關注了用戶 C,需要在 A 的列表中加入 C 的動態,而這種操作的複雜度是恐怖的

所以,一般採用「拉」模型:根據動態發起者組織列表,當用戶請求時,將所有的動作發起者動態 merge 在一起,實時生成 feed 列表 —— 拉模型。細節可參考

楊宏志:知乎首頁 Feed 演進?

zhuanlan.zhihu.com
圖標

1. 架構現狀

當前的知乎的 TimeLine 是兩個完全不相關的項目,單獨維護關注頁、個人動態頁。基本思路如下:

關注頁項目,通過離線腳本監聽用戶動態 XX 點贊了 XX 回答」…… 存入 redis 的 zset (以動作發起者維度組織數據:key 即發起者 id,item 為動作內容 "vote_up_answer_XX",score 為動作時間),以維護動態列表。在線部分,獲取請求用戶的關注列表,拿到這些被關注者最近的動態,按時間序 merge 在一起。

關注頁項目,通過離線腳本監聽用戶動態「XX 點贊了 XX 回答」…… 存入 Hbase (總數據大約 40億+,無法使用 mysql 等方式存儲),以維護動態列表。在線部分,獲取請求用戶的關注列表,通過 scan 對 Hbase 進行翻頁。

舊版 timeline 架構圖

2. 當前架構的問題

這套架構的優點在於設計簡單,新的動態追加到列表中,用戶請求時對列表進行翻頁。然而,簡單背後卻引入了很多頭疼的問題。

架構運行了大約2年多的時間,期間遇到的最大的問題就是兩個 TimeLine 信息不一致。然後就是各種的用戶投訴:啪,丟過來個人動態頁截圖,為什麼這條動態在關注頁沒有傳播!或者,發布了某個動態之後,卻在個人動態頁 check 不到記錄。然而,這種問題研發往往也是一籌莫展,因為上述系統不具體鏈路跟蹤的能力。比如,某個 Feed 關注頁存在,個人動態頁不存在 —— 個人動態頁沒有成功處理插入,還是關注頁沒成功處理刪除??無從查起!!

其次,離線操作很複雜性。由於各種原因,我們可能收到:多條 M 點贊 A 的消息,處理里需要 insert on duplicate key update 即需要更新動態的 create_time:

  1. 查找並刪除之前的記錄 (對 hbase 壓力巨大,而且容易處理失敗造成列表中 feed 重複)
  2. 插入新記錄

無盡的煩惱

  1. 兩套架構,兩套代碼運維成本很高 (個人動態頁,N 年沒有人動過代碼了)
  2. 消息重複消費,冪等邏輯給 DB 造成了巨大壓力 (hbase 搜索重複 feed, 刪除後再插入新的)
  3. 關注頁 redis 關注頁資料庫故障,首頁無法使用 —— 類似於刪庫,剩下的只能是跑路
  4. 沒有 debug 鏈路,用戶投訴限流,卻無從查起
  5. hbase 的 cache 與關注頁的 zset 信息基本重疊,浪費了資源,而且不利於提升個人動態頁性能
  6. ……

所以我們要重構

  1. 不讓兩個列表數據相互打臉
  2. 快速定位動態異常原因,甚至運營自己去定位
    1. 在線計算動態非法被過濾?
    2. 離線消息監聽失敗?消息丟失?
    3. 上游取消了動態?
    4. spam 等機制判斷動態無效?
  3. 發現問題可以極速修復
  4. 節約資源
    1. 統一架構,減少研發學習成本、debug 成本
    2. 減少存儲資源
    3. 性能優化,提高介面性能,節省機器

3. 架構推演

我們要重構,而且我們清晰知道要改成什麼樣子。那麼,怎麼才能達到我們理想中的樣子??

3.1 粗暴方案

不再用兩套存儲、兩套系統。所有動態都寫到 hbase 同一個表裡, 然後關注頁、個人動態頁從這個表裡拉數據;上層架構,根據這個表的設計來計算用戶 feed。 —— 兩個列表不再相互打臉,架構統一,降低運維成本

增加 feed-history 存儲,將任何一條 feed 的生命周期都存儲在裡面。同時配合管理後台,快速知道某條 feed 是否在列表中流通 & 為什麼,以及快速修複列表

粗暴方案

很明顯這樣設計是不行的,為什麼?hbase 抗壓能力有限、性能不穩定。關注頁的高 QPS 以及複雜的計算,會直接導致 hbase 被打死。

3.2 緩存方案

給 hbase 加一層緩存,用戶產生新動態時實時刷新緩存。個人動態頁,關注頁都走緩存。

緩存方案

這樣設計貌似完美了……

可是,我們的 cache 應該怎麼存數據呢?存多長時間的動態?cache miss 了怎麼辦?cache TTL 了怎麼辦?

  1. 如果用戶 A 的 cache miss 了,用戶 A 的粉絲進入關注頁後。用戶 A 的數據需要回源到 Hbase, 由於 hbase 的不穩定可能會導致:1. 關注頁本次響應變慢;2. 粉絲關注 feed 中丟失了 A 的動態 —— 極端狀況就是關注頁一條 feed 都出不來。
  2. 如果 hbase 掛掉,上述兩個問題肯定都會發生。更嚴重的是 hbase 可能掛很久,繼而 cache 都會 TTL,關注頁也就掛了。即使 hbase 恢復了,cache 也得慢慢去熱。關注頁,可能長期不可用
  3. cache 長度的問題。在關注頁設計中,redis zset 只存了最近兩個月的動態;而如果本 cache 需要為個人動態頁服務,那麼簡單實現就是用戶全部的動態。這個存儲浪費了巨大的 redis 資源。
  4. cache 重建,新的動態生成,cache 需要重建。上述的任何一個問題,都會導致此處帶來巨大的 hbase 壓力。

3.3 新版方案

既然有上面的問題,所以在新版的設計中,我們把 cache 升級成了 DB。通過監聽新動態對 redis、hbase 進行雙寫,當然這裡 redis db 只存了兩個月的動態。個人動態頁需要的更長時間範圍的數據,通過 cache 來實現。

個人動態頁最近兩個月的動態請求,拉取 redis db 的數據。舊數據請求才會拉取 hbase,同時為這段 hbase 增加了 cache。 —— 綜合下來,預計 hbase 的壓力可以降到舊版 timeline 系統的 1% 以下。同時,可以大幅度提高個人動態頁的響應速度。

直觀看來,採用了與舊版 timeline 類似的雙 DB 方案。那麼,如何保證兩個頁面數據不會相互打臉呢?1. 統一離線架構,嚴格的冪等設計,從機制上避免數據矛盾;2. 抽樣檢測腳本及時報警,以及快速糾正工具

新版方案

4. 新的挑戰

看去方案很完美,可是實際操作起來是這樣嗎?

4.1 離線腳本保證消息冪等

如果消息處理不冪等怎麼樣?最直觀的是用戶會看到兩條一模一樣的 feed,這又怎麼樣?用戶不接受!會被投訴!然後會很煩的追 bug。所以,冪等很重要。

  1. 從機制層面,kafka 保證至少一次,但多餘的一次就會造成下游的重複消費。此種情況應對比較簡單,將 md5(msg) 在 redis 里存個 key, 接到消息時判斷下,重複則過濾。
  2. 但是,還有一種情況:生產方誤發、補發。這種消息有可能過去很久了,上述去重的 redis miss key 已經失效。或者,這個 msg 與原始的 msg 並不一定一樣 (兩個消息只是業務功能一樣,但 md5 不一樣,比如增加了某個補發標誌位)。
  3. 消費失敗重新消費,這裡需要事務機制。

4.2 最大限度減少 hbase 壓力

雖然我們選擇了用 hbase 作在線業務,但這是不得以啊不得以……知乎可選存儲只有 redis、mysql、hbase。數據太大,redis、mysql 直接棄選,hbase 再怎麼不穩也得上!

可是它不穩不能導致關注頁掛掉!關鍵是,離線更新嚴重依賴 get_duplicate_history。如果 hbase 掛了,關注頁更新也就停了……

4.3 cache 的麻煩並未消失 —— 斷檔問題

cache 長度的問題,緩存用戶兩個月前的全部動態?這無疑浪費了巨大的 redis 資源,同時 cache 重建,會導致巨大的 hbase 壓力。

舊版 timeline 的實現,緩存 到用戶當前的 offset (其實是一個 session 的功能)。但是:

  1. 用戶 A 拉取到 offset=10,sleep 1h, 緩存清空
  2. 用戶 B 拉取到 offset=6,緩存無效, 重建
  3. 用戶 A 翻頁,緩存追加
  4. 用戶 C 拉取頁面,看不到 7,8,9 的信息
斷檔

所以,這是舊版 timeline 系統一個恐怖的 bug。基於這個思路,只能存儲最大的 offset, 而且不能 TTL(或者較長的 TTL),重建會導致巨大的 hbase 壓力。。。 三個條件任一變化,就會出現用戶看到的 feed 斷檔 (丟掉一片動態)。

4.4 cache 的麻煩其實加重 —— 斷檔問題

按設計,兩個月內的動態訪問 redis 的 db,兩個月之前的動態訪問 hbase 並且 cache。

  1. 用戶 A 看用戶 M 的兩個月前的動態頁,建立的緩存 C1
  2. 1分鐘後,redis db 的最舊的動態被剔除 (超出了兩個月)
  3. 用戶 B 看用戶 M 的兩個月前的動態頁,命中 C1 時,直接根據 C1 翻頁 (丟掉了一些動態)

基於這個思路,每剔除一條動態,我們應該重建相應的 cache。難度很大:1. 頻繁重建 hbase 壓力大;2. 離線剔除動態,需要維護在線服務的 cache,數據聯動很大。

4.5 路由策略複雜

用戶請求個人動態頁,需要判斷當前 offset 是定位到 redis.db 還是 hbase。

5. 離線架構

分析完上述挑戰,離線架構最大的問題也就是如何為 hbase 減壓(尤其是讀壓力),如何保證在線邏輯的冪等。

當然,還有衍生問題,怎麼樣存儲數據可以讓管理平台去高效穩定的工作。

5.1 冪等的實現

從機制層面,kafka 保證至少一次,但生產方多發的問題直接通過 redis 去重一把就好了。

但是,還有一種情況:生產方誤發、補發。這種消息有可能過去很久了,上述去重的 redis miss key 已經失效。或者,這個 msg 與原始的 msg 並不一定一樣。這種情況我們可以通過 feed-history 去重。但如果原始 msg 與初發 msg 被同時消費,feed-history 可能未來得及記錄有效信息。這裡就會被當成重複動態,產生線上 bug。只能依賴管理平台,人工干預 >_<|||

消費方失敗重新消費,這裡需要事務機制,這裡還是需要 feed_history 幫忙。

最保底的原則,寧可少動態,不可動態重複。少動態可能會有人沒注意到,重複肯定會被所有人罵死。

5.2 為 hbase 減壓

根據上述流程圖,hbase 壓力來源:獲取歷史。而且也是唯一可以優化的點,因為其它的寫邏輯是為了保證冪等的重要一環。

但是怎麼能優化呢?

讀的目的是什麼?判斷有沒有歷史。而且絕大部分操作是沒有歷史的,所以這裡拿到的一般都是空 list。很特殊的情況下,會有很多歷史(反覆點贊、取消、點贊、取消,線上有個 bad case,history 長度為 7000)。

  1. 增加一個 redis 去判斷有沒有必要去讀 history, 可以為 hbase 減少 99.9% 的壓力
  2. history 是為了讓用戶再次點贊時可以正常流通,惡意的刷贊行為可以直接認為作弊過濾掉

5.3 讓管理平台更好用

我們的管理平台要做什麼?快速定位線上問題,比如為什麼某條動態未流通。我們需要根據用戶反饋,生成 history 表的 row-key;查詢 history 表可以快速拿到 feed 的生命周期(什麼時間插入過,插入的是什麼,什麼時間刪除過……)。

某條動態丟失,我們可以根據 history 的信息快速生成丟失的動態,並插入到資料庫。動態重複,可以根據 history 刪除舊動態。

那麼問題來了,動態丟失,我們怎麼快速生成一個正常的動態呢?如何確保通過 history 可以成功地刪除舊動態呢?

我們通過 history 中記錄的 msg,調用離線腳本中的動態生成函數 make_feed?確實可以生成一個有效的動態插入到資料庫。但是,怎麼保證它 make_feed(msg) 生成的動態,可以用來刪除 hbase 的過期數據呢?

  1. 插入資料庫時,make_feed 為 f1, f1(msg) = feed_1 插入資料庫
  2. n 天后,make_feed 升級為 f2, f2(msg) = feed_2
  3. delete feed_2 根本無法成功刪除舊動態

所以,在 history 中我們記錄了完整的信息。當初收到的 msg, redis db 對應的 key, hbase feed 的 row_key,以及 feed_info。無論離線邏輯是否有 bug,框架會保證 msg 除非未被處理,否則肯定可以被回收。

(實現細節後續文檔會補充。。。)

6. 在線架構

緩存問題很多,但是解決思路很簡單:要大破才能大立,跳出現有思維。舊版的個人動態頁緩存確實是基於 session 的思路。作用很明顯,大 V 的 cache 可以幫 hbase 分流很多壓力。

但是新版的設計中,hbase 的 cache 沒那麼重要。只是幫我把預期有效的數據緩存一下就好了(空間局部原理),即使 miss 也無所謂。本身兩個月動態的 redis db,就 cover 了 hbase 絕大部分壓力。

所以,我們的新 cache 是基於拉取用戶做的,只要做好局部緩存就好了(而且可以極短的 TTL)。整個體系便成了:優先取 redis 存儲,數據不足走 cache, cache miss 走 hbase,取完之後緩存空間局部的列表。這時的 cache 變成了真正意義上的 cache。

不需要考慮存完整列表,因為用戶 A 的 cache 用戶 B 不用到。不需要考慮長 ttl, cache miss 直接重建就好了。

緩存思路的演進

7. 預期收益

不談收益的優化就是耍流氓 ^_^

單從機器資源角度,舊版個人動態頁佔用了 100G 的緩存資源,60個 cpu 的計算資源。新版改進後,緩存預計可以壓縮到 20G, cpu 可以壓縮到 10個。

從介面性能角度,新架構大量減少了 hbase 的壓力,以及對 hbase 的依賴。介面穩定性預計可以達到 99.99%,p95 下降 40%+。

在運維成本方面。兩個頁面架構統一,至少節約 50% 的人力投入;同時重構後的新系統比舊版關注頁、舊版個人頁會有更低的學習成本。

更關鍵的是,新版架構會更好的用戶體驗,減少運營同學的壓力。同時,實現線上問題快速定位,節約運營、研發人力。

推薦閱讀:

相关文章