Design History

功能模塊

https://www.bilibili.com/account/history

為了大部分用戶的基本功能體驗,滿足用戶需求,例如播放歷史查看播放進度同步等。 離線型用戶,app 本地保留歷史記錄數據。

同樣的,也要考慮平台化,視頻、文章、漫畫等業務擴展接入。

  • 變更功能:添加記錄、刪除記錄、清空歷史。
  • 讀取功能:按照 timeline 返回 top N,點查獲取進度信息。
  • 其他功能:暫停/恢復記錄,首次觀察增加經驗等。

歷史記錄類型的業務,是一個極高 tps 寫入,高 qps 讀取的業務服務。 分析清楚系統的 hot path,投入優化,而不是哪哪都去優化。

架構設計

概覽

BFF: app-interface、history

歷史 BFF 層接受來自外部用戶的讀請求,依賴其他例如稿件、漫畫服務來組裝完整的面向歷史業務(頁面)需要的數據的組合。 同時接受來自內部其他業務線的寫請求,通常都是業務方自己進行業務 ID 的判定,然後投遞到歷史服務的 BFF 寫接口中。 最終 BFF 是打包在 app-interface 大雜燴 BFF 中,考慮到隔離性,讀寫流量很大,獨立成 history BFF 服務。

Service: history-service

服務層,去平台業務的邏輯,專注在歷史數據的持久化上(因為對於播放類業務,BFF 專注平台業務數據組織,service 負責數據的讀、寫、刪、清理等操作。播放進度是非常高頻同步的,需要考慮性能優化)。

使用 write-back 的思路,把狀態數據先入分佈式緩存,再回寫數據庫。

Job: history-job

job 消費上游 kafka 的數據,利用消息隊列的堆積能力, 對於存儲層的差速(消費能力跟不上生產速度時),可以進行一定的數據反壓。 配合上游 service 批量打包過來的數據持久化

Upstream: some-app,some-api

整個歷史服務還會被一些外部 gRPC 服務所依賴,所以 history 還充當了內網的 gRPC Provider,這些上游服務, 使用歷史服務的寫接口,把自己業務的數據進行持久化。

歷史服務最重要的設計,就是批量打包(pipeline)聚合數據。將高頻、密集的寫請求先入緩存(write-back),批量消費減少對存儲的直接壓力,類似的設計隨處可見。

history-service

history-service,專注在歷史數據處理。

寫的核心邏輯: 用戶觀看的稿件、漫畫等,帶有進度信息的數據,同一個 id 最後一次的數據即可, 即 last-write win,高頻的用戶端同步邏輯,只需要最後一次數據持久化即可。 我們可以在 in-process 內存中,定時定量來聚合不同用戶的“同一個對象的最後一次進度”,使用 kafka 消息隊列來消除寫入峰值。但同時我們需要保證用戶數據可以實時被觀察到,不能出現上報進度後,需要一陣子才能體現進度變化。所以我們即在內存中打包數據,同時實時寫入到 redis 中,這樣即保證了實時,又避免海量寫入衝擊存儲。

kafka 是為高吞吐設計,超高頻的寫入並不是最優,所以內存聚合和分片算法比較重要,按照 uid 來sharding 數據,寫放大仍然很大,這裡我們使用 region sharding,打包一組數據當作一個 kafka message(比如 uid % 100數據打包)。

history-service

寫邏輯的數據流向: 實時寫 redis -> 內存維護用戶數據 -> 定時/定量寫入到 kafka。 讀的核心邏輯: 歷史數據,實時寫入 redis 後,不會無限制的存儲,會按量截斷,所以分佈式緩存中數據不是完整數據, 歷史數據從 redis sortedset 中讀取後,如果發現尾部數據不足,會觸發 cache-aside 模式,從存儲中回撈數據,但是不會重新回填緩存,因為拉取過去更久遠的數據,屬於用戶緯度的低頻度行為。歷史數據通常是按照 timeline 來組織,游標的 key 可以使用時間戳進行翻頁或者下拉。

history-job

history-job,獲取打包好的用戶數據,進行批量持久化。 上游 history-service 按照 uid region sharding 聚合好的數據,在 job 中消費取出,為了節約傳輸過程,以及 history-service 的 in-process cache 的內存使用,我們只維護了用戶的 uid 以及 id 列表,最小化存儲和傳輸。因為數據是不完整的,我們額外需要從 redis 中按照 id 對應的數據內容,再持久化。從原來的 N 條記錄變為一個用戶一條記錄。 對於存儲的選型,我們認為 HBase 非常合適高密度寫入。後續我們會單獨討論我們經歷過的幾次存儲迭代和選型。

history

history 作為 BFF (Backend For Frontend ),對用戶端提供統一的用戶記錄記錄入口接口,同時也對內提供 gRPC 寫入歷史接口。 如果業務場景中不存在統一的用戶入口訪問歷史記錄,可以去掉 BFF 層,直接使用 history-service 提供讀接口,這樣需要每個業務方自己實現自己的數據組裝。

我們也有類似用戶首次播放、觀看等加經驗或者獎勵積分類似的操作,所以我們這裡依賴 redis,進行判定用戶當天是否是首次訪問, 我們比較容易想到使用 bitmap 或者 bloom filter 來進行判斷,然後往下游 kafka 投遞消息,而不直接依賴業務的某個服務。

因為我們有關閉歷史記錄的功能,這樣每次寫入操作都需要前置讀取一次,是否打開了開關,同樣的每次首次發送獎勵也是一樣,你有更好的辦法嗎?

存儲設計

數據庫設計

我們最早的主力存儲選型是: HBase。

數據寫入: PUT mid, values,只需要寫入到 column_family 的 info 列簇(Family), rowkey 使用用戶 id md5 以後的頭兩位 + 用戶,避免 rowkey 熱點密集到一個 region 中,導致寫/讀熱點。 對於 column_family: info, 存儲一個列 obj_id + obj_type,例如 稿件業務:1、稿件ID: 100,100_1 作為列名, 對於 value 使用 protobuf 序列化一個結構體接入。 所以只需要單次更新 kv store。 另外我們使用 HBase TTL 的能力,只需要保存90天的用戶數據即可。 (刪除同理)

數據讀取: 列表獲取為 GET mid,直接獲取1000條,在內存中排序和翻頁。 點查 GET mid columns,在茫茫多視頻查看當前視頻的閱讀進度,cache miss 會非常嚴重, 雖然支持點查,但是對於上層 cache miss 後,不再回源請求 HBase。

緩存設計

數據寫入: 每次產生的歷史數據,需要立馬更新 redis,使用 sorted set 基於時間排序的列表,member 為業務 ID。 同時存儲一份數據到 redis string 中,使用 protobuf 序列化完整的數據內容。 為了避免 redis 中單個用戶數據無限增長,需要超過一定量後對數據進行截斷。

數據讀取: 分為兩個場景,一個是歷史頁面,這時候使用 sorted set,排序查找即可,拿到列表後,mget 批量獲取 history_content 內容。 另外一個是點查進度,比如我們點擊進入一個視頻詳情頁,這時候直接查找 history_content 進行點查,不再回源 HBase,因為命中率太低。

首次觸發某行為,增加經驗的,我們在緩存設計中,經常使用 bitmap(roaring bitmap)、bloom filter 緩存加速訪問, 但是在使用緩存時,需要注意規避熱點問題,某個key sharding 命中 node 是固定的, 因此我們可以利用構建多組 bitmap 或 bloom filter,來進行打散。 prefix_key = hash(mid) % 1000 根據 prefix_key 找到對應的 cache 再進行操作,這樣 1000 個 key 盡可能均勻的分佈到更小集合的 node,而不會產生數據熱點。 prefix_key 目的是要把key 打散 但是仍然每次觸發行為,都為前置判定,有更好的優化方案嗎?

bloom filter 的誤判率,可以前置計算預估下

Bloom Filter
  • Source from : https://medium.com/@Kadai/%E8%B3%87%E6%96%99%E7%B5%90%E6%A7%8B%E5%A4%A7%E4%BE%BF%E7%95%B6-bloom-filter-58b0320a346d Bloom Filter 有兩個要素:長度為 n 的 bit array 和 m 個獨立的 hash function 當要寫入資料(x)的時候,用所有的 hash function 對 x 進行 hash 後 mod n 得到 m 個位置, 把 bit array 這些位置的 bit 設為 1,就完成了一次寫入。 查詢也是同樣的流程在得到 m 個位置之後,去 bit array 取出相對應的值,如果全都是 1 的話就代表集合中有這個元素,就可以確認這個資料之前曾經出現過。

可用性設計

Write-Back

在 history-service 中實時寫入 redis 數據,因此只需要重點優化緩存架構中,扛住峰值的流量寫入。 之後在服務內存中,使用 map[int]map[int]struct{} 聚合數據,之後利用 chan 在內部發送每個小消息,再聚合成一個大map, 在 sendproc 中,使用 timer 和 定量判定邏輯,發送到下游 kafka 中。

在 history-job 中,獲取消息後,重新去 redis 中回撈數據即: history-content,然後構建完整的數據批量寫入到 HBase 中。 這裡存在兩個風險:

  1. history-service 重啟過程中,預聚合的消息丟失;
  2. history-job 讀取 redis 構建數據,但 redis 丟失;

我們在這裡進行了 trade-off,高收斂比的設計,意味著存在數據丟失的風險,對於歷史場景,非 L0 的業務服務/數據,我們認為極端情況下可接受。

聚合

經過 BFF history 的流量 per-request 都會發送給 history-service,我們最容易想到的優化就是聚合上移來減少發送給下游的 rpc。 但是按照 mid region sharding 的思路非常具有業務的耦合性,所以不應該把邏輯上移,而只是數據上移,所以可以考慮簡單 batch put 請求, 做一個無邏輯的數據聚合再發送給 history-service,這樣可以大大的減少內網的流量,節約資源。

我們發現經過 API Gateway 的流量都會觸發高頻的 per-rpc auth,給內網的 identify-service 帶來了不少壓力。 我們認為大部分歷史行為通過心跳的方式同步進度,為何不連接一個長連接,長連接服務再握手後先進行用戶級的身份驗證,之後維持身份信息, 而不是每次發送 request 都進行驗證,這樣可以大大減少內網的 identify-service 的流量。

我們內網使用 boardcast(goim) 服務維護長連接,長連接一次驗證,不斷使用。

廣播

用戶首次觸發的行為,需要發送消息給下游系統進行觸發其他獎勵等。 如何減少這類一天只用一次的標記位緩存請求?

使用 in-process localcache,只有高頻的用戶訪問,帶來的收益就越大, 我們很容易想到使用 LRU 維護這個集合,但用戶分佈很廣,很難覆蓋,命中率很低。

越源頭解決架構問題,通常越簡單,效率越高。

我們在寫操作(高頻請求)中,把當前的 flag 返回到 API 協議中,作為一個日期值,客戶端保存到本地, 下次請求的時候帶上,如果發現該值在,獲取以後直接使用不再請求緩存,例如: 2021-1-1,發現當前時間還是2021-1-1,直接不再請求 redis, 如果發現當前時間是2021-1-2,需要觸發一次 redis 訪問,返回新的 flag 到客戶端,這樣把狀態廣播同步到任何其他設備,可以大大減少判定緩存。

實現成本在於,你認為的代價高低。

References

© Kimi Tsai all right reserved.            Updated : 2023-07-12 09:04:54

results matching ""

    No results matching ""

    results matching ""

      No results matching ""