'知乎已讀服務的前生今世與未來'

知乎 HBase Redis BigTable 技術 設計 Java 讀書 高可用架構 2019-07-29
"

導讀:對於很多大型網站來說,一些不起眼的小功能反而是實現的難點。對於知乎來說,已讀服務會隨著用戶量和內容數量的增長而平方級增長,而且響應時間要求很短,因此是一個有實現難度的系統。本文作者介紹了知乎已讀服務的架構設計和演進過程,並對很多技術取捨做了深入剖析,十分值得閱讀。

"

導讀:對於很多大型網站來說,一些不起眼的小功能反而是實現的難點。對於知乎來說,已讀服務會隨著用戶量和內容數量的增長而平方級增長,而且響應時間要求很短,因此是一個有實現難度的系統。本文作者介紹了知乎已讀服務的架構設計和演進過程,並對很多技術取捨做了深入剖析,十分值得閱讀。

知乎已讀服務的前生今世與未來

作者簡介:孫曉光,知乎搜索後端負責人,目前承擔知乎搜索後端架構設計以及工程團隊的管理工作。曾多年從事私有云相關產品開發工作關注雲原生技術,TiKV 項目 Committer。

知乎從問答起步,在過去的8 年中逐步成長為一個大規模的綜合性知識內容平臺,目前,知乎上有多達 3000 萬個問題,共收穫了超過 1.3 億個回答,同時知乎還沉澱了數量眾多的文章、電子書以及其他付費內容。知乎通過個性化首頁推薦的方式在海量的信息中高效分發用戶感興趣的優質內容。為了避免給用戶推薦重複的內容,已讀服務會將所有知乎站上用戶深入閱讀或快速掠過的內容長期保存,並將這些數據應用於首頁推薦信息流和個性化推送的已讀過濾。

業務場景,技術挑戰

"

導讀:對於很多大型網站來說,一些不起眼的小功能反而是實現的難點。對於知乎來說,已讀服務會隨著用戶量和內容數量的增長而平方級增長,而且響應時間要求很短,因此是一個有實現難度的系統。本文作者介紹了知乎已讀服務的架構設計和演進過程,並對很多技術取捨做了深入剖析,十分值得閱讀。

知乎已讀服務的前生今世與未來

作者簡介:孫曉光,知乎搜索後端負責人,目前承擔知乎搜索後端架構設計以及工程團隊的管理工作。曾多年從事私有云相關產品開發工作關注雲原生技術,TiKV 項目 Committer。

知乎從問答起步,在過去的8 年中逐步成長為一個大規模的綜合性知識內容平臺,目前,知乎上有多達 3000 萬個問題,共收穫了超過 1.3 億個回答,同時知乎還沉澱了數量眾多的文章、電子書以及其他付費內容。知乎通過個性化首頁推薦的方式在海量的信息中高效分發用戶感興趣的優質內容。為了避免給用戶推薦重複的內容,已讀服務會將所有知乎站上用戶深入閱讀或快速掠過的內容長期保存,並將這些數據應用於首頁推薦信息流和個性化推送的已讀過濾。

業務場景,技術挑戰

知乎已讀服務的前生今世與未來

首頁已讀過濾流程示意圖

從首頁使用已讀服務的流程我們可以看出這個服務的業務模式較為簡單,我們只需要簡單的以用戶為第一維度內容為第二緯度來查詢指定用戶是否已經閱讀過某個內容。但我們並沒因為業務簡單就在設計上放棄了靈活性和普適性。為此我們設計開發了一套支持BigTable 數據模型的 Cache Through 緩衝系統 RBase 來實現已讀服務,一方面充分利用 Cache 的高吞吐低時延能力,另一方面還可以利用靈活的 BigTable 數據模型來輔助業務快速演進。

"

導讀:對於很多大型網站來說,一些不起眼的小功能反而是實現的難點。對於知乎來說,已讀服務會隨著用戶量和內容數量的增長而平方級增長,而且響應時間要求很短,因此是一個有實現難度的系統。本文作者介紹了知乎已讀服務的架構設計和演進過程,並對很多技術取捨做了深入剖析,十分值得閱讀。

知乎已讀服務的前生今世與未來

作者簡介:孫曉光,知乎搜索後端負責人,目前承擔知乎搜索後端架構設計以及工程團隊的管理工作。曾多年從事私有云相關產品開發工作關注雲原生技術,TiKV 項目 Committer。

知乎從問答起步,在過去的8 年中逐步成長為一個大規模的綜合性知識內容平臺,目前,知乎上有多達 3000 萬個問題,共收穫了超過 1.3 億個回答,同時知乎還沉澱了數量眾多的文章、電子書以及其他付費內容。知乎通過個性化首頁推薦的方式在海量的信息中高效分發用戶感興趣的優質內容。為了避免給用戶推薦重複的內容,已讀服務會將所有知乎站上用戶深入閱讀或快速掠過的內容長期保存,並將這些數據應用於首頁推薦信息流和個性化推送的已讀過濾。

業務場景,技術挑戰

知乎已讀服務的前生今世與未來

首頁已讀過濾流程示意圖

從首頁使用已讀服務的流程我們可以看出這個服務的業務模式較為簡單,我們只需要簡單的以用戶為第一維度內容為第二緯度來查詢指定用戶是否已經閱讀過某個內容。但我們並沒因為業務簡單就在設計上放棄了靈活性和普適性。為此我們設計開發了一套支持BigTable 數據模型的 Cache Through 緩衝系統 RBase 來實現已讀服務,一方面充分利用 Cache 的高吞吐低時延能力,另一方面還可以利用靈活的 BigTable 數據模型來輔助業務快速演進。

知乎已讀服務的前生今世與未來

BigTable 數據模型

已讀服務雖然從業務模式看非常簡單,但它在技術上的挑戰確並不低。目前知乎已讀的數據規模已超萬億並以每天接近30 億的速度持續高速增長。與常見的“讀多寫少”的業務不同,已讀服務不僅需要在這樣的存量數據規模下提供在線查詢服務,還同時承載著每秒 4 萬條新紀錄寫入的衝擊。已讀內容過濾作為首頁信息流推薦中對響應時間影響較大的關鍵任務點,它的可用性和響應時間都需要滿足非常高的要求。

綜合業務需求和線上數據來看,已讀服務的要求和挑戰主要有以下幾點:

● 可用性要求「高」:服務於個性化首頁和個性化推送,最重要的流量分發渠道

● 寫入量「大」:峰值每秒寫入40K+ 行記錄,日新增記錄近 30 億條

● 歷史數據「長期」保存:目前已達一萬二千億條記錄

● 查詢吞吐「高」:在線查詢峰值30K QPS / 12M+ 條已讀檢查

● 響應時間「敏感」:90ms 超時

早期方案,架構演進

BloomFilter on Redis Cluster

"

導讀:對於很多大型網站來說,一些不起眼的小功能反而是實現的難點。對於知乎來說,已讀服務會隨著用戶量和內容數量的增長而平方級增長,而且響應時間要求很短,因此是一個有實現難度的系統。本文作者介紹了知乎已讀服務的架構設計和演進過程,並對很多技術取捨做了深入剖析,十分值得閱讀。

知乎已讀服務的前生今世與未來

作者簡介:孫曉光,知乎搜索後端負責人,目前承擔知乎搜索後端架構設計以及工程團隊的管理工作。曾多年從事私有云相關產品開發工作關注雲原生技術,TiKV 項目 Committer。

知乎從問答起步,在過去的8 年中逐步成長為一個大規模的綜合性知識內容平臺,目前,知乎上有多達 3000 萬個問題,共收穫了超過 1.3 億個回答,同時知乎還沉澱了數量眾多的文章、電子書以及其他付費內容。知乎通過個性化首頁推薦的方式在海量的信息中高效分發用戶感興趣的優質內容。為了避免給用戶推薦重複的內容,已讀服務會將所有知乎站上用戶深入閱讀或快速掠過的內容長期保存,並將這些數據應用於首頁推薦信息流和個性化推送的已讀過濾。

業務場景,技術挑戰

知乎已讀服務的前生今世與未來

首頁已讀過濾流程示意圖

從首頁使用已讀服務的流程我們可以看出這個服務的業務模式較為簡單,我們只需要簡單的以用戶為第一維度內容為第二緯度來查詢指定用戶是否已經閱讀過某個內容。但我們並沒因為業務簡單就在設計上放棄了靈活性和普適性。為此我們設計開發了一套支持BigTable 數據模型的 Cache Through 緩衝系統 RBase 來實現已讀服務,一方面充分利用 Cache 的高吞吐低時延能力,另一方面還可以利用靈活的 BigTable 數據模型來輔助業務快速演進。

知乎已讀服務的前生今世與未來

BigTable 數據模型

已讀服務雖然從業務模式看非常簡單,但它在技術上的挑戰確並不低。目前知乎已讀的數據規模已超萬億並以每天接近30 億的速度持續高速增長。與常見的“讀多寫少”的業務不同,已讀服務不僅需要在這樣的存量數據規模下提供在線查詢服務,還同時承載著每秒 4 萬條新紀錄寫入的衝擊。已讀內容過濾作為首頁信息流推薦中對響應時間影響較大的關鍵任務點,它的可用性和響應時間都需要滿足非常高的要求。

綜合業務需求和線上數據來看,已讀服務的要求和挑戰主要有以下幾點:

● 可用性要求「高」:服務於個性化首頁和個性化推送,最重要的流量分發渠道

● 寫入量「大」:峰值每秒寫入40K+ 行記錄,日新增記錄近 30 億條

● 歷史數據「長期」保存:目前已達一萬二千億條記錄

● 查詢吞吐「高」:在線查詢峰值30K QPS / 12M+ 條已讀檢查

● 響應時間「敏感」:90ms 超時

早期方案,架構演進

BloomFilter on Redis Cluster

知乎已讀服務的前生今世與未來

最初我們在Redis 集群上使用 BITSET 結構直接存儲已讀數據的 BloomFilter。首先由於缺乏多個位的批量操作,操作放大非常嚴重會消耗非常多的計算資源。其次使用全內存的方式存儲全量數據也拉高了整體成本。最後由於難以預估用戶的閱讀量增速無法以用戶為粒度合理控制 BloomFilter 的尺寸和 False Positive Rate。

HBase

考慮到BloomFilter on Redis 方案存在的問題,我們開始嘗試使用 HBase 來存儲用戶的閱讀歷史並提供在線查詢服務。已讀的業務需求可以非常直觀的映射到 BigTable 的數據模型上。我們將用戶 id 作為 row key,訪問的文檔 id 作為 qualifier 保存下來, 而 timestamp 則恰好可以用來記錄文檔的讀取時間。整個系統的可擴展性和成本都要顯著優於直接使用 Redis Cluster 存儲 BloomFilter 的方案。

隨著已讀數據量級和業務查詢量的迅速增長,已讀數據訪問極度稀疏的特點開始影響到了HBase 的 cache 命中率。HBase 的存儲模型在發生 cache miss 需要訪問存儲的情況下 IO 路徑很長。根據緩衝穿透的層次不同整個請求路徑上可能會經過數個 Java 進程,任何一個進程的 GC 和 IO 都會對這次訪問的 latency 產生顯著的影響,導致響應時間產生較大的波動,而大的響應時間波動是首頁難以接受的。

"

導讀:對於很多大型網站來說,一些不起眼的小功能反而是實現的難點。對於知乎來說,已讀服務會隨著用戶量和內容數量的增長而平方級增長,而且響應時間要求很短,因此是一個有實現難度的系統。本文作者介紹了知乎已讀服務的架構設計和演進過程,並對很多技術取捨做了深入剖析,十分值得閱讀。

知乎已讀服務的前生今世與未來

作者簡介:孫曉光,知乎搜索後端負責人,目前承擔知乎搜索後端架構設計以及工程團隊的管理工作。曾多年從事私有云相關產品開發工作關注雲原生技術,TiKV 項目 Committer。

知乎從問答起步,在過去的8 年中逐步成長為一個大規模的綜合性知識內容平臺,目前,知乎上有多達 3000 萬個問題,共收穫了超過 1.3 億個回答,同時知乎還沉澱了數量眾多的文章、電子書以及其他付費內容。知乎通過個性化首頁推薦的方式在海量的信息中高效分發用戶感興趣的優質內容。為了避免給用戶推薦重複的內容,已讀服務會將所有知乎站上用戶深入閱讀或快速掠過的內容長期保存,並將這些數據應用於首頁推薦信息流和個性化推送的已讀過濾。

業務場景,技術挑戰

知乎已讀服務的前生今世與未來

首頁已讀過濾流程示意圖

從首頁使用已讀服務的流程我們可以看出這個服務的業務模式較為簡單,我們只需要簡單的以用戶為第一維度內容為第二緯度來查詢指定用戶是否已經閱讀過某個內容。但我們並沒因為業務簡單就在設計上放棄了靈活性和普適性。為此我們設計開發了一套支持BigTable 數據模型的 Cache Through 緩衝系統 RBase 來實現已讀服務,一方面充分利用 Cache 的高吞吐低時延能力,另一方面還可以利用靈活的 BigTable 數據模型來輔助業務快速演進。

知乎已讀服務的前生今世與未來

BigTable 數據模型

已讀服務雖然從業務模式看非常簡單,但它在技術上的挑戰確並不低。目前知乎已讀的數據規模已超萬億並以每天接近30 億的速度持續高速增長。與常見的“讀多寫少”的業務不同,已讀服務不僅需要在這樣的存量數據規模下提供在線查詢服務,還同時承載著每秒 4 萬條新紀錄寫入的衝擊。已讀內容過濾作為首頁信息流推薦中對響應時間影響較大的關鍵任務點,它的可用性和響應時間都需要滿足非常高的要求。

綜合業務需求和線上數據來看,已讀服務的要求和挑戰主要有以下幾點:

● 可用性要求「高」:服務於個性化首頁和個性化推送,最重要的流量分發渠道

● 寫入量「大」:峰值每秒寫入40K+ 行記錄,日新增記錄近 30 億條

● 歷史數據「長期」保存:目前已達一萬二千億條記錄

● 查詢吞吐「高」:在線查詢峰值30K QPS / 12M+ 條已讀檢查

● 響應時間「敏感」:90ms 超時

早期方案,架構演進

BloomFilter on Redis Cluster

知乎已讀服務的前生今世與未來

最初我們在Redis 集群上使用 BITSET 結構直接存儲已讀數據的 BloomFilter。首先由於缺乏多個位的批量操作,操作放大非常嚴重會消耗非常多的計算資源。其次使用全內存的方式存儲全量數據也拉高了整體成本。最後由於難以預估用戶的閱讀量增速無法以用戶為粒度合理控制 BloomFilter 的尺寸和 False Positive Rate。

HBase

考慮到BloomFilter on Redis 方案存在的問題,我們開始嘗試使用 HBase 來存儲用戶的閱讀歷史並提供在線查詢服務。已讀的業務需求可以非常直觀的映射到 BigTable 的數據模型上。我們將用戶 id 作為 row key,訪問的文檔 id 作為 qualifier 保存下來, 而 timestamp 則恰好可以用來記錄文檔的讀取時間。整個系統的可擴展性和成本都要顯著優於直接使用 Redis Cluster 存儲 BloomFilter 的方案。

隨著已讀數據量級和業務查詢量的迅速增長,已讀數據訪問極度稀疏的特點開始影響到了HBase 的 cache 命中率。HBase 的存儲模型在發生 cache miss 需要訪問存儲的情況下 IO 路徑很長。根據緩衝穿透的層次不同整個請求路徑上可能會經過數個 Java 進程,任何一個進程的 GC 和 IO 都會對這次訪問的 latency 產生顯著的影響,導致響應時間產生較大的波動,而大的響應時間波動是首頁難以接受的。

知乎已讀服務的前生今世與未來

HBase 緩衝穿透的 IO 路徑

在吸取了最初兩代已讀架構方案的經驗教訓後,我們開始設計實現新一代的已讀服務,在這次的設計中我們在可用性、性能以及擴展性上都設定了更高的目標尤其是以往系統表現不好的性能和擴展性方面我們希望看到更加長足的進步。

● 高可用

● ✓ HBase

● ✓BloomFilter on Redis Cluster

● 高性能

● ✘HBase

● ✓BloomFilter on Redis Cluster

● 易擴展

● ✓HBase

● ✘BloomFilter on Redis Cluster

下面就讓我們一起從高可用、高性能和易擴展這三個角度來思考如何構建一個更好的已讀服務滿足好業務的需求和挑戰。

高可用

當我們討論高可用的時候,也意味著我們已經意識到故障是無時無刻都在發生的,依賴傳統人工運維的方式來保證複雜系統的高可用是不現實的。我們需要以系統化的方式對各個組件的狀態進行探測感知他們發生的故障。並且我們需要為系統中的組件設計自愈機制,當故障發生時可以不經人工干預而自動的恢復。最後我們還需要隔離各種故障所產生的變化,讓業務側儘可能對故障的發生和恢復無感知。

"

導讀:對於很多大型網站來說,一些不起眼的小功能反而是實現的難點。對於知乎來說,已讀服務會隨著用戶量和內容數量的增長而平方級增長,而且響應時間要求很短,因此是一個有實現難度的系統。本文作者介紹了知乎已讀服務的架構設計和演進過程,並對很多技術取捨做了深入剖析,十分值得閱讀。

知乎已讀服務的前生今世與未來

作者簡介:孫曉光,知乎搜索後端負責人,目前承擔知乎搜索後端架構設計以及工程團隊的管理工作。曾多年從事私有云相關產品開發工作關注雲原生技術,TiKV 項目 Committer。

知乎從問答起步,在過去的8 年中逐步成長為一個大規模的綜合性知識內容平臺,目前,知乎上有多達 3000 萬個問題,共收穫了超過 1.3 億個回答,同時知乎還沉澱了數量眾多的文章、電子書以及其他付費內容。知乎通過個性化首頁推薦的方式在海量的信息中高效分發用戶感興趣的優質內容。為了避免給用戶推薦重複的內容,已讀服務會將所有知乎站上用戶深入閱讀或快速掠過的內容長期保存,並將這些數據應用於首頁推薦信息流和個性化推送的已讀過濾。

業務場景,技術挑戰

知乎已讀服務的前生今世與未來

首頁已讀過濾流程示意圖

從首頁使用已讀服務的流程我們可以看出這個服務的業務模式較為簡單,我們只需要簡單的以用戶為第一維度內容為第二緯度來查詢指定用戶是否已經閱讀過某個內容。但我們並沒因為業務簡單就在設計上放棄了靈活性和普適性。為此我們設計開發了一套支持BigTable 數據模型的 Cache Through 緩衝系統 RBase 來實現已讀服務,一方面充分利用 Cache 的高吞吐低時延能力,另一方面還可以利用靈活的 BigTable 數據模型來輔助業務快速演進。

知乎已讀服務的前生今世與未來

BigTable 數據模型

已讀服務雖然從業務模式看非常簡單,但它在技術上的挑戰確並不低。目前知乎已讀的數據規模已超萬億並以每天接近30 億的速度持續高速增長。與常見的“讀多寫少”的業務不同,已讀服務不僅需要在這樣的存量數據規模下提供在線查詢服務,還同時承載著每秒 4 萬條新紀錄寫入的衝擊。已讀內容過濾作為首頁信息流推薦中對響應時間影響較大的關鍵任務點,它的可用性和響應時間都需要滿足非常高的要求。

綜合業務需求和線上數據來看,已讀服務的要求和挑戰主要有以下幾點:

● 可用性要求「高」:服務於個性化首頁和個性化推送,最重要的流量分發渠道

● 寫入量「大」:峰值每秒寫入40K+ 行記錄,日新增記錄近 30 億條

● 歷史數據「長期」保存:目前已達一萬二千億條記錄

● 查詢吞吐「高」:在線查詢峰值30K QPS / 12M+ 條已讀檢查

● 響應時間「敏感」:90ms 超時

早期方案,架構演進

BloomFilter on Redis Cluster

知乎已讀服務的前生今世與未來

最初我們在Redis 集群上使用 BITSET 結構直接存儲已讀數據的 BloomFilter。首先由於缺乏多個位的批量操作,操作放大非常嚴重會消耗非常多的計算資源。其次使用全內存的方式存儲全量數據也拉高了整體成本。最後由於難以預估用戶的閱讀量增速無法以用戶為粒度合理控制 BloomFilter 的尺寸和 False Positive Rate。

HBase

考慮到BloomFilter on Redis 方案存在的問題,我們開始嘗試使用 HBase 來存儲用戶的閱讀歷史並提供在線查詢服務。已讀的業務需求可以非常直觀的映射到 BigTable 的數據模型上。我們將用戶 id 作為 row key,訪問的文檔 id 作為 qualifier 保存下來, 而 timestamp 則恰好可以用來記錄文檔的讀取時間。整個系統的可擴展性和成本都要顯著優於直接使用 Redis Cluster 存儲 BloomFilter 的方案。

隨著已讀數據量級和業務查詢量的迅速增長,已讀數據訪問極度稀疏的特點開始影響到了HBase 的 cache 命中率。HBase 的存儲模型在發生 cache miss 需要訪問存儲的情況下 IO 路徑很長。根據緩衝穿透的層次不同整個請求路徑上可能會經過數個 Java 進程,任何一個進程的 GC 和 IO 都會對這次訪問的 latency 產生顯著的影響,導致響應時間產生較大的波動,而大的響應時間波動是首頁難以接受的。

知乎已讀服務的前生今世與未來

HBase 緩衝穿透的 IO 路徑

在吸取了最初兩代已讀架構方案的經驗教訓後,我們開始設計實現新一代的已讀服務,在這次的設計中我們在可用性、性能以及擴展性上都設定了更高的目標尤其是以往系統表現不好的性能和擴展性方面我們希望看到更加長足的進步。

● 高可用

● ✓ HBase

● ✓BloomFilter on Redis Cluster

● 高性能

● ✘HBase

● ✓BloomFilter on Redis Cluster

● 易擴展

● ✓HBase

● ✘BloomFilter on Redis Cluster

下面就讓我們一起從高可用、高性能和易擴展這三個角度來思考如何構建一個更好的已讀服務滿足好業務的需求和挑戰。

高可用

當我們討論高可用的時候,也意味著我們已經意識到故障是無時無刻都在發生的,依賴傳統人工運維的方式來保證複雜系統的高可用是不現實的。我們需要以系統化的方式對各個組件的狀態進行探測感知他們發生的故障。並且我們需要為系統中的組件設計自愈機制,當故障發生時可以不經人工干預而自動的恢復。最後我們還需要隔離各種故障所產生的變化,讓業務側儘可能對故障的發生和恢復無感知。

知乎已讀服務的前生今世與未來

故障監測、自動恢復並隔離變化

高性能

對常見的系統來說,越核心的組件往往狀態越重擴展的代價也越大,層層攔截快速降低需要深入到核心組件的請求量對提高性能是非常有效的手段。首先我們通過緩衝分Slot 的方式來擴展集群所能緩衝的數據規模。接著進一步在 Slot 內通過多副本的方式提升單個 Slot 緩衝數據集的讀取吞吐,將大量的請求攔截在系統的緩衝層進行消化。如果請求不可避免的走到了最終的數據庫組件上,我們還可以利用效率較高的壓縮來繼續降低落到物理設備上的IO壓力。

"

導讀:對於很多大型網站來說,一些不起眼的小功能反而是實現的難點。對於知乎來說,已讀服務會隨著用戶量和內容數量的增長而平方級增長,而且響應時間要求很短,因此是一個有實現難度的系統。本文作者介紹了知乎已讀服務的架構設計和演進過程,並對很多技術取捨做了深入剖析,十分值得閱讀。

知乎已讀服務的前生今世與未來

作者簡介:孫曉光,知乎搜索後端負責人,目前承擔知乎搜索後端架構設計以及工程團隊的管理工作。曾多年從事私有云相關產品開發工作關注雲原生技術,TiKV 項目 Committer。

知乎從問答起步,在過去的8 年中逐步成長為一個大規模的綜合性知識內容平臺,目前,知乎上有多達 3000 萬個問題,共收穫了超過 1.3 億個回答,同時知乎還沉澱了數量眾多的文章、電子書以及其他付費內容。知乎通過個性化首頁推薦的方式在海量的信息中高效分發用戶感興趣的優質內容。為了避免給用戶推薦重複的內容,已讀服務會將所有知乎站上用戶深入閱讀或快速掠過的內容長期保存,並將這些數據應用於首頁推薦信息流和個性化推送的已讀過濾。

業務場景,技術挑戰

知乎已讀服務的前生今世與未來

首頁已讀過濾流程示意圖

從首頁使用已讀服務的流程我們可以看出這個服務的業務模式較為簡單,我們只需要簡單的以用戶為第一維度內容為第二緯度來查詢指定用戶是否已經閱讀過某個內容。但我們並沒因為業務簡單就在設計上放棄了靈活性和普適性。為此我們設計開發了一套支持BigTable 數據模型的 Cache Through 緩衝系統 RBase 來實現已讀服務,一方面充分利用 Cache 的高吞吐低時延能力,另一方面還可以利用靈活的 BigTable 數據模型來輔助業務快速演進。

知乎已讀服務的前生今世與未來

BigTable 數據模型

已讀服務雖然從業務模式看非常簡單,但它在技術上的挑戰確並不低。目前知乎已讀的數據規模已超萬億並以每天接近30 億的速度持續高速增長。與常見的“讀多寫少”的業務不同,已讀服務不僅需要在這樣的存量數據規模下提供在線查詢服務,還同時承載著每秒 4 萬條新紀錄寫入的衝擊。已讀內容過濾作為首頁信息流推薦中對響應時間影響較大的關鍵任務點,它的可用性和響應時間都需要滿足非常高的要求。

綜合業務需求和線上數據來看,已讀服務的要求和挑戰主要有以下幾點:

● 可用性要求「高」:服務於個性化首頁和個性化推送,最重要的流量分發渠道

● 寫入量「大」:峰值每秒寫入40K+ 行記錄,日新增記錄近 30 億條

● 歷史數據「長期」保存:目前已達一萬二千億條記錄

● 查詢吞吐「高」:在線查詢峰值30K QPS / 12M+ 條已讀檢查

● 響應時間「敏感」:90ms 超時

早期方案,架構演進

BloomFilter on Redis Cluster

知乎已讀服務的前生今世與未來

最初我們在Redis 集群上使用 BITSET 結構直接存儲已讀數據的 BloomFilter。首先由於缺乏多個位的批量操作,操作放大非常嚴重會消耗非常多的計算資源。其次使用全內存的方式存儲全量數據也拉高了整體成本。最後由於難以預估用戶的閱讀量增速無法以用戶為粒度合理控制 BloomFilter 的尺寸和 False Positive Rate。

HBase

考慮到BloomFilter on Redis 方案存在的問題,我們開始嘗試使用 HBase 來存儲用戶的閱讀歷史並提供在線查詢服務。已讀的業務需求可以非常直觀的映射到 BigTable 的數據模型上。我們將用戶 id 作為 row key,訪問的文檔 id 作為 qualifier 保存下來, 而 timestamp 則恰好可以用來記錄文檔的讀取時間。整個系統的可擴展性和成本都要顯著優於直接使用 Redis Cluster 存儲 BloomFilter 的方案。

隨著已讀數據量級和業務查詢量的迅速增長,已讀數據訪問極度稀疏的特點開始影響到了HBase 的 cache 命中率。HBase 的存儲模型在發生 cache miss 需要訪問存儲的情況下 IO 路徑很長。根據緩衝穿透的層次不同整個請求路徑上可能會經過數個 Java 進程,任何一個進程的 GC 和 IO 都會對這次訪問的 latency 產生顯著的影響,導致響應時間產生較大的波動,而大的響應時間波動是首頁難以接受的。

知乎已讀服務的前生今世與未來

HBase 緩衝穿透的 IO 路徑

在吸取了最初兩代已讀架構方案的經驗教訓後,我們開始設計實現新一代的已讀服務,在這次的設計中我們在可用性、性能以及擴展性上都設定了更高的目標尤其是以往系統表現不好的性能和擴展性方面我們希望看到更加長足的進步。

● 高可用

● ✓ HBase

● ✓BloomFilter on Redis Cluster

● 高性能

● ✘HBase

● ✓BloomFilter on Redis Cluster

● 易擴展

● ✓HBase

● ✘BloomFilter on Redis Cluster

下面就讓我們一起從高可用、高性能和易擴展這三個角度來思考如何構建一個更好的已讀服務滿足好業務的需求和挑戰。

高可用

當我們討論高可用的時候,也意味著我們已經意識到故障是無時無刻都在發生的,依賴傳統人工運維的方式來保證複雜系統的高可用是不現實的。我們需要以系統化的方式對各個組件的狀態進行探測感知他們發生的故障。並且我們需要為系統中的組件設計自愈機制,當故障發生時可以不經人工干預而自動的恢復。最後我們還需要隔離各種故障所產生的變化,讓業務側儘可能對故障的發生和恢復無感知。

知乎已讀服務的前生今世與未來

故障監測、自動恢復並隔離變化

高性能

對常見的系統來說,越核心的組件往往狀態越重擴展的代價也越大,層層攔截快速降低需要深入到核心組件的請求量對提高性能是非常有效的手段。首先我們通過緩衝分Slot 的方式來擴展集群所能緩衝的數據規模。接著進一步在 Slot 內通過多副本的方式提升單個 Slot 緩衝數據集的讀取吞吐,將大量的請求攔截在系統的緩衝層進行消化。如果請求不可避免的走到了最終的數據庫組件上,我們還可以利用效率較高的壓縮來繼續降低落到物理設備上的IO壓力。

知乎已讀服務的前生今世與未來

分層去併發

易擴展

提升系統擴展性的關鍵在於減少有狀態組件的範圍,在路由和服務發現組件的幫助下,系統中的無狀態組件可以非常輕鬆的擴展擴容。所以通過擴大無狀態服務的範圍,收縮重狀態服務的比例可以顯著的幫助我們提升整個系統的可擴展性。除此之外如果我們能夠設計一些可以從外部系統恢復狀態的弱狀態服務,那麼我們往往可以利用弱狀態的組件來部分替代重狀態組件。隨著弱狀態組件的擴大和重狀態組件的收縮,整個系統的可擴展性可以得到進一步的提升。

"

導讀:對於很多大型網站來說,一些不起眼的小功能反而是實現的難點。對於知乎來說,已讀服務會隨著用戶量和內容數量的增長而平方級增長,而且響應時間要求很短,因此是一個有實現難度的系統。本文作者介紹了知乎已讀服務的架構設計和演進過程,並對很多技術取捨做了深入剖析,十分值得閱讀。

知乎已讀服務的前生今世與未來

作者簡介:孫曉光,知乎搜索後端負責人,目前承擔知乎搜索後端架構設計以及工程團隊的管理工作。曾多年從事私有云相關產品開發工作關注雲原生技術,TiKV 項目 Committer。

知乎從問答起步,在過去的8 年中逐步成長為一個大規模的綜合性知識內容平臺,目前,知乎上有多達 3000 萬個問題,共收穫了超過 1.3 億個回答,同時知乎還沉澱了數量眾多的文章、電子書以及其他付費內容。知乎通過個性化首頁推薦的方式在海量的信息中高效分發用戶感興趣的優質內容。為了避免給用戶推薦重複的內容,已讀服務會將所有知乎站上用戶深入閱讀或快速掠過的內容長期保存,並將這些數據應用於首頁推薦信息流和個性化推送的已讀過濾。

業務場景,技術挑戰

知乎已讀服務的前生今世與未來

首頁已讀過濾流程示意圖

從首頁使用已讀服務的流程我們可以看出這個服務的業務模式較為簡單,我們只需要簡單的以用戶為第一維度內容為第二緯度來查詢指定用戶是否已經閱讀過某個內容。但我們並沒因為業務簡單就在設計上放棄了靈活性和普適性。為此我們設計開發了一套支持BigTable 數據模型的 Cache Through 緩衝系統 RBase 來實現已讀服務,一方面充分利用 Cache 的高吞吐低時延能力,另一方面還可以利用靈活的 BigTable 數據模型來輔助業務快速演進。

知乎已讀服務的前生今世與未來

BigTable 數據模型

已讀服務雖然從業務模式看非常簡單,但它在技術上的挑戰確並不低。目前知乎已讀的數據規模已超萬億並以每天接近30 億的速度持續高速增長。與常見的“讀多寫少”的業務不同,已讀服務不僅需要在這樣的存量數據規模下提供在線查詢服務,還同時承載著每秒 4 萬條新紀錄寫入的衝擊。已讀內容過濾作為首頁信息流推薦中對響應時間影響較大的關鍵任務點,它的可用性和響應時間都需要滿足非常高的要求。

綜合業務需求和線上數據來看,已讀服務的要求和挑戰主要有以下幾點:

● 可用性要求「高」:服務於個性化首頁和個性化推送,最重要的流量分發渠道

● 寫入量「大」:峰值每秒寫入40K+ 行記錄,日新增記錄近 30 億條

● 歷史數據「長期」保存:目前已達一萬二千億條記錄

● 查詢吞吐「高」:在線查詢峰值30K QPS / 12M+ 條已讀檢查

● 響應時間「敏感」:90ms 超時

早期方案,架構演進

BloomFilter on Redis Cluster

知乎已讀服務的前生今世與未來

最初我們在Redis 集群上使用 BITSET 結構直接存儲已讀數據的 BloomFilter。首先由於缺乏多個位的批量操作,操作放大非常嚴重會消耗非常多的計算資源。其次使用全內存的方式存儲全量數據也拉高了整體成本。最後由於難以預估用戶的閱讀量增速無法以用戶為粒度合理控制 BloomFilter 的尺寸和 False Positive Rate。

HBase

考慮到BloomFilter on Redis 方案存在的問題,我們開始嘗試使用 HBase 來存儲用戶的閱讀歷史並提供在線查詢服務。已讀的業務需求可以非常直觀的映射到 BigTable 的數據模型上。我們將用戶 id 作為 row key,訪問的文檔 id 作為 qualifier 保存下來, 而 timestamp 則恰好可以用來記錄文檔的讀取時間。整個系統的可擴展性和成本都要顯著優於直接使用 Redis Cluster 存儲 BloomFilter 的方案。

隨著已讀數據量級和業務查詢量的迅速增長,已讀數據訪問極度稀疏的特點開始影響到了HBase 的 cache 命中率。HBase 的存儲模型在發生 cache miss 需要訪問存儲的情況下 IO 路徑很長。根據緩衝穿透的層次不同整個請求路徑上可能會經過數個 Java 進程,任何一個進程的 GC 和 IO 都會對這次訪問的 latency 產生顯著的影響,導致響應時間產生較大的波動,而大的響應時間波動是首頁難以接受的。

知乎已讀服務的前生今世與未來

HBase 緩衝穿透的 IO 路徑

在吸取了最初兩代已讀架構方案的經驗教訓後,我們開始設計實現新一代的已讀服務,在這次的設計中我們在可用性、性能以及擴展性上都設定了更高的目標尤其是以往系統表現不好的性能和擴展性方面我們希望看到更加長足的進步。

● 高可用

● ✓ HBase

● ✓BloomFilter on Redis Cluster

● 高性能

● ✘HBase

● ✓BloomFilter on Redis Cluster

● 易擴展

● ✓HBase

● ✘BloomFilter on Redis Cluster

下面就讓我們一起從高可用、高性能和易擴展這三個角度來思考如何構建一個更好的已讀服務滿足好業務的需求和挑戰。

高可用

當我們討論高可用的時候,也意味著我們已經意識到故障是無時無刻都在發生的,依賴傳統人工運維的方式來保證複雜系統的高可用是不現實的。我們需要以系統化的方式對各個組件的狀態進行探測感知他們發生的故障。並且我們需要為系統中的組件設計自愈機制,當故障發生時可以不經人工干預而自動的恢復。最後我們還需要隔離各種故障所產生的變化,讓業務側儘可能對故障的發生和恢復無感知。

知乎已讀服務的前生今世與未來

故障監測、自動恢復並隔離變化

高性能

對常見的系統來說,越核心的組件往往狀態越重擴展的代價也越大,層層攔截快速降低需要深入到核心組件的請求量對提高性能是非常有效的手段。首先我們通過緩衝分Slot 的方式來擴展集群所能緩衝的數據規模。接著進一步在 Slot 內通過多副本的方式提升單個 Slot 緩衝數據集的讀取吞吐,將大量的請求攔截在系統的緩衝層進行消化。如果請求不可避免的走到了最終的數據庫組件上,我們還可以利用效率較高的壓縮來繼續降低落到物理設備上的IO壓力。

知乎已讀服務的前生今世與未來

分層去併發

易擴展

提升系統擴展性的關鍵在於減少有狀態組件的範圍,在路由和服務發現組件的幫助下,系統中的無狀態組件可以非常輕鬆的擴展擴容。所以通過擴大無狀態服務的範圍,收縮重狀態服務的比例可以顯著的幫助我們提升整個系統的可擴展性。除此之外如果我們能夠設計一些可以從外部系統恢復狀態的弱狀態服務,那麼我們往往可以利用弱狀態的組件來部分替代重狀態組件。隨著弱狀態組件的擴大和重狀態組件的收縮,整個系統的可擴展性可以得到進一步的提升。

知乎已讀服務的前生今世與未來

弱狀態部分替代重狀態

RBase

在高可用、高性能和易擴展的設計理念下,我們設計實現了RBase 做為已讀服務的根基。現在讓我們來從 RBase 全局設計入手來了解高可用、高性能和易擴展的設計理念是如何落地的。

"

導讀:對於很多大型網站來說,一些不起眼的小功能反而是實現的難點。對於知乎來說,已讀服務會隨著用戶量和內容數量的增長而平方級增長,而且響應時間要求很短,因此是一個有實現難度的系統。本文作者介紹了知乎已讀服務的架構設計和演進過程,並對很多技術取捨做了深入剖析,十分值得閱讀。

知乎已讀服務的前生今世與未來

作者簡介:孫曉光,知乎搜索後端負責人,目前承擔知乎搜索後端架構設計以及工程團隊的管理工作。曾多年從事私有云相關產品開發工作關注雲原生技術,TiKV 項目 Committer。

知乎從問答起步,在過去的8 年中逐步成長為一個大規模的綜合性知識內容平臺,目前,知乎上有多達 3000 萬個問題,共收穫了超過 1.3 億個回答,同時知乎還沉澱了數量眾多的文章、電子書以及其他付費內容。知乎通過個性化首頁推薦的方式在海量的信息中高效分發用戶感興趣的優質內容。為了避免給用戶推薦重複的內容,已讀服務會將所有知乎站上用戶深入閱讀或快速掠過的內容長期保存,並將這些數據應用於首頁推薦信息流和個性化推送的已讀過濾。

業務場景,技術挑戰

知乎已讀服務的前生今世與未來

首頁已讀過濾流程示意圖

從首頁使用已讀服務的流程我們可以看出這個服務的業務模式較為簡單,我們只需要簡單的以用戶為第一維度內容為第二緯度來查詢指定用戶是否已經閱讀過某個內容。但我們並沒因為業務簡單就在設計上放棄了靈活性和普適性。為此我們設計開發了一套支持BigTable 數據模型的 Cache Through 緩衝系統 RBase 來實現已讀服務,一方面充分利用 Cache 的高吞吐低時延能力,另一方面還可以利用靈活的 BigTable 數據模型來輔助業務快速演進。

知乎已讀服務的前生今世與未來

BigTable 數據模型

已讀服務雖然從業務模式看非常簡單,但它在技術上的挑戰確並不低。目前知乎已讀的數據規模已超萬億並以每天接近30 億的速度持續高速增長。與常見的“讀多寫少”的業務不同,已讀服務不僅需要在這樣的存量數據規模下提供在線查詢服務,還同時承載著每秒 4 萬條新紀錄寫入的衝擊。已讀內容過濾作為首頁信息流推薦中對響應時間影響較大的關鍵任務點,它的可用性和響應時間都需要滿足非常高的要求。

綜合業務需求和線上數據來看,已讀服務的要求和挑戰主要有以下幾點:

● 可用性要求「高」:服務於個性化首頁和個性化推送,最重要的流量分發渠道

● 寫入量「大」:峰值每秒寫入40K+ 行記錄,日新增記錄近 30 億條

● 歷史數據「長期」保存:目前已達一萬二千億條記錄

● 查詢吞吐「高」:在線查詢峰值30K QPS / 12M+ 條已讀檢查

● 響應時間「敏感」:90ms 超時

早期方案,架構演進

BloomFilter on Redis Cluster

知乎已讀服務的前生今世與未來

最初我們在Redis 集群上使用 BITSET 結構直接存儲已讀數據的 BloomFilter。首先由於缺乏多個位的批量操作,操作放大非常嚴重會消耗非常多的計算資源。其次使用全內存的方式存儲全量數據也拉高了整體成本。最後由於難以預估用戶的閱讀量增速無法以用戶為粒度合理控制 BloomFilter 的尺寸和 False Positive Rate。

HBase

考慮到BloomFilter on Redis 方案存在的問題,我們開始嘗試使用 HBase 來存儲用戶的閱讀歷史並提供在線查詢服務。已讀的業務需求可以非常直觀的映射到 BigTable 的數據模型上。我們將用戶 id 作為 row key,訪問的文檔 id 作為 qualifier 保存下來, 而 timestamp 則恰好可以用來記錄文檔的讀取時間。整個系統的可擴展性和成本都要顯著優於直接使用 Redis Cluster 存儲 BloomFilter 的方案。

隨著已讀數據量級和業務查詢量的迅速增長,已讀數據訪問極度稀疏的特點開始影響到了HBase 的 cache 命中率。HBase 的存儲模型在發生 cache miss 需要訪問存儲的情況下 IO 路徑很長。根據緩衝穿透的層次不同整個請求路徑上可能會經過數個 Java 進程,任何一個進程的 GC 和 IO 都會對這次訪問的 latency 產生顯著的影響,導致響應時間產生較大的波動,而大的響應時間波動是首頁難以接受的。

知乎已讀服務的前生今世與未來

HBase 緩衝穿透的 IO 路徑

在吸取了最初兩代已讀架構方案的經驗教訓後,我們開始設計實現新一代的已讀服務,在這次的設計中我們在可用性、性能以及擴展性上都設定了更高的目標尤其是以往系統表現不好的性能和擴展性方面我們希望看到更加長足的進步。

● 高可用

● ✓ HBase

● ✓BloomFilter on Redis Cluster

● 高性能

● ✘HBase

● ✓BloomFilter on Redis Cluster

● 易擴展

● ✓HBase

● ✘BloomFilter on Redis Cluster

下面就讓我們一起從高可用、高性能和易擴展這三個角度來思考如何構建一個更好的已讀服務滿足好業務的需求和挑戰。

高可用

當我們討論高可用的時候,也意味著我們已經意識到故障是無時無刻都在發生的,依賴傳統人工運維的方式來保證複雜系統的高可用是不現實的。我們需要以系統化的方式對各個組件的狀態進行探測感知他們發生的故障。並且我們需要為系統中的組件設計自愈機制,當故障發生時可以不經人工干預而自動的恢復。最後我們還需要隔離各種故障所產生的變化,讓業務側儘可能對故障的發生和恢復無感知。

知乎已讀服務的前生今世與未來

故障監測、自動恢復並隔離變化

高性能

對常見的系統來說,越核心的組件往往狀態越重擴展的代價也越大,層層攔截快速降低需要深入到核心組件的請求量對提高性能是非常有效的手段。首先我們通過緩衝分Slot 的方式來擴展集群所能緩衝的數據規模。接著進一步在 Slot 內通過多副本的方式提升單個 Slot 緩衝數據集的讀取吞吐,將大量的請求攔截在系統的緩衝層進行消化。如果請求不可避免的走到了最終的數據庫組件上,我們還可以利用效率較高的壓縮來繼續降低落到物理設備上的IO壓力。

知乎已讀服務的前生今世與未來

分層去併發

易擴展

提升系統擴展性的關鍵在於減少有狀態組件的範圍,在路由和服務發現組件的幫助下,系統中的無狀態組件可以非常輕鬆的擴展擴容。所以通過擴大無狀態服務的範圍,收縮重狀態服務的比例可以顯著的幫助我們提升整個系統的可擴展性。除此之外如果我們能夠設計一些可以從外部系統恢復狀態的弱狀態服務,那麼我們往往可以利用弱狀態的組件來部分替代重狀態組件。隨著弱狀態組件的擴大和重狀態組件的收縮,整個系統的可擴展性可以得到進一步的提升。

知乎已讀服務的前生今世與未來

弱狀態部分替代重狀態

RBase

在高可用、高性能和易擴展的設計理念下,我們設計實現了RBase 做為已讀服務的根基。現在讓我們來從 RBase 全局設計入手來了解高可用、高性能和易擴展的設計理念是如何落地的。

知乎已讀服務的前生今世與未來

RBase 架構

客戶端API 和 Proxy 是完全無狀態可隨時擴展的組件,最底層是由 MHA 管理的 MySQL 集群,中間存在大量可從數據庫或者副本中恢復的弱狀態組件。這些弱狀態組件中最核心的部分是分層的緩衝模塊,這些緩衝模塊的狀態可以從副本或數據庫中恢復重建因此他們的可擴展性仍然是非常優秀的。緩衝以外的組件則負責管理緩衝的一致性,在它們的協助下緩衝模塊可以完全避免無意義的 Cache Invalidate 提升緩存的命中率,從而極大緩解了傳導到底層數據庫系統上的壓力。整個架構中除了唯一的重狀態組件 MySQL 集群之外所有的組件都擁有自我恢復的能力。在擁有自我恢復能力和全局故障監測的前提下,我們使用 Kubernetes 來管理所有 RBase 的服務組件在機制上確保整個服務的高可用。

RBase 的設計中緩存是一個非常關鍵的組件,然而它們的組織管理方式又同常見的緩存系統不盡相同,這些設計上的不同體現著我們在高性能方面的思考。現在讓我們以一個典型的計算機系統的內存分層設計來引出我們在緩存系統設計上的思路。

"

導讀:對於很多大型網站來說,一些不起眼的小功能反而是實現的難點。對於知乎來說,已讀服務會隨著用戶量和內容數量的增長而平方級增長,而且響應時間要求很短,因此是一個有實現難度的系統。本文作者介紹了知乎已讀服務的架構設計和演進過程,並對很多技術取捨做了深入剖析,十分值得閱讀。

知乎已讀服務的前生今世與未來

作者簡介:孫曉光,知乎搜索後端負責人,目前承擔知乎搜索後端架構設計以及工程團隊的管理工作。曾多年從事私有云相關產品開發工作關注雲原生技術,TiKV 項目 Committer。

知乎從問答起步,在過去的8 年中逐步成長為一個大規模的綜合性知識內容平臺,目前,知乎上有多達 3000 萬個問題,共收穫了超過 1.3 億個回答,同時知乎還沉澱了數量眾多的文章、電子書以及其他付費內容。知乎通過個性化首頁推薦的方式在海量的信息中高效分發用戶感興趣的優質內容。為了避免給用戶推薦重複的內容,已讀服務會將所有知乎站上用戶深入閱讀或快速掠過的內容長期保存,並將這些數據應用於首頁推薦信息流和個性化推送的已讀過濾。

業務場景,技術挑戰

知乎已讀服務的前生今世與未來

首頁已讀過濾流程示意圖

從首頁使用已讀服務的流程我們可以看出這個服務的業務模式較為簡單,我們只需要簡單的以用戶為第一維度內容為第二緯度來查詢指定用戶是否已經閱讀過某個內容。但我們並沒因為業務簡單就在設計上放棄了靈活性和普適性。為此我們設計開發了一套支持BigTable 數據模型的 Cache Through 緩衝系統 RBase 來實現已讀服務,一方面充分利用 Cache 的高吞吐低時延能力,另一方面還可以利用靈活的 BigTable 數據模型來輔助業務快速演進。

知乎已讀服務的前生今世與未來

BigTable 數據模型

已讀服務雖然從業務模式看非常簡單,但它在技術上的挑戰確並不低。目前知乎已讀的數據規模已超萬億並以每天接近30 億的速度持續高速增長。與常見的“讀多寫少”的業務不同,已讀服務不僅需要在這樣的存量數據規模下提供在線查詢服務,還同時承載著每秒 4 萬條新紀錄寫入的衝擊。已讀內容過濾作為首頁信息流推薦中對響應時間影響較大的關鍵任務點,它的可用性和響應時間都需要滿足非常高的要求。

綜合業務需求和線上數據來看,已讀服務的要求和挑戰主要有以下幾點:

● 可用性要求「高」:服務於個性化首頁和個性化推送,最重要的流量分發渠道

● 寫入量「大」:峰值每秒寫入40K+ 行記錄,日新增記錄近 30 億條

● 歷史數據「長期」保存:目前已達一萬二千億條記錄

● 查詢吞吐「高」:在線查詢峰值30K QPS / 12M+ 條已讀檢查

● 響應時間「敏感」:90ms 超時

早期方案,架構演進

BloomFilter on Redis Cluster

知乎已讀服務的前生今世與未來

最初我們在Redis 集群上使用 BITSET 結構直接存儲已讀數據的 BloomFilter。首先由於缺乏多個位的批量操作,操作放大非常嚴重會消耗非常多的計算資源。其次使用全內存的方式存儲全量數據也拉高了整體成本。最後由於難以預估用戶的閱讀量增速無法以用戶為粒度合理控制 BloomFilter 的尺寸和 False Positive Rate。

HBase

考慮到BloomFilter on Redis 方案存在的問題,我們開始嘗試使用 HBase 來存儲用戶的閱讀歷史並提供在線查詢服務。已讀的業務需求可以非常直觀的映射到 BigTable 的數據模型上。我們將用戶 id 作為 row key,訪問的文檔 id 作為 qualifier 保存下來, 而 timestamp 則恰好可以用來記錄文檔的讀取時間。整個系統的可擴展性和成本都要顯著優於直接使用 Redis Cluster 存儲 BloomFilter 的方案。

隨著已讀數據量級和業務查詢量的迅速增長,已讀數據訪問極度稀疏的特點開始影響到了HBase 的 cache 命中率。HBase 的存儲模型在發生 cache miss 需要訪問存儲的情況下 IO 路徑很長。根據緩衝穿透的層次不同整個請求路徑上可能會經過數個 Java 進程,任何一個進程的 GC 和 IO 都會對這次訪問的 latency 產生顯著的影響,導致響應時間產生較大的波動,而大的響應時間波動是首頁難以接受的。

知乎已讀服務的前生今世與未來

HBase 緩衝穿透的 IO 路徑

在吸取了最初兩代已讀架構方案的經驗教訓後,我們開始設計實現新一代的已讀服務,在這次的設計中我們在可用性、性能以及擴展性上都設定了更高的目標尤其是以往系統表現不好的性能和擴展性方面我們希望看到更加長足的進步。

● 高可用

● ✓ HBase

● ✓BloomFilter on Redis Cluster

● 高性能

● ✘HBase

● ✓BloomFilter on Redis Cluster

● 易擴展

● ✓HBase

● ✘BloomFilter on Redis Cluster

下面就讓我們一起從高可用、高性能和易擴展這三個角度來思考如何構建一個更好的已讀服務滿足好業務的需求和挑戰。

高可用

當我們討論高可用的時候,也意味著我們已經意識到故障是無時無刻都在發生的,依賴傳統人工運維的方式來保證複雜系統的高可用是不現實的。我們需要以系統化的方式對各個組件的狀態進行探測感知他們發生的故障。並且我們需要為系統中的組件設計自愈機制,當故障發生時可以不經人工干預而自動的恢復。最後我們還需要隔離各種故障所產生的變化,讓業務側儘可能對故障的發生和恢復無感知。

知乎已讀服務的前生今世與未來

故障監測、自動恢復並隔離變化

高性能

對常見的系統來說,越核心的組件往往狀態越重擴展的代價也越大,層層攔截快速降低需要深入到核心組件的請求量對提高性能是非常有效的手段。首先我們通過緩衝分Slot 的方式來擴展集群所能緩衝的數據規模。接著進一步在 Slot 內通過多副本的方式提升單個 Slot 緩衝數據集的讀取吞吐,將大量的請求攔截在系統的緩衝層進行消化。如果請求不可避免的走到了最終的數據庫組件上,我們還可以利用效率較高的壓縮來繼續降低落到物理設備上的IO壓力。

知乎已讀服務的前生今世與未來

分層去併發

易擴展

提升系統擴展性的關鍵在於減少有狀態組件的範圍,在路由和服務發現組件的幫助下,系統中的無狀態組件可以非常輕鬆的擴展擴容。所以通過擴大無狀態服務的範圍,收縮重狀態服務的比例可以顯著的幫助我們提升整個系統的可擴展性。除此之外如果我們能夠設計一些可以從外部系統恢復狀態的弱狀態服務,那麼我們往往可以利用弱狀態的組件來部分替代重狀態組件。隨著弱狀態組件的擴大和重狀態組件的收縮,整個系統的可擴展性可以得到進一步的提升。

知乎已讀服務的前生今世與未來

弱狀態部分替代重狀態

RBase

在高可用、高性能和易擴展的設計理念下,我們設計實現了RBase 做為已讀服務的根基。現在讓我們來從 RBase 全局設計入手來了解高可用、高性能和易擴展的設計理念是如何落地的。

知乎已讀服務的前生今世與未來

RBase 架構

客戶端API 和 Proxy 是完全無狀態可隨時擴展的組件,最底層是由 MHA 管理的 MySQL 集群,中間存在大量可從數據庫或者副本中恢復的弱狀態組件。這些弱狀態組件中最核心的部分是分層的緩衝模塊,這些緩衝模塊的狀態可以從副本或數據庫中恢復重建因此他們的可擴展性仍然是非常優秀的。緩衝以外的組件則負責管理緩衝的一致性,在它們的協助下緩衝模塊可以完全避免無意義的 Cache Invalidate 提升緩存的命中率,從而極大緩解了傳導到底層數據庫系統上的壓力。整個架構中除了唯一的重狀態組件 MySQL 集群之外所有的組件都擁有自我恢復的能力。在擁有自我恢復能力和全局故障監測的前提下,我們使用 Kubernetes 來管理所有 RBase 的服務組件在機制上確保整個服務的高可用。

RBase 的設計中緩存是一個非常關鍵的組件,然而它們的組織管理方式又同常見的緩存系統不盡相同,這些設計上的不同體現著我們在高性能方面的思考。現在讓我們以一個典型的計算機系統的內存分層設計來引出我們在緩存系統設計上的思路。

知乎已讀服務的前生今世與未來

計算機系統內存分層示意圖

在過去的幾十年中工業界為了提升計算機系統的性能,在計算機系統中添加了更多的CPU 乃至更多的核心。隨著 CPU 內部處理能力的不斷增強我們還為 CPU 加上多級的緩存來彌補主存在帶寬和時延上同 CPU 的巨大差距。除此之外我們還進一步把主存連同 CPU 分成多組讓他們之間有更快的本地連接,只有當需要交叉訪問遠程內存的時候再通過互聯總線進行交流。在這樣一個系統裡大部分的讀寫操作都發生在離核心最近的 L1 或者 L2 cache 裡,而在修改主存中數據的時候則需要更加複雜的機制和更長的時間來達成緩衝的最終一致。為了能將計算機系統的性能發揮到極致工業界不斷的改進體系結構,在這個架構中處處都體現著設計的取捨和智慧。

我們利用在內存分層設計上獲得的靈感,在RBase 的緩存一致性上採用了類似的設計。類似計算機系統內存的數據庫層,相似的分層緩衝設計,類似的 cache through 以及 cache coherence 組件設計。大道至簡殊途同歸,通過學習借鑑體系結構經典成熟的思想將它應用到軟件系統設計中同樣可以得到非常好的效果。

核心組件,關鍵設計

接下來讓我們從細節來深入瞭解已讀系統中一些核心組件的關鍵設計。

Proxy

"

導讀:對於很多大型網站來說,一些不起眼的小功能反而是實現的難點。對於知乎來說,已讀服務會隨著用戶量和內容數量的增長而平方級增長,而且響應時間要求很短,因此是一個有實現難度的系統。本文作者介紹了知乎已讀服務的架構設計和演進過程,並對很多技術取捨做了深入剖析,十分值得閱讀。

知乎已讀服務的前生今世與未來

作者簡介:孫曉光,知乎搜索後端負責人,目前承擔知乎搜索後端架構設計以及工程團隊的管理工作。曾多年從事私有云相關產品開發工作關注雲原生技術,TiKV 項目 Committer。

知乎從問答起步,在過去的8 年中逐步成長為一個大規模的綜合性知識內容平臺,目前,知乎上有多達 3000 萬個問題,共收穫了超過 1.3 億個回答,同時知乎還沉澱了數量眾多的文章、電子書以及其他付費內容。知乎通過個性化首頁推薦的方式在海量的信息中高效分發用戶感興趣的優質內容。為了避免給用戶推薦重複的內容,已讀服務會將所有知乎站上用戶深入閱讀或快速掠過的內容長期保存,並將這些數據應用於首頁推薦信息流和個性化推送的已讀過濾。

業務場景,技術挑戰

知乎已讀服務的前生今世與未來

首頁已讀過濾流程示意圖

從首頁使用已讀服務的流程我們可以看出這個服務的業務模式較為簡單,我們只需要簡單的以用戶為第一維度內容為第二緯度來查詢指定用戶是否已經閱讀過某個內容。但我們並沒因為業務簡單就在設計上放棄了靈活性和普適性。為此我們設計開發了一套支持BigTable 數據模型的 Cache Through 緩衝系統 RBase 來實現已讀服務,一方面充分利用 Cache 的高吞吐低時延能力,另一方面還可以利用靈活的 BigTable 數據模型來輔助業務快速演進。

知乎已讀服務的前生今世與未來

BigTable 數據模型

已讀服務雖然從業務模式看非常簡單,但它在技術上的挑戰確並不低。目前知乎已讀的數據規模已超萬億並以每天接近30 億的速度持續高速增長。與常見的“讀多寫少”的業務不同,已讀服務不僅需要在這樣的存量數據規模下提供在線查詢服務,還同時承載著每秒 4 萬條新紀錄寫入的衝擊。已讀內容過濾作為首頁信息流推薦中對響應時間影響較大的關鍵任務點,它的可用性和響應時間都需要滿足非常高的要求。

綜合業務需求和線上數據來看,已讀服務的要求和挑戰主要有以下幾點:

● 可用性要求「高」:服務於個性化首頁和個性化推送,最重要的流量分發渠道

● 寫入量「大」:峰值每秒寫入40K+ 行記錄,日新增記錄近 30 億條

● 歷史數據「長期」保存:目前已達一萬二千億條記錄

● 查詢吞吐「高」:在線查詢峰值30K QPS / 12M+ 條已讀檢查

● 響應時間「敏感」:90ms 超時

早期方案,架構演進

BloomFilter on Redis Cluster

知乎已讀服務的前生今世與未來

最初我們在Redis 集群上使用 BITSET 結構直接存儲已讀數據的 BloomFilter。首先由於缺乏多個位的批量操作,操作放大非常嚴重會消耗非常多的計算資源。其次使用全內存的方式存儲全量數據也拉高了整體成本。最後由於難以預估用戶的閱讀量增速無法以用戶為粒度合理控制 BloomFilter 的尺寸和 False Positive Rate。

HBase

考慮到BloomFilter on Redis 方案存在的問題,我們開始嘗試使用 HBase 來存儲用戶的閱讀歷史並提供在線查詢服務。已讀的業務需求可以非常直觀的映射到 BigTable 的數據模型上。我們將用戶 id 作為 row key,訪問的文檔 id 作為 qualifier 保存下來, 而 timestamp 則恰好可以用來記錄文檔的讀取時間。整個系統的可擴展性和成本都要顯著優於直接使用 Redis Cluster 存儲 BloomFilter 的方案。

隨著已讀數據量級和業務查詢量的迅速增長,已讀數據訪問極度稀疏的特點開始影響到了HBase 的 cache 命中率。HBase 的存儲模型在發生 cache miss 需要訪問存儲的情況下 IO 路徑很長。根據緩衝穿透的層次不同整個請求路徑上可能會經過數個 Java 進程,任何一個進程的 GC 和 IO 都會對這次訪問的 latency 產生顯著的影響,導致響應時間產生較大的波動,而大的響應時間波動是首頁難以接受的。

知乎已讀服務的前生今世與未來

HBase 緩衝穿透的 IO 路徑

在吸取了最初兩代已讀架構方案的經驗教訓後,我們開始設計實現新一代的已讀服務,在這次的設計中我們在可用性、性能以及擴展性上都設定了更高的目標尤其是以往系統表現不好的性能和擴展性方面我們希望看到更加長足的進步。

● 高可用

● ✓ HBase

● ✓BloomFilter on Redis Cluster

● 高性能

● ✘HBase

● ✓BloomFilter on Redis Cluster

● 易擴展

● ✓HBase

● ✘BloomFilter on Redis Cluster

下面就讓我們一起從高可用、高性能和易擴展這三個角度來思考如何構建一個更好的已讀服務滿足好業務的需求和挑戰。

高可用

當我們討論高可用的時候,也意味著我們已經意識到故障是無時無刻都在發生的,依賴傳統人工運維的方式來保證複雜系統的高可用是不現實的。我們需要以系統化的方式對各個組件的狀態進行探測感知他們發生的故障。並且我們需要為系統中的組件設計自愈機制,當故障發生時可以不經人工干預而自動的恢復。最後我們還需要隔離各種故障所產生的變化,讓業務側儘可能對故障的發生和恢復無感知。

知乎已讀服務的前生今世與未來

故障監測、自動恢復並隔離變化

高性能

對常見的系統來說,越核心的組件往往狀態越重擴展的代價也越大,層層攔截快速降低需要深入到核心組件的請求量對提高性能是非常有效的手段。首先我們通過緩衝分Slot 的方式來擴展集群所能緩衝的數據規模。接著進一步在 Slot 內通過多副本的方式提升單個 Slot 緩衝數據集的讀取吞吐,將大量的請求攔截在系統的緩衝層進行消化。如果請求不可避免的走到了最終的數據庫組件上,我們還可以利用效率較高的壓縮來繼續降低落到物理設備上的IO壓力。

知乎已讀服務的前生今世與未來

分層去併發

易擴展

提升系統擴展性的關鍵在於減少有狀態組件的範圍,在路由和服務發現組件的幫助下,系統中的無狀態組件可以非常輕鬆的擴展擴容。所以通過擴大無狀態服務的範圍,收縮重狀態服務的比例可以顯著的幫助我們提升整個系統的可擴展性。除此之外如果我們能夠設計一些可以從外部系統恢復狀態的弱狀態服務,那麼我們往往可以利用弱狀態的組件來部分替代重狀態組件。隨著弱狀態組件的擴大和重狀態組件的收縮,整個系統的可擴展性可以得到進一步的提升。

知乎已讀服務的前生今世與未來

弱狀態部分替代重狀態

RBase

在高可用、高性能和易擴展的設計理念下,我們設計實現了RBase 做為已讀服務的根基。現在讓我們來從 RBase 全局設計入手來了解高可用、高性能和易擴展的設計理念是如何落地的。

知乎已讀服務的前生今世與未來

RBase 架構

客戶端API 和 Proxy 是完全無狀態可隨時擴展的組件,最底層是由 MHA 管理的 MySQL 集群,中間存在大量可從數據庫或者副本中恢復的弱狀態組件。這些弱狀態組件中最核心的部分是分層的緩衝模塊,這些緩衝模塊的狀態可以從副本或數據庫中恢復重建因此他們的可擴展性仍然是非常優秀的。緩衝以外的組件則負責管理緩衝的一致性,在它們的協助下緩衝模塊可以完全避免無意義的 Cache Invalidate 提升緩存的命中率,從而極大緩解了傳導到底層數據庫系統上的壓力。整個架構中除了唯一的重狀態組件 MySQL 集群之外所有的組件都擁有自我恢復的能力。在擁有自我恢復能力和全局故障監測的前提下,我們使用 Kubernetes 來管理所有 RBase 的服務組件在機制上確保整個服務的高可用。

RBase 的設計中緩存是一個非常關鍵的組件,然而它們的組織管理方式又同常見的緩存系統不盡相同,這些設計上的不同體現著我們在高性能方面的思考。現在讓我們以一個典型的計算機系統的內存分層設計來引出我們在緩存系統設計上的思路。

知乎已讀服務的前生今世與未來

計算機系統內存分層示意圖

在過去的幾十年中工業界為了提升計算機系統的性能,在計算機系統中添加了更多的CPU 乃至更多的核心。隨著 CPU 內部處理能力的不斷增強我們還為 CPU 加上多級的緩存來彌補主存在帶寬和時延上同 CPU 的巨大差距。除此之外我們還進一步把主存連同 CPU 分成多組讓他們之間有更快的本地連接,只有當需要交叉訪問遠程內存的時候再通過互聯總線進行交流。在這樣一個系統裡大部分的讀寫操作都發生在離核心最近的 L1 或者 L2 cache 裡,而在修改主存中數據的時候則需要更加複雜的機制和更長的時間來達成緩衝的最終一致。為了能將計算機系統的性能發揮到極致工業界不斷的改進體系結構,在這個架構中處處都體現著設計的取捨和智慧。

我們利用在內存分層設計上獲得的靈感,在RBase 的緩存一致性上採用了類似的設計。類似計算機系統內存的數據庫層,相似的分層緩衝設計,類似的 cache through 以及 cache coherence 組件設計。大道至簡殊途同歸,通過學習借鑑體系結構經典成熟的思想將它應用到軟件系統設計中同樣可以得到非常好的效果。

核心組件,關鍵設計

接下來讓我們從細節來深入瞭解已讀系統中一些核心組件的關鍵設計。

Proxy

知乎已讀服務的前生今世與未來

Proxy 負責負載均衡和隔離故障

Proxy 是已讀服務的接入層組件,它用傳統的方式將緩衝按照用戶維度拆分成多個 slot 組織起來,每個 slot 負責數據集內的一個子集。Slot 內可以有多個副本來分擔同一批數據的讀取壓力,proxy 會在 slot 內對同一個會話綁定同一個副本來保證會話內的一致性,當副本發生故障時 proxy 優先選擇同一個 slot 內的其它副本來繼續承載請求,在極端情況發生 slot 內的所有副本同時失效時,proxy 還可以選擇其它 slot 的活躍節點來處理用戶的請求,這時我們付出了無法利用緩衝提高性能的代價來換取系統在極端場景下的可用性。

Cache

在由「用戶」和「內容類型」和「內容」所組成的空間中,由於「用戶」維度和「內容」維度的基數非常高,都在數億級別,即使記錄數在萬億這樣的數量級下,數據在整個三維空間內的分佈依然非常稀疏。單純依靠底層存儲系統的能力很難在尺寸巨大且極度稀疏的數據集上提供高吞吐的在線查詢,更難以滿足業務對低響應時間的要求。另外尺寸巨大且分佈稀疏的數據集對緩存系統的資源消耗和命中率的也提出了巨大的挑戰。

"

導讀:對於很多大型網站來說,一些不起眼的小功能反而是實現的難點。對於知乎來說,已讀服務會隨著用戶量和內容數量的增長而平方級增長,而且響應時間要求很短,因此是一個有實現難度的系統。本文作者介紹了知乎已讀服務的架構設計和演進過程,並對很多技術取捨做了深入剖析,十分值得閱讀。

知乎已讀服務的前生今世與未來

作者簡介:孫曉光,知乎搜索後端負責人,目前承擔知乎搜索後端架構設計以及工程團隊的管理工作。曾多年從事私有云相關產品開發工作關注雲原生技術,TiKV 項目 Committer。

知乎從問答起步,在過去的8 年中逐步成長為一個大規模的綜合性知識內容平臺,目前,知乎上有多達 3000 萬個問題,共收穫了超過 1.3 億個回答,同時知乎還沉澱了數量眾多的文章、電子書以及其他付費內容。知乎通過個性化首頁推薦的方式在海量的信息中高效分發用戶感興趣的優質內容。為了避免給用戶推薦重複的內容,已讀服務會將所有知乎站上用戶深入閱讀或快速掠過的內容長期保存,並將這些數據應用於首頁推薦信息流和個性化推送的已讀過濾。

業務場景,技術挑戰

知乎已讀服務的前生今世與未來

首頁已讀過濾流程示意圖

從首頁使用已讀服務的流程我們可以看出這個服務的業務模式較為簡單,我們只需要簡單的以用戶為第一維度內容為第二緯度來查詢指定用戶是否已經閱讀過某個內容。但我們並沒因為業務簡單就在設計上放棄了靈活性和普適性。為此我們設計開發了一套支持BigTable 數據模型的 Cache Through 緩衝系統 RBase 來實現已讀服務,一方面充分利用 Cache 的高吞吐低時延能力,另一方面還可以利用靈活的 BigTable 數據模型來輔助業務快速演進。

知乎已讀服務的前生今世與未來

BigTable 數據模型

已讀服務雖然從業務模式看非常簡單,但它在技術上的挑戰確並不低。目前知乎已讀的數據規模已超萬億並以每天接近30 億的速度持續高速增長。與常見的“讀多寫少”的業務不同,已讀服務不僅需要在這樣的存量數據規模下提供在線查詢服務,還同時承載著每秒 4 萬條新紀錄寫入的衝擊。已讀內容過濾作為首頁信息流推薦中對響應時間影響較大的關鍵任務點,它的可用性和響應時間都需要滿足非常高的要求。

綜合業務需求和線上數據來看,已讀服務的要求和挑戰主要有以下幾點:

● 可用性要求「高」:服務於個性化首頁和個性化推送,最重要的流量分發渠道

● 寫入量「大」:峰值每秒寫入40K+ 行記錄,日新增記錄近 30 億條

● 歷史數據「長期」保存:目前已達一萬二千億條記錄

● 查詢吞吐「高」:在線查詢峰值30K QPS / 12M+ 條已讀檢查

● 響應時間「敏感」:90ms 超時

早期方案,架構演進

BloomFilter on Redis Cluster

知乎已讀服務的前生今世與未來

最初我們在Redis 集群上使用 BITSET 結構直接存儲已讀數據的 BloomFilter。首先由於缺乏多個位的批量操作,操作放大非常嚴重會消耗非常多的計算資源。其次使用全內存的方式存儲全量數據也拉高了整體成本。最後由於難以預估用戶的閱讀量增速無法以用戶為粒度合理控制 BloomFilter 的尺寸和 False Positive Rate。

HBase

考慮到BloomFilter on Redis 方案存在的問題,我們開始嘗試使用 HBase 來存儲用戶的閱讀歷史並提供在線查詢服務。已讀的業務需求可以非常直觀的映射到 BigTable 的數據模型上。我們將用戶 id 作為 row key,訪問的文檔 id 作為 qualifier 保存下來, 而 timestamp 則恰好可以用來記錄文檔的讀取時間。整個系統的可擴展性和成本都要顯著優於直接使用 Redis Cluster 存儲 BloomFilter 的方案。

隨著已讀數據量級和業務查詢量的迅速增長,已讀數據訪問極度稀疏的特點開始影響到了HBase 的 cache 命中率。HBase 的存儲模型在發生 cache miss 需要訪問存儲的情況下 IO 路徑很長。根據緩衝穿透的層次不同整個請求路徑上可能會經過數個 Java 進程,任何一個進程的 GC 和 IO 都會對這次訪問的 latency 產生顯著的影響,導致響應時間產生較大的波動,而大的響應時間波動是首頁難以接受的。

知乎已讀服務的前生今世與未來

HBase 緩衝穿透的 IO 路徑

在吸取了最初兩代已讀架構方案的經驗教訓後,我們開始設計實現新一代的已讀服務,在這次的設計中我們在可用性、性能以及擴展性上都設定了更高的目標尤其是以往系統表現不好的性能和擴展性方面我們希望看到更加長足的進步。

● 高可用

● ✓ HBase

● ✓BloomFilter on Redis Cluster

● 高性能

● ✘HBase

● ✓BloomFilter on Redis Cluster

● 易擴展

● ✓HBase

● ✘BloomFilter on Redis Cluster

下面就讓我們一起從高可用、高性能和易擴展這三個角度來思考如何構建一個更好的已讀服務滿足好業務的需求和挑戰。

高可用

當我們討論高可用的時候,也意味著我們已經意識到故障是無時無刻都在發生的,依賴傳統人工運維的方式來保證複雜系統的高可用是不現實的。我們需要以系統化的方式對各個組件的狀態進行探測感知他們發生的故障。並且我們需要為系統中的組件設計自愈機制,當故障發生時可以不經人工干預而自動的恢復。最後我們還需要隔離各種故障所產生的變化,讓業務側儘可能對故障的發生和恢復無感知。

知乎已讀服務的前生今世與未來

故障監測、自動恢復並隔離變化

高性能

對常見的系統來說,越核心的組件往往狀態越重擴展的代價也越大,層層攔截快速降低需要深入到核心組件的請求量對提高性能是非常有效的手段。首先我們通過緩衝分Slot 的方式來擴展集群所能緩衝的數據規模。接著進一步在 Slot 內通過多副本的方式提升單個 Slot 緩衝數據集的讀取吞吐,將大量的請求攔截在系統的緩衝層進行消化。如果請求不可避免的走到了最終的數據庫組件上,我們還可以利用效率較高的壓縮來繼續降低落到物理設備上的IO壓力。

知乎已讀服務的前生今世與未來

分層去併發

易擴展

提升系統擴展性的關鍵在於減少有狀態組件的範圍,在路由和服務發現組件的幫助下,系統中的無狀態組件可以非常輕鬆的擴展擴容。所以通過擴大無狀態服務的範圍,收縮重狀態服務的比例可以顯著的幫助我們提升整個系統的可擴展性。除此之外如果我們能夠設計一些可以從外部系統恢復狀態的弱狀態服務,那麼我們往往可以利用弱狀態的組件來部分替代重狀態組件。隨著弱狀態組件的擴大和重狀態組件的收縮,整個系統的可擴展性可以得到進一步的提升。

知乎已讀服務的前生今世與未來

弱狀態部分替代重狀態

RBase

在高可用、高性能和易擴展的設計理念下,我們設計實現了RBase 做為已讀服務的根基。現在讓我們來從 RBase 全局設計入手來了解高可用、高性能和易擴展的設計理念是如何落地的。

知乎已讀服務的前生今世與未來

RBase 架構

客戶端API 和 Proxy 是完全無狀態可隨時擴展的組件,最底層是由 MHA 管理的 MySQL 集群,中間存在大量可從數據庫或者副本中恢復的弱狀態組件。這些弱狀態組件中最核心的部分是分層的緩衝模塊,這些緩衝模塊的狀態可以從副本或數據庫中恢復重建因此他們的可擴展性仍然是非常優秀的。緩衝以外的組件則負責管理緩衝的一致性,在它們的協助下緩衝模塊可以完全避免無意義的 Cache Invalidate 提升緩存的命中率,從而極大緩解了傳導到底層數據庫系統上的壓力。整個架構中除了唯一的重狀態組件 MySQL 集群之外所有的組件都擁有自我恢復的能力。在擁有自我恢復能力和全局故障監測的前提下,我們使用 Kubernetes 來管理所有 RBase 的服務組件在機制上確保整個服務的高可用。

RBase 的設計中緩存是一個非常關鍵的組件,然而它們的組織管理方式又同常見的緩存系統不盡相同,這些設計上的不同體現著我們在高性能方面的思考。現在讓我們以一個典型的計算機系統的內存分層設計來引出我們在緩存系統設計上的思路。

知乎已讀服務的前生今世與未來

計算機系統內存分層示意圖

在過去的幾十年中工業界為了提升計算機系統的性能,在計算機系統中添加了更多的CPU 乃至更多的核心。隨著 CPU 內部處理能力的不斷增強我們還為 CPU 加上多級的緩存來彌補主存在帶寬和時延上同 CPU 的巨大差距。除此之外我們還進一步把主存連同 CPU 分成多組讓他們之間有更快的本地連接,只有當需要交叉訪問遠程內存的時候再通過互聯總線進行交流。在這樣一個系統裡大部分的讀寫操作都發生在離核心最近的 L1 或者 L2 cache 裡,而在修改主存中數據的時候則需要更加複雜的機制和更長的時間來達成緩衝的最終一致。為了能將計算機系統的性能發揮到極致工業界不斷的改進體系結構,在這個架構中處處都體現著設計的取捨和智慧。

我們利用在內存分層設計上獲得的靈感,在RBase 的緩存一致性上採用了類似的設計。類似計算機系統內存的數據庫層,相似的分層緩衝設計,類似的 cache through 以及 cache coherence 組件設計。大道至簡殊途同歸,通過學習借鑑體系結構經典成熟的思想將它應用到軟件系統設計中同樣可以得到非常好的效果。

核心組件,關鍵設計

接下來讓我們從細節來深入瞭解已讀系統中一些核心組件的關鍵設計。

Proxy

知乎已讀服務的前生今世與未來

Proxy 負責負載均衡和隔離故障

Proxy 是已讀服務的接入層組件,它用傳統的方式將緩衝按照用戶維度拆分成多個 slot 組織起來,每個 slot 負責數據集內的一個子集。Slot 內可以有多個副本來分擔同一批數據的讀取壓力,proxy 會在 slot 內對同一個會話綁定同一個副本來保證會話內的一致性,當副本發生故障時 proxy 優先選擇同一個 slot 內的其它副本來繼續承載請求,在極端情況發生 slot 內的所有副本同時失效時,proxy 還可以選擇其它 slot 的活躍節點來處理用戶的請求,這時我們付出了無法利用緩衝提高性能的代價來換取系統在極端場景下的可用性。

Cache

在由「用戶」和「內容類型」和「內容」所組成的空間中,由於「用戶」維度和「內容」維度的基數非常高,都在數億級別,即使記錄數在萬億這樣的數量級下,數據在整個三維空間內的分佈依然非常稀疏。單純依靠底層存儲系統的能力很難在尺寸巨大且極度稀疏的數據集上提供高吞吐的在線查詢,更難以滿足業務對低響應時間的要求。另外尺寸巨大且分佈稀疏的數據集對緩存系統的資源消耗和命中率的也提出了巨大的挑戰。

知乎已讀服務的前生今世與未來

已讀數據空間分佈極度稀疏

考慮到目前知乎站上沉澱的內容量級巨大,我們可以容忍false positive 但依舊為用戶召回到足夠多可能會感興趣的內容。基於這樣的業務特點我們可以將數據庫中存儲的原始數據轉化為 BloomFilter 緩衝起來,這極大的降低了內存的消耗在相同的資源狀況下可以緩衝更多的數據提高緩存的命中率。

"

導讀:對於很多大型網站來說,一些不起眼的小功能反而是實現的難點。對於知乎來說,已讀服務會隨著用戶量和內容數量的增長而平方級增長,而且響應時間要求很短,因此是一個有實現難度的系統。本文作者介紹了知乎已讀服務的架構設計和演進過程,並對很多技術取捨做了深入剖析,十分值得閱讀。

知乎已讀服務的前生今世與未來

作者簡介:孫曉光,知乎搜索後端負責人,目前承擔知乎搜索後端架構設計以及工程團隊的管理工作。曾多年從事私有云相關產品開發工作關注雲原生技術,TiKV 項目 Committer。

知乎從問答起步,在過去的8 年中逐步成長為一個大規模的綜合性知識內容平臺,目前,知乎上有多達 3000 萬個問題,共收穫了超過 1.3 億個回答,同時知乎還沉澱了數量眾多的文章、電子書以及其他付費內容。知乎通過個性化首頁推薦的方式在海量的信息中高效分發用戶感興趣的優質內容。為了避免給用戶推薦重複的內容,已讀服務會將所有知乎站上用戶深入閱讀或快速掠過的內容長期保存,並將這些數據應用於首頁推薦信息流和個性化推送的已讀過濾。

業務場景,技術挑戰

知乎已讀服務的前生今世與未來

首頁已讀過濾流程示意圖

從首頁使用已讀服務的流程我們可以看出這個服務的業務模式較為簡單,我們只需要簡單的以用戶為第一維度內容為第二緯度來查詢指定用戶是否已經閱讀過某個內容。但我們並沒因為業務簡單就在設計上放棄了靈活性和普適性。為此我們設計開發了一套支持BigTable 數據模型的 Cache Through 緩衝系統 RBase 來實現已讀服務,一方面充分利用 Cache 的高吞吐低時延能力,另一方面還可以利用靈活的 BigTable 數據模型來輔助業務快速演進。

知乎已讀服務的前生今世與未來

BigTable 數據模型

已讀服務雖然從業務模式看非常簡單,但它在技術上的挑戰確並不低。目前知乎已讀的數據規模已超萬億並以每天接近30 億的速度持續高速增長。與常見的“讀多寫少”的業務不同,已讀服務不僅需要在這樣的存量數據規模下提供在線查詢服務,還同時承載著每秒 4 萬條新紀錄寫入的衝擊。已讀內容過濾作為首頁信息流推薦中對響應時間影響較大的關鍵任務點,它的可用性和響應時間都需要滿足非常高的要求。

綜合業務需求和線上數據來看,已讀服務的要求和挑戰主要有以下幾點:

● 可用性要求「高」:服務於個性化首頁和個性化推送,最重要的流量分發渠道

● 寫入量「大」:峰值每秒寫入40K+ 行記錄,日新增記錄近 30 億條

● 歷史數據「長期」保存:目前已達一萬二千億條記錄

● 查詢吞吐「高」:在線查詢峰值30K QPS / 12M+ 條已讀檢查

● 響應時間「敏感」:90ms 超時

早期方案,架構演進

BloomFilter on Redis Cluster

知乎已讀服務的前生今世與未來

最初我們在Redis 集群上使用 BITSET 結構直接存儲已讀數據的 BloomFilter。首先由於缺乏多個位的批量操作,操作放大非常嚴重會消耗非常多的計算資源。其次使用全內存的方式存儲全量數據也拉高了整體成本。最後由於難以預估用戶的閱讀量增速無法以用戶為粒度合理控制 BloomFilter 的尺寸和 False Positive Rate。

HBase

考慮到BloomFilter on Redis 方案存在的問題,我們開始嘗試使用 HBase 來存儲用戶的閱讀歷史並提供在線查詢服務。已讀的業務需求可以非常直觀的映射到 BigTable 的數據模型上。我們將用戶 id 作為 row key,訪問的文檔 id 作為 qualifier 保存下來, 而 timestamp 則恰好可以用來記錄文檔的讀取時間。整個系統的可擴展性和成本都要顯著優於直接使用 Redis Cluster 存儲 BloomFilter 的方案。

隨著已讀數據量級和業務查詢量的迅速增長,已讀數據訪問極度稀疏的特點開始影響到了HBase 的 cache 命中率。HBase 的存儲模型在發生 cache miss 需要訪問存儲的情況下 IO 路徑很長。根據緩衝穿透的層次不同整個請求路徑上可能會經過數個 Java 進程,任何一個進程的 GC 和 IO 都會對這次訪問的 latency 產生顯著的影響,導致響應時間產生較大的波動,而大的響應時間波動是首頁難以接受的。

知乎已讀服務的前生今世與未來

HBase 緩衝穿透的 IO 路徑

在吸取了最初兩代已讀架構方案的經驗教訓後,我們開始設計實現新一代的已讀服務,在這次的設計中我們在可用性、性能以及擴展性上都設定了更高的目標尤其是以往系統表現不好的性能和擴展性方面我們希望看到更加長足的進步。

● 高可用

● ✓ HBase

● ✓BloomFilter on Redis Cluster

● 高性能

● ✘HBase

● ✓BloomFilter on Redis Cluster

● 易擴展

● ✓HBase

● ✘BloomFilter on Redis Cluster

下面就讓我們一起從高可用、高性能和易擴展這三個角度來思考如何構建一個更好的已讀服務滿足好業務的需求和挑戰。

高可用

當我們討論高可用的時候,也意味著我們已經意識到故障是無時無刻都在發生的,依賴傳統人工運維的方式來保證複雜系統的高可用是不現實的。我們需要以系統化的方式對各個組件的狀態進行探測感知他們發生的故障。並且我們需要為系統中的組件設計自愈機制,當故障發生時可以不經人工干預而自動的恢復。最後我們還需要隔離各種故障所產生的變化,讓業務側儘可能對故障的發生和恢復無感知。

知乎已讀服務的前生今世與未來

故障監測、自動恢復並隔離變化

高性能

對常見的系統來說,越核心的組件往往狀態越重擴展的代價也越大,層層攔截快速降低需要深入到核心組件的請求量對提高性能是非常有效的手段。首先我們通過緩衝分Slot 的方式來擴展集群所能緩衝的數據規模。接著進一步在 Slot 內通過多副本的方式提升單個 Slot 緩衝數據集的讀取吞吐,將大量的請求攔截在系統的緩衝層進行消化。如果請求不可避免的走到了最終的數據庫組件上,我們還可以利用效率較高的壓縮來繼續降低落到物理設備上的IO壓力。

知乎已讀服務的前生今世與未來

分層去併發

易擴展

提升系統擴展性的關鍵在於減少有狀態組件的範圍,在路由和服務發現組件的幫助下,系統中的無狀態組件可以非常輕鬆的擴展擴容。所以通過擴大無狀態服務的範圍,收縮重狀態服務的比例可以顯著的幫助我們提升整個系統的可擴展性。除此之外如果我們能夠設計一些可以從外部系統恢復狀態的弱狀態服務,那麼我們往往可以利用弱狀態的組件來部分替代重狀態組件。隨著弱狀態組件的擴大和重狀態組件的收縮,整個系統的可擴展性可以得到進一步的提升。

知乎已讀服務的前生今世與未來

弱狀態部分替代重狀態

RBase

在高可用、高性能和易擴展的設計理念下,我們設計實現了RBase 做為已讀服務的根基。現在讓我們來從 RBase 全局設計入手來了解高可用、高性能和易擴展的設計理念是如何落地的。

知乎已讀服務的前生今世與未來

RBase 架構

客戶端API 和 Proxy 是完全無狀態可隨時擴展的組件,最底層是由 MHA 管理的 MySQL 集群,中間存在大量可從數據庫或者副本中恢復的弱狀態組件。這些弱狀態組件中最核心的部分是分層的緩衝模塊,這些緩衝模塊的狀態可以從副本或數據庫中恢復重建因此他們的可擴展性仍然是非常優秀的。緩衝以外的組件則負責管理緩衝的一致性,在它們的協助下緩衝模塊可以完全避免無意義的 Cache Invalidate 提升緩存的命中率,從而極大緩解了傳導到底層數據庫系統上的壓力。整個架構中除了唯一的重狀態組件 MySQL 集群之外所有的組件都擁有自我恢復的能力。在擁有自我恢復能力和全局故障監測的前提下,我們使用 Kubernetes 來管理所有 RBase 的服務組件在機制上確保整個服務的高可用。

RBase 的設計中緩存是一個非常關鍵的組件,然而它們的組織管理方式又同常見的緩存系統不盡相同,這些設計上的不同體現著我們在高性能方面的思考。現在讓我們以一個典型的計算機系統的內存分層設計來引出我們在緩存系統設計上的思路。

知乎已讀服務的前生今世與未來

計算機系統內存分層示意圖

在過去的幾十年中工業界為了提升計算機系統的性能,在計算機系統中添加了更多的CPU 乃至更多的核心。隨著 CPU 內部處理能力的不斷增強我們還為 CPU 加上多級的緩存來彌補主存在帶寬和時延上同 CPU 的巨大差距。除此之外我們還進一步把主存連同 CPU 分成多組讓他們之間有更快的本地連接,只有當需要交叉訪問遠程內存的時候再通過互聯總線進行交流。在這樣一個系統裡大部分的讀寫操作都發生在離核心最近的 L1 或者 L2 cache 裡,而在修改主存中數據的時候則需要更加複雜的機制和更長的時間來達成緩衝的最終一致。為了能將計算機系統的性能發揮到極致工業界不斷的改進體系結構,在這個架構中處處都體現著設計的取捨和智慧。

我們利用在內存分層設計上獲得的靈感,在RBase 的緩存一致性上採用了類似的設計。類似計算機系統內存的數據庫層,相似的分層緩衝設計,類似的 cache through 以及 cache coherence 組件設計。大道至簡殊途同歸,通過學習借鑑體系結構經典成熟的思想將它應用到軟件系統設計中同樣可以得到非常好的效果。

核心組件,關鍵設計

接下來讓我們從細節來深入瞭解已讀系統中一些核心組件的關鍵設計。

Proxy

知乎已讀服務的前生今世與未來

Proxy 負責負載均衡和隔離故障

Proxy 是已讀服務的接入層組件,它用傳統的方式將緩衝按照用戶維度拆分成多個 slot 組織起來,每個 slot 負責數據集內的一個子集。Slot 內可以有多個副本來分擔同一批數據的讀取壓力,proxy 會在 slot 內對同一個會話綁定同一個副本來保證會話內的一致性,當副本發生故障時 proxy 優先選擇同一個 slot 內的其它副本來繼續承載請求,在極端情況發生 slot 內的所有副本同時失效時,proxy 還可以選擇其它 slot 的活躍節點來處理用戶的請求,這時我們付出了無法利用緩衝提高性能的代價來換取系統在極端場景下的可用性。

Cache

在由「用戶」和「內容類型」和「內容」所組成的空間中,由於「用戶」維度和「內容」維度的基數非常高,都在數億級別,即使記錄數在萬億這樣的數量級下,數據在整個三維空間內的分佈依然非常稀疏。單純依靠底層存儲系統的能力很難在尺寸巨大且極度稀疏的數據集上提供高吞吐的在線查詢,更難以滿足業務對低響應時間的要求。另外尺寸巨大且分佈稀疏的數據集對緩存系統的資源消耗和命中率的也提出了巨大的挑戰。

知乎已讀服務的前生今世與未來

已讀數據空間分佈極度稀疏

考慮到目前知乎站上沉澱的內容量級巨大,我們可以容忍false positive 但依舊為用戶召回到足夠多可能會感興趣的內容。基於這樣的業務特點我們可以將數據庫中存儲的原始數據轉化為 BloomFilter 緩衝起來,這極大的降低了內存的消耗在相同的資源狀況下可以緩衝更多的數據提高緩存的命中率。

知乎已讀服務的前生今世與未來

緩存Bloom Filter

提升緩存命中率的方式有很多種,除了前面提到的提升緩存數據密度增加可緩衝的數據量級之外,我們還可以通過避免不必要的緩存失效來進一步的提升緩存的效率。因此我們將緩存設計為write through cache 使用原地更新緩存的方式來避免 invalidate cache 操作。再配合數據變更訂閱我們可以在不失效緩衝的情況下確保同一份數據的多個緩衝副本能在很短的時間內達成最終一致。另一方面得益於 read through 的設計,我們可以將對同一份數據的多個併發查詢請求轉化成一次 cache miss 加多次緩衝讀取,進一步提升緩存的命中率降低穿透到底層數據庫系統的壓力。

"

導讀:對於很多大型網站來說,一些不起眼的小功能反而是實現的難點。對於知乎來說,已讀服務會隨著用戶量和內容數量的增長而平方級增長,而且響應時間要求很短,因此是一個有實現難度的系統。本文作者介紹了知乎已讀服務的架構設計和演進過程,並對很多技術取捨做了深入剖析,十分值得閱讀。

知乎已讀服務的前生今世與未來

作者簡介:孫曉光,知乎搜索後端負責人,目前承擔知乎搜索後端架構設計以及工程團隊的管理工作。曾多年從事私有云相關產品開發工作關注雲原生技術,TiKV 項目 Committer。

知乎從問答起步,在過去的8 年中逐步成長為一個大規模的綜合性知識內容平臺,目前,知乎上有多達 3000 萬個問題,共收穫了超過 1.3 億個回答,同時知乎還沉澱了數量眾多的文章、電子書以及其他付費內容。知乎通過個性化首頁推薦的方式在海量的信息中高效分發用戶感興趣的優質內容。為了避免給用戶推薦重複的內容,已讀服務會將所有知乎站上用戶深入閱讀或快速掠過的內容長期保存,並將這些數據應用於首頁推薦信息流和個性化推送的已讀過濾。

業務場景,技術挑戰

知乎已讀服務的前生今世與未來

首頁已讀過濾流程示意圖

從首頁使用已讀服務的流程我們可以看出這個服務的業務模式較為簡單,我們只需要簡單的以用戶為第一維度內容為第二緯度來查詢指定用戶是否已經閱讀過某個內容。但我們並沒因為業務簡單就在設計上放棄了靈活性和普適性。為此我們設計開發了一套支持BigTable 數據模型的 Cache Through 緩衝系統 RBase 來實現已讀服務,一方面充分利用 Cache 的高吞吐低時延能力,另一方面還可以利用靈活的 BigTable 數據模型來輔助業務快速演進。

知乎已讀服務的前生今世與未來

BigTable 數據模型

已讀服務雖然從業務模式看非常簡單,但它在技術上的挑戰確並不低。目前知乎已讀的數據規模已超萬億並以每天接近30 億的速度持續高速增長。與常見的“讀多寫少”的業務不同,已讀服務不僅需要在這樣的存量數據規模下提供在線查詢服務,還同時承載著每秒 4 萬條新紀錄寫入的衝擊。已讀內容過濾作為首頁信息流推薦中對響應時間影響較大的關鍵任務點,它的可用性和響應時間都需要滿足非常高的要求。

綜合業務需求和線上數據來看,已讀服務的要求和挑戰主要有以下幾點:

● 可用性要求「高」:服務於個性化首頁和個性化推送,最重要的流量分發渠道

● 寫入量「大」:峰值每秒寫入40K+ 行記錄,日新增記錄近 30 億條

● 歷史數據「長期」保存:目前已達一萬二千億條記錄

● 查詢吞吐「高」:在線查詢峰值30K QPS / 12M+ 條已讀檢查

● 響應時間「敏感」:90ms 超時

早期方案,架構演進

BloomFilter on Redis Cluster

知乎已讀服務的前生今世與未來

最初我們在Redis 集群上使用 BITSET 結構直接存儲已讀數據的 BloomFilter。首先由於缺乏多個位的批量操作,操作放大非常嚴重會消耗非常多的計算資源。其次使用全內存的方式存儲全量數據也拉高了整體成本。最後由於難以預估用戶的閱讀量增速無法以用戶為粒度合理控制 BloomFilter 的尺寸和 False Positive Rate。

HBase

考慮到BloomFilter on Redis 方案存在的問題,我們開始嘗試使用 HBase 來存儲用戶的閱讀歷史並提供在線查詢服務。已讀的業務需求可以非常直觀的映射到 BigTable 的數據模型上。我們將用戶 id 作為 row key,訪問的文檔 id 作為 qualifier 保存下來, 而 timestamp 則恰好可以用來記錄文檔的讀取時間。整個系統的可擴展性和成本都要顯著優於直接使用 Redis Cluster 存儲 BloomFilter 的方案。

隨著已讀數據量級和業務查詢量的迅速增長,已讀數據訪問極度稀疏的特點開始影響到了HBase 的 cache 命中率。HBase 的存儲模型在發生 cache miss 需要訪問存儲的情況下 IO 路徑很長。根據緩衝穿透的層次不同整個請求路徑上可能會經過數個 Java 進程,任何一個進程的 GC 和 IO 都會對這次訪問的 latency 產生顯著的影響,導致響應時間產生較大的波動,而大的響應時間波動是首頁難以接受的。

知乎已讀服務的前生今世與未來

HBase 緩衝穿透的 IO 路徑

在吸取了最初兩代已讀架構方案的經驗教訓後,我們開始設計實現新一代的已讀服務,在這次的設計中我們在可用性、性能以及擴展性上都設定了更高的目標尤其是以往系統表現不好的性能和擴展性方面我們希望看到更加長足的進步。

● 高可用

● ✓ HBase

● ✓BloomFilter on Redis Cluster

● 高性能

● ✘HBase

● ✓BloomFilter on Redis Cluster

● 易擴展

● ✓HBase

● ✘BloomFilter on Redis Cluster

下面就讓我們一起從高可用、高性能和易擴展這三個角度來思考如何構建一個更好的已讀服務滿足好業務的需求和挑戰。

高可用

當我們討論高可用的時候,也意味著我們已經意識到故障是無時無刻都在發生的,依賴傳統人工運維的方式來保證複雜系統的高可用是不現實的。我們需要以系統化的方式對各個組件的狀態進行探測感知他們發生的故障。並且我們需要為系統中的組件設計自愈機制,當故障發生時可以不經人工干預而自動的恢復。最後我們還需要隔離各種故障所產生的變化,讓業務側儘可能對故障的發生和恢復無感知。

知乎已讀服務的前生今世與未來

故障監測、自動恢復並隔離變化

高性能

對常見的系統來說,越核心的組件往往狀態越重擴展的代價也越大,層層攔截快速降低需要深入到核心組件的請求量對提高性能是非常有效的手段。首先我們通過緩衝分Slot 的方式來擴展集群所能緩衝的數據規模。接著進一步在 Slot 內通過多副本的方式提升單個 Slot 緩衝數據集的讀取吞吐,將大量的請求攔截在系統的緩衝層進行消化。如果請求不可避免的走到了最終的數據庫組件上,我們還可以利用效率較高的壓縮來繼續降低落到物理設備上的IO壓力。

知乎已讀服務的前生今世與未來

分層去併發

易擴展

提升系統擴展性的關鍵在於減少有狀態組件的範圍,在路由和服務發現組件的幫助下,系統中的無狀態組件可以非常輕鬆的擴展擴容。所以通過擴大無狀態服務的範圍,收縮重狀態服務的比例可以顯著的幫助我們提升整個系統的可擴展性。除此之外如果我們能夠設計一些可以從外部系統恢復狀態的弱狀態服務,那麼我們往往可以利用弱狀態的組件來部分替代重狀態組件。隨著弱狀態組件的擴大和重狀態組件的收縮,整個系統的可擴展性可以得到進一步的提升。

知乎已讀服務的前生今世與未來

弱狀態部分替代重狀態

RBase

在高可用、高性能和易擴展的設計理念下,我們設計實現了RBase 做為已讀服務的根基。現在讓我們來從 RBase 全局設計入手來了解高可用、高性能和易擴展的設計理念是如何落地的。

知乎已讀服務的前生今世與未來

RBase 架構

客戶端API 和 Proxy 是完全無狀態可隨時擴展的組件,最底層是由 MHA 管理的 MySQL 集群,中間存在大量可從數據庫或者副本中恢復的弱狀態組件。這些弱狀態組件中最核心的部分是分層的緩衝模塊,這些緩衝模塊的狀態可以從副本或數據庫中恢復重建因此他們的可擴展性仍然是非常優秀的。緩衝以外的組件則負責管理緩衝的一致性,在它們的協助下緩衝模塊可以完全避免無意義的 Cache Invalidate 提升緩存的命中率,從而極大緩解了傳導到底層數據庫系統上的壓力。整個架構中除了唯一的重狀態組件 MySQL 集群之外所有的組件都擁有自我恢復的能力。在擁有自我恢復能力和全局故障監測的前提下,我們使用 Kubernetes 來管理所有 RBase 的服務組件在機制上確保整個服務的高可用。

RBase 的設計中緩存是一個非常關鍵的組件,然而它們的組織管理方式又同常見的緩存系統不盡相同,這些設計上的不同體現著我們在高性能方面的思考。現在讓我們以一個典型的計算機系統的內存分層設計來引出我們在緩存系統設計上的思路。

知乎已讀服務的前生今世與未來

計算機系統內存分層示意圖

在過去的幾十年中工業界為了提升計算機系統的性能,在計算機系統中添加了更多的CPU 乃至更多的核心。隨著 CPU 內部處理能力的不斷增強我們還為 CPU 加上多級的緩存來彌補主存在帶寬和時延上同 CPU 的巨大差距。除此之外我們還進一步把主存連同 CPU 分成多組讓他們之間有更快的本地連接,只有當需要交叉訪問遠程內存的時候再通過互聯總線進行交流。在這樣一個系統裡大部分的讀寫操作都發生在離核心最近的 L1 或者 L2 cache 裡,而在修改主存中數據的時候則需要更加複雜的機制和更長的時間來達成緩衝的最終一致。為了能將計算機系統的性能發揮到極致工業界不斷的改進體系結構,在這個架構中處處都體現著設計的取捨和智慧。

我們利用在內存分層設計上獲得的靈感,在RBase 的緩存一致性上採用了類似的設計。類似計算機系統內存的數據庫層,相似的分層緩衝設計,類似的 cache through 以及 cache coherence 組件設計。大道至簡殊途同歸,通過學習借鑑體系結構經典成熟的思想將它應用到軟件系統設計中同樣可以得到非常好的效果。

核心組件,關鍵設計

接下來讓我們從細節來深入瞭解已讀系統中一些核心組件的關鍵設計。

Proxy

知乎已讀服務的前生今世與未來

Proxy 負責負載均衡和隔離故障

Proxy 是已讀服務的接入層組件,它用傳統的方式將緩衝按照用戶維度拆分成多個 slot 組織起來,每個 slot 負責數據集內的一個子集。Slot 內可以有多個副本來分擔同一批數據的讀取壓力,proxy 會在 slot 內對同一個會話綁定同一個副本來保證會話內的一致性,當副本發生故障時 proxy 優先選擇同一個 slot 內的其它副本來繼續承載請求,在極端情況發生 slot 內的所有副本同時失效時,proxy 還可以選擇其它 slot 的活躍節點來處理用戶的請求,這時我們付出了無法利用緩衝提高性能的代價來換取系統在極端場景下的可用性。

Cache

在由「用戶」和「內容類型」和「內容」所組成的空間中,由於「用戶」維度和「內容」維度的基數非常高,都在數億級別,即使記錄數在萬億這樣的數量級下,數據在整個三維空間內的分佈依然非常稀疏。單純依靠底層存儲系統的能力很難在尺寸巨大且極度稀疏的數據集上提供高吞吐的在線查詢,更難以滿足業務對低響應時間的要求。另外尺寸巨大且分佈稀疏的數據集對緩存系統的資源消耗和命中率的也提出了巨大的挑戰。

知乎已讀服務的前生今世與未來

已讀數據空間分佈極度稀疏

考慮到目前知乎站上沉澱的內容量級巨大,我們可以容忍false positive 但依舊為用戶召回到足夠多可能會感興趣的內容。基於這樣的業務特點我們可以將數據庫中存儲的原始數據轉化為 BloomFilter 緩衝起來,這極大的降低了內存的消耗在相同的資源狀況下可以緩衝更多的數據提高緩存的命中率。

知乎已讀服務的前生今世與未來

緩存Bloom Filter

提升緩存命中率的方式有很多種,除了前面提到的提升緩存數據密度增加可緩衝的數據量級之外,我們還可以通過避免不必要的緩存失效來進一步的提升緩存的效率。因此我們將緩存設計為write through cache 使用原地更新緩存的方式來避免 invalidate cache 操作。再配合數據變更訂閱我們可以在不失效緩衝的情況下確保同一份數據的多個緩衝副本能在很短的時間內達成最終一致。另一方面得益於 read through 的設計,我們可以將對同一份數據的多個併發查詢請求轉化成一次 cache miss 加多次緩衝讀取,進一步提升緩存的命中率降低穿透到底層數據庫系統的壓力。

知乎已讀服務的前生今世與未來

Cache Through

緩衝系統的核心工作是攔截住大量熱數據的訪問,因此維持緩衝數據的熱度是整個系統的穩定性的關鍵因素。但作為不斷迭代演進的業務系統,如何在系統滾動升級或者副本擴容的時候讓新啟動的緩衝節點也快速熱身進入狀態呢?雖然我們可以選擇逐步向新節點開放流量的方式避免冷緩衝的影響,但我們也會面臨故障後自動恢復的情景,這時我們沒有時間等待新的緩衝服務逐步進入狀態。考慮到這點我們在新節點啟動後會從當前slot 內挑選一個活躍的副本遷移全部或足夠多的狀態讓新節點快速進入工作狀態,避免因新節點緩衝不熱導致的響應時間抖動。

"

導讀:對於很多大型網站來說,一些不起眼的小功能反而是實現的難點。對於知乎來說,已讀服務會隨著用戶量和內容數量的增長而平方級增長,而且響應時間要求很短,因此是一個有實現難度的系統。本文作者介紹了知乎已讀服務的架構設計和演進過程,並對很多技術取捨做了深入剖析,十分值得閱讀。

知乎已讀服務的前生今世與未來

作者簡介:孫曉光,知乎搜索後端負責人,目前承擔知乎搜索後端架構設計以及工程團隊的管理工作。曾多年從事私有云相關產品開發工作關注雲原生技術,TiKV 項目 Committer。

知乎從問答起步,在過去的8 年中逐步成長為一個大規模的綜合性知識內容平臺,目前,知乎上有多達 3000 萬個問題,共收穫了超過 1.3 億個回答,同時知乎還沉澱了數量眾多的文章、電子書以及其他付費內容。知乎通過個性化首頁推薦的方式在海量的信息中高效分發用戶感興趣的優質內容。為了避免給用戶推薦重複的內容,已讀服務會將所有知乎站上用戶深入閱讀或快速掠過的內容長期保存,並將這些數據應用於首頁推薦信息流和個性化推送的已讀過濾。

業務場景,技術挑戰

知乎已讀服務的前生今世與未來

首頁已讀過濾流程示意圖

從首頁使用已讀服務的流程我們可以看出這個服務的業務模式較為簡單,我們只需要簡單的以用戶為第一維度內容為第二緯度來查詢指定用戶是否已經閱讀過某個內容。但我們並沒因為業務簡單就在設計上放棄了靈活性和普適性。為此我們設計開發了一套支持BigTable 數據模型的 Cache Through 緩衝系統 RBase 來實現已讀服務,一方面充分利用 Cache 的高吞吐低時延能力,另一方面還可以利用靈活的 BigTable 數據模型來輔助業務快速演進。

知乎已讀服務的前生今世與未來

BigTable 數據模型

已讀服務雖然從業務模式看非常簡單,但它在技術上的挑戰確並不低。目前知乎已讀的數據規模已超萬億並以每天接近30 億的速度持續高速增長。與常見的“讀多寫少”的業務不同,已讀服務不僅需要在這樣的存量數據規模下提供在線查詢服務,還同時承載著每秒 4 萬條新紀錄寫入的衝擊。已讀內容過濾作為首頁信息流推薦中對響應時間影響較大的關鍵任務點,它的可用性和響應時間都需要滿足非常高的要求。

綜合業務需求和線上數據來看,已讀服務的要求和挑戰主要有以下幾點:

● 可用性要求「高」:服務於個性化首頁和個性化推送,最重要的流量分發渠道

● 寫入量「大」:峰值每秒寫入40K+ 行記錄,日新增記錄近 30 億條

● 歷史數據「長期」保存:目前已達一萬二千億條記錄

● 查詢吞吐「高」:在線查詢峰值30K QPS / 12M+ 條已讀檢查

● 響應時間「敏感」:90ms 超時

早期方案,架構演進

BloomFilter on Redis Cluster

知乎已讀服務的前生今世與未來

最初我們在Redis 集群上使用 BITSET 結構直接存儲已讀數據的 BloomFilter。首先由於缺乏多個位的批量操作,操作放大非常嚴重會消耗非常多的計算資源。其次使用全內存的方式存儲全量數據也拉高了整體成本。最後由於難以預估用戶的閱讀量增速無法以用戶為粒度合理控制 BloomFilter 的尺寸和 False Positive Rate。

HBase

考慮到BloomFilter on Redis 方案存在的問題,我們開始嘗試使用 HBase 來存儲用戶的閱讀歷史並提供在線查詢服務。已讀的業務需求可以非常直觀的映射到 BigTable 的數據模型上。我們將用戶 id 作為 row key,訪問的文檔 id 作為 qualifier 保存下來, 而 timestamp 則恰好可以用來記錄文檔的讀取時間。整個系統的可擴展性和成本都要顯著優於直接使用 Redis Cluster 存儲 BloomFilter 的方案。

隨著已讀數據量級和業務查詢量的迅速增長,已讀數據訪問極度稀疏的特點開始影響到了HBase 的 cache 命中率。HBase 的存儲模型在發生 cache miss 需要訪問存儲的情況下 IO 路徑很長。根據緩衝穿透的層次不同整個請求路徑上可能會經過數個 Java 進程,任何一個進程的 GC 和 IO 都會對這次訪問的 latency 產生顯著的影響,導致響應時間產生較大的波動,而大的響應時間波動是首頁難以接受的。

知乎已讀服務的前生今世與未來

HBase 緩衝穿透的 IO 路徑

在吸取了最初兩代已讀架構方案的經驗教訓後,我們開始設計實現新一代的已讀服務,在這次的設計中我們在可用性、性能以及擴展性上都設定了更高的目標尤其是以往系統表現不好的性能和擴展性方面我們希望看到更加長足的進步。

● 高可用

● ✓ HBase

● ✓BloomFilter on Redis Cluster

● 高性能

● ✘HBase

● ✓BloomFilter on Redis Cluster

● 易擴展

● ✓HBase

● ✘BloomFilter on Redis Cluster

下面就讓我們一起從高可用、高性能和易擴展這三個角度來思考如何構建一個更好的已讀服務滿足好業務的需求和挑戰。

高可用

當我們討論高可用的時候,也意味著我們已經意識到故障是無時無刻都在發生的,依賴傳統人工運維的方式來保證複雜系統的高可用是不現實的。我們需要以系統化的方式對各個組件的狀態進行探測感知他們發生的故障。並且我們需要為系統中的組件設計自愈機制,當故障發生時可以不經人工干預而自動的恢復。最後我們還需要隔離各種故障所產生的變化,讓業務側儘可能對故障的發生和恢復無感知。

知乎已讀服務的前生今世與未來

故障監測、自動恢復並隔離變化

高性能

對常見的系統來說,越核心的組件往往狀態越重擴展的代價也越大,層層攔截快速降低需要深入到核心組件的請求量對提高性能是非常有效的手段。首先我們通過緩衝分Slot 的方式來擴展集群所能緩衝的數據規模。接著進一步在 Slot 內通過多副本的方式提升單個 Slot 緩衝數據集的讀取吞吐,將大量的請求攔截在系統的緩衝層進行消化。如果請求不可避免的走到了最終的數據庫組件上,我們還可以利用效率較高的壓縮來繼續降低落到物理設備上的IO壓力。

知乎已讀服務的前生今世與未來

分層去併發

易擴展

提升系統擴展性的關鍵在於減少有狀態組件的範圍,在路由和服務發現組件的幫助下,系統中的無狀態組件可以非常輕鬆的擴展擴容。所以通過擴大無狀態服務的範圍,收縮重狀態服務的比例可以顯著的幫助我們提升整個系統的可擴展性。除此之外如果我們能夠設計一些可以從外部系統恢復狀態的弱狀態服務,那麼我們往往可以利用弱狀態的組件來部分替代重狀態組件。隨著弱狀態組件的擴大和重狀態組件的收縮,整個系統的可擴展性可以得到進一步的提升。

知乎已讀服務的前生今世與未來

弱狀態部分替代重狀態

RBase

在高可用、高性能和易擴展的設計理念下,我們設計實現了RBase 做為已讀服務的根基。現在讓我們來從 RBase 全局設計入手來了解高可用、高性能和易擴展的設計理念是如何落地的。

知乎已讀服務的前生今世與未來

RBase 架構

客戶端API 和 Proxy 是完全無狀態可隨時擴展的組件,最底層是由 MHA 管理的 MySQL 集群,中間存在大量可從數據庫或者副本中恢復的弱狀態組件。這些弱狀態組件中最核心的部分是分層的緩衝模塊,這些緩衝模塊的狀態可以從副本或數據庫中恢復重建因此他們的可擴展性仍然是非常優秀的。緩衝以外的組件則負責管理緩衝的一致性,在它們的協助下緩衝模塊可以完全避免無意義的 Cache Invalidate 提升緩存的命中率,從而極大緩解了傳導到底層數據庫系統上的壓力。整個架構中除了唯一的重狀態組件 MySQL 集群之外所有的組件都擁有自我恢復的能力。在擁有自我恢復能力和全局故障監測的前提下,我們使用 Kubernetes 來管理所有 RBase 的服務組件在機制上確保整個服務的高可用。

RBase 的設計中緩存是一個非常關鍵的組件,然而它們的組織管理方式又同常見的緩存系統不盡相同,這些設計上的不同體現著我們在高性能方面的思考。現在讓我們以一個典型的計算機系統的內存分層設計來引出我們在緩存系統設計上的思路。

知乎已讀服務的前生今世與未來

計算機系統內存分層示意圖

在過去的幾十年中工業界為了提升計算機系統的性能,在計算機系統中添加了更多的CPU 乃至更多的核心。隨著 CPU 內部處理能力的不斷增強我們還為 CPU 加上多級的緩存來彌補主存在帶寬和時延上同 CPU 的巨大差距。除此之外我們還進一步把主存連同 CPU 分成多組讓他們之間有更快的本地連接,只有當需要交叉訪問遠程內存的時候再通過互聯總線進行交流。在這樣一個系統裡大部分的讀寫操作都發生在離核心最近的 L1 或者 L2 cache 裡,而在修改主存中數據的時候則需要更加複雜的機制和更長的時間來達成緩衝的最終一致。為了能將計算機系統的性能發揮到極致工業界不斷的改進體系結構,在這個架構中處處都體現著設計的取捨和智慧。

我們利用在內存分層設計上獲得的靈感,在RBase 的緩存一致性上採用了類似的設計。類似計算機系統內存的數據庫層,相似的分層緩衝設計,類似的 cache through 以及 cache coherence 組件設計。大道至簡殊途同歸,通過學習借鑑體系結構經典成熟的思想將它應用到軟件系統設計中同樣可以得到非常好的效果。

核心組件,關鍵設計

接下來讓我們從細節來深入瞭解已讀系統中一些核心組件的關鍵設計。

Proxy

知乎已讀服務的前生今世與未來

Proxy 負責負載均衡和隔離故障

Proxy 是已讀服務的接入層組件,它用傳統的方式將緩衝按照用戶維度拆分成多個 slot 組織起來,每個 slot 負責數據集內的一個子集。Slot 內可以有多個副本來分擔同一批數據的讀取壓力,proxy 會在 slot 內對同一個會話綁定同一個副本來保證會話內的一致性,當副本發生故障時 proxy 優先選擇同一個 slot 內的其它副本來繼續承載請求,在極端情況發生 slot 內的所有副本同時失效時,proxy 還可以選擇其它 slot 的活躍節點來處理用戶的請求,這時我們付出了無法利用緩衝提高性能的代價來換取系統在極端場景下的可用性。

Cache

在由「用戶」和「內容類型」和「內容」所組成的空間中,由於「用戶」維度和「內容」維度的基數非常高,都在數億級別,即使記錄數在萬億這樣的數量級下,數據在整個三維空間內的分佈依然非常稀疏。單純依靠底層存儲系統的能力很難在尺寸巨大且極度稀疏的數據集上提供高吞吐的在線查詢,更難以滿足業務對低響應時間的要求。另外尺寸巨大且分佈稀疏的數據集對緩存系統的資源消耗和命中率的也提出了巨大的挑戰。

知乎已讀服務的前生今世與未來

已讀數據空間分佈極度稀疏

考慮到目前知乎站上沉澱的內容量級巨大,我們可以容忍false positive 但依舊為用戶召回到足夠多可能會感興趣的內容。基於這樣的業務特點我們可以將數據庫中存儲的原始數據轉化為 BloomFilter 緩衝起來,這極大的降低了內存的消耗在相同的資源狀況下可以緩衝更多的數據提高緩存的命中率。

知乎已讀服務的前生今世與未來

緩存Bloom Filter

提升緩存命中率的方式有很多種,除了前面提到的提升緩存數據密度增加可緩衝的數據量級之外,我們還可以通過避免不必要的緩存失效來進一步的提升緩存的效率。因此我們將緩存設計為write through cache 使用原地更新緩存的方式來避免 invalidate cache 操作。再配合數據變更訂閱我們可以在不失效緩衝的情況下確保同一份數據的多個緩衝副本能在很短的時間內達成最終一致。另一方面得益於 read through 的設計,我們可以將對同一份數據的多個併發查詢請求轉化成一次 cache miss 加多次緩衝讀取,進一步提升緩存的命中率降低穿透到底層數據庫系統的壓力。

知乎已讀服務的前生今世與未來

Cache Through

緩衝系統的核心工作是攔截住大量熱數據的訪問,因此維持緩衝數據的熱度是整個系統的穩定性的關鍵因素。但作為不斷迭代演進的業務系統,如何在系統滾動升級或者副本擴容的時候讓新啟動的緩衝節點也快速熱身進入狀態呢?雖然我們可以選擇逐步向新節點開放流量的方式避免冷緩衝的影響,但我們也會面臨故障後自動恢復的情景,這時我們沒有時間等待新的緩衝服務逐步進入狀態。考慮到這點我們在新節點啟動後會從當前slot 內挑選一個活躍的副本遷移全部或足夠多的狀態讓新節點快速進入工作狀態,避免因新節點緩衝不熱導致的響應時間抖動。

知乎已讀服務的前生今世與未來

緩衝狀態遷移

前面我們提到借鑑計算機系統中的內存分層設計,接下來我們從收益的角度來探討採用類似的分層緩衝設計在已讀服務上為系統帶來了什麼。現實中的緩衝系統不論如何提高命中率,我們始終都需要面對cache miss 的場景。多層緩衝的存在可以讓我們在不同層級的 Cache 上應用不同的配置策略,力圖在每一個新引入的 cache 層級上都進一步降低穿透到下一層的請求數量。在多層緩衝的幫助下我們可以讓不同的緩衝層級分別從空間維度和時間維度關注數據的熱度。我們甚至還可以在多數據中心部署的情況下,通過進一步增加緩衝層數利用多層緩衝的機制來極大的降低跨數據中心訪問數據的帶寬消耗和時延增加。

"

導讀:對於很多大型網站來說,一些不起眼的小功能反而是實現的難點。對於知乎來說,已讀服務會隨著用戶量和內容數量的增長而平方級增長,而且響應時間要求很短,因此是一個有實現難度的系統。本文作者介紹了知乎已讀服務的架構設計和演進過程,並對很多技術取捨做了深入剖析,十分值得閱讀。

知乎已讀服務的前生今世與未來

作者簡介:孫曉光,知乎搜索後端負責人,目前承擔知乎搜索後端架構設計以及工程團隊的管理工作。曾多年從事私有云相關產品開發工作關注雲原生技術,TiKV 項目 Committer。

知乎從問答起步,在過去的8 年中逐步成長為一個大規模的綜合性知識內容平臺,目前,知乎上有多達 3000 萬個問題,共收穫了超過 1.3 億個回答,同時知乎還沉澱了數量眾多的文章、電子書以及其他付費內容。知乎通過個性化首頁推薦的方式在海量的信息中高效分發用戶感興趣的優質內容。為了避免給用戶推薦重複的內容,已讀服務會將所有知乎站上用戶深入閱讀或快速掠過的內容長期保存,並將這些數據應用於首頁推薦信息流和個性化推送的已讀過濾。

業務場景,技術挑戰

知乎已讀服務的前生今世與未來

首頁已讀過濾流程示意圖

從首頁使用已讀服務的流程我們可以看出這個服務的業務模式較為簡單,我們只需要簡單的以用戶為第一維度內容為第二緯度來查詢指定用戶是否已經閱讀過某個內容。但我們並沒因為業務簡單就在設計上放棄了靈活性和普適性。為此我們設計開發了一套支持BigTable 數據模型的 Cache Through 緩衝系統 RBase 來實現已讀服務,一方面充分利用 Cache 的高吞吐低時延能力,另一方面還可以利用靈活的 BigTable 數據模型來輔助業務快速演進。

知乎已讀服務的前生今世與未來

BigTable 數據模型

已讀服務雖然從業務模式看非常簡單,但它在技術上的挑戰確並不低。目前知乎已讀的數據規模已超萬億並以每天接近30 億的速度持續高速增長。與常見的“讀多寫少”的業務不同,已讀服務不僅需要在這樣的存量數據規模下提供在線查詢服務,還同時承載著每秒 4 萬條新紀錄寫入的衝擊。已讀內容過濾作為首頁信息流推薦中對響應時間影響較大的關鍵任務點,它的可用性和響應時間都需要滿足非常高的要求。

綜合業務需求和線上數據來看,已讀服務的要求和挑戰主要有以下幾點:

● 可用性要求「高」:服務於個性化首頁和個性化推送,最重要的流量分發渠道

● 寫入量「大」:峰值每秒寫入40K+ 行記錄,日新增記錄近 30 億條

● 歷史數據「長期」保存:目前已達一萬二千億條記錄

● 查詢吞吐「高」:在線查詢峰值30K QPS / 12M+ 條已讀檢查

● 響應時間「敏感」:90ms 超時

早期方案,架構演進

BloomFilter on Redis Cluster

知乎已讀服務的前生今世與未來

最初我們在Redis 集群上使用 BITSET 結構直接存儲已讀數據的 BloomFilter。首先由於缺乏多個位的批量操作,操作放大非常嚴重會消耗非常多的計算資源。其次使用全內存的方式存儲全量數據也拉高了整體成本。最後由於難以預估用戶的閱讀量增速無法以用戶為粒度合理控制 BloomFilter 的尺寸和 False Positive Rate。

HBase

考慮到BloomFilter on Redis 方案存在的問題,我們開始嘗試使用 HBase 來存儲用戶的閱讀歷史並提供在線查詢服務。已讀的業務需求可以非常直觀的映射到 BigTable 的數據模型上。我們將用戶 id 作為 row key,訪問的文檔 id 作為 qualifier 保存下來, 而 timestamp 則恰好可以用來記錄文檔的讀取時間。整個系統的可擴展性和成本都要顯著優於直接使用 Redis Cluster 存儲 BloomFilter 的方案。

隨著已讀數據量級和業務查詢量的迅速增長,已讀數據訪問極度稀疏的特點開始影響到了HBase 的 cache 命中率。HBase 的存儲模型在發生 cache miss 需要訪問存儲的情況下 IO 路徑很長。根據緩衝穿透的層次不同整個請求路徑上可能會經過數個 Java 進程,任何一個進程的 GC 和 IO 都會對這次訪問的 latency 產生顯著的影響,導致響應時間產生較大的波動,而大的響應時間波動是首頁難以接受的。

知乎已讀服務的前生今世與未來

HBase 緩衝穿透的 IO 路徑

在吸取了最初兩代已讀架構方案的經驗教訓後,我們開始設計實現新一代的已讀服務,在這次的設計中我們在可用性、性能以及擴展性上都設定了更高的目標尤其是以往系統表現不好的性能和擴展性方面我們希望看到更加長足的進步。

● 高可用

● ✓ HBase

● ✓BloomFilter on Redis Cluster

● 高性能

● ✘HBase

● ✓BloomFilter on Redis Cluster

● 易擴展

● ✓HBase

● ✘BloomFilter on Redis Cluster

下面就讓我們一起從高可用、高性能和易擴展這三個角度來思考如何構建一個更好的已讀服務滿足好業務的需求和挑戰。

高可用

當我們討論高可用的時候,也意味著我們已經意識到故障是無時無刻都在發生的,依賴傳統人工運維的方式來保證複雜系統的高可用是不現實的。我們需要以系統化的方式對各個組件的狀態進行探測感知他們發生的故障。並且我們需要為系統中的組件設計自愈機制,當故障發生時可以不經人工干預而自動的恢復。最後我們還需要隔離各種故障所產生的變化,讓業務側儘可能對故障的發生和恢復無感知。

知乎已讀服務的前生今世與未來

故障監測、自動恢復並隔離變化

高性能

對常見的系統來說,越核心的組件往往狀態越重擴展的代價也越大,層層攔截快速降低需要深入到核心組件的請求量對提高性能是非常有效的手段。首先我們通過緩衝分Slot 的方式來擴展集群所能緩衝的數據規模。接著進一步在 Slot 內通過多副本的方式提升單個 Slot 緩衝數據集的讀取吞吐,將大量的請求攔截在系統的緩衝層進行消化。如果請求不可避免的走到了最終的數據庫組件上,我們還可以利用效率較高的壓縮來繼續降低落到物理設備上的IO壓力。

知乎已讀服務的前生今世與未來

分層去併發

易擴展

提升系統擴展性的關鍵在於減少有狀態組件的範圍,在路由和服務發現組件的幫助下,系統中的無狀態組件可以非常輕鬆的擴展擴容。所以通過擴大無狀態服務的範圍,收縮重狀態服務的比例可以顯著的幫助我們提升整個系統的可擴展性。除此之外如果我們能夠設計一些可以從外部系統恢復狀態的弱狀態服務,那麼我們往往可以利用弱狀態的組件來部分替代重狀態組件。隨著弱狀態組件的擴大和重狀態組件的收縮,整個系統的可擴展性可以得到進一步的提升。

知乎已讀服務的前生今世與未來

弱狀態部分替代重狀態

RBase

在高可用、高性能和易擴展的設計理念下,我們設計實現了RBase 做為已讀服務的根基。現在讓我們來從 RBase 全局設計入手來了解高可用、高性能和易擴展的設計理念是如何落地的。

知乎已讀服務的前生今世與未來

RBase 架構

客戶端API 和 Proxy 是完全無狀態可隨時擴展的組件,最底層是由 MHA 管理的 MySQL 集群,中間存在大量可從數據庫或者副本中恢復的弱狀態組件。這些弱狀態組件中最核心的部分是分層的緩衝模塊,這些緩衝模塊的狀態可以從副本或數據庫中恢復重建因此他們的可擴展性仍然是非常優秀的。緩衝以外的組件則負責管理緩衝的一致性,在它們的協助下緩衝模塊可以完全避免無意義的 Cache Invalidate 提升緩存的命中率,從而極大緩解了傳導到底層數據庫系統上的壓力。整個架構中除了唯一的重狀態組件 MySQL 集群之外所有的組件都擁有自我恢復的能力。在擁有自我恢復能力和全局故障監測的前提下,我們使用 Kubernetes 來管理所有 RBase 的服務組件在機制上確保整個服務的高可用。

RBase 的設計中緩存是一個非常關鍵的組件,然而它們的組織管理方式又同常見的緩存系統不盡相同,這些設計上的不同體現著我們在高性能方面的思考。現在讓我們以一個典型的計算機系統的內存分層設計來引出我們在緩存系統設計上的思路。

知乎已讀服務的前生今世與未來

計算機系統內存分層示意圖

在過去的幾十年中工業界為了提升計算機系統的性能,在計算機系統中添加了更多的CPU 乃至更多的核心。隨著 CPU 內部處理能力的不斷增強我們還為 CPU 加上多級的緩存來彌補主存在帶寬和時延上同 CPU 的巨大差距。除此之外我們還進一步把主存連同 CPU 分成多組讓他們之間有更快的本地連接,只有當需要交叉訪問遠程內存的時候再通過互聯總線進行交流。在這樣一個系統裡大部分的讀寫操作都發生在離核心最近的 L1 或者 L2 cache 裡,而在修改主存中數據的時候則需要更加複雜的機制和更長的時間來達成緩衝的最終一致。為了能將計算機系統的性能發揮到極致工業界不斷的改進體系結構,在這個架構中處處都體現著設計的取捨和智慧。

我們利用在內存分層設計上獲得的靈感,在RBase 的緩存一致性上採用了類似的設計。類似計算機系統內存的數據庫層,相似的分層緩衝設計,類似的 cache through 以及 cache coherence 組件設計。大道至簡殊途同歸,通過學習借鑑體系結構經典成熟的思想將它應用到軟件系統設計中同樣可以得到非常好的效果。

核心組件,關鍵設計

接下來讓我們從細節來深入瞭解已讀系統中一些核心組件的關鍵設計。

Proxy

知乎已讀服務的前生今世與未來

Proxy 負責負載均衡和隔離故障

Proxy 是已讀服務的接入層組件,它用傳統的方式將緩衝按照用戶維度拆分成多個 slot 組織起來,每個 slot 負責數據集內的一個子集。Slot 內可以有多個副本來分擔同一批數據的讀取壓力,proxy 會在 slot 內對同一個會話綁定同一個副本來保證會話內的一致性,當副本發生故障時 proxy 優先選擇同一個 slot 內的其它副本來繼續承載請求,在極端情況發生 slot 內的所有副本同時失效時,proxy 還可以選擇其它 slot 的活躍節點來處理用戶的請求,這時我們付出了無法利用緩衝提高性能的代價來換取系統在極端場景下的可用性。

Cache

在由「用戶」和「內容類型」和「內容」所組成的空間中,由於「用戶」維度和「內容」維度的基數非常高,都在數億級別,即使記錄數在萬億這樣的數量級下,數據在整個三維空間內的分佈依然非常稀疏。單純依靠底層存儲系統的能力很難在尺寸巨大且極度稀疏的數據集上提供高吞吐的在線查詢,更難以滿足業務對低響應時間的要求。另外尺寸巨大且分佈稀疏的數據集對緩存系統的資源消耗和命中率的也提出了巨大的挑戰。

知乎已讀服務的前生今世與未來

已讀數據空間分佈極度稀疏

考慮到目前知乎站上沉澱的內容量級巨大,我們可以容忍false positive 但依舊為用戶召回到足夠多可能會感興趣的內容。基於這樣的業務特點我們可以將數據庫中存儲的原始數據轉化為 BloomFilter 緩衝起來,這極大的降低了內存的消耗在相同的資源狀況下可以緩衝更多的數據提高緩存的命中率。

知乎已讀服務的前生今世與未來

緩存Bloom Filter

提升緩存命中率的方式有很多種,除了前面提到的提升緩存數據密度增加可緩衝的數據量級之外,我們還可以通過避免不必要的緩存失效來進一步的提升緩存的效率。因此我們將緩存設計為write through cache 使用原地更新緩存的方式來避免 invalidate cache 操作。再配合數據變更訂閱我們可以在不失效緩衝的情況下確保同一份數據的多個緩衝副本能在很短的時間內達成最終一致。另一方面得益於 read through 的設計,我們可以將對同一份數據的多個併發查詢請求轉化成一次 cache miss 加多次緩衝讀取,進一步提升緩存的命中率降低穿透到底層數據庫系統的壓力。

知乎已讀服務的前生今世與未來

Cache Through

緩衝系統的核心工作是攔截住大量熱數據的訪問,因此維持緩衝數據的熱度是整個系統的穩定性的關鍵因素。但作為不斷迭代演進的業務系統,如何在系統滾動升級或者副本擴容的時候讓新啟動的緩衝節點也快速熱身進入狀態呢?雖然我們可以選擇逐步向新節點開放流量的方式避免冷緩衝的影響,但我們也會面臨故障後自動恢復的情景,這時我們沒有時間等待新的緩衝服務逐步進入狀態。考慮到這點我們在新節點啟動後會從當前slot 內挑選一個活躍的副本遷移全部或足夠多的狀態讓新節點快速進入工作狀態,避免因新節點緩衝不熱導致的響應時間抖動。

知乎已讀服務的前生今世與未來

緩衝狀態遷移

前面我們提到借鑑計算機系統中的內存分層設計,接下來我們從收益的角度來探討採用類似的分層緩衝設計在已讀服務上為系統帶來了什麼。現實中的緩衝系統不論如何提高命中率,我們始終都需要面對cache miss 的場景。多層緩衝的存在可以讓我們在不同層級的 Cache 上應用不同的配置策略,力圖在每一個新引入的 cache 層級上都進一步降低穿透到下一層的請求數量。在多層緩衝的幫助下我們可以讓不同的緩衝層級分別從空間維度和時間維度關注數據的熱度。我們甚至還可以在多數據中心部署的情況下,通過進一步增加緩衝層數利用多層緩衝的機制來極大的降低跨數據中心訪問數據的帶寬消耗和時延增加。

知乎已讀服務的前生今世與未來

跨數據中心部署

知乎作為一個內容社區,用戶的已讀數據是非常核心的行為數據。不但我們在首頁個性化推薦上有過濾需求,在個性化推送上也存在著類似的過濾需求。個性化推送是典型的離線任務,查詢吞吐更高但可以放鬆響應時間的要求,雖然他們所訪問的數據源相同但推送和首頁所訪問的數據在熱度分佈上存在著顯著的不同。為了讓業務之間不互相影響並且針對不同業務的數據訪問特徵選擇不同的緩衝策略,我們還進一步提供了cache 標籤隔離的機制來隔離離線寫入和多個不同的業務租戶的查詢。

"

導讀:對於很多大型網站來說,一些不起眼的小功能反而是實現的難點。對於知乎來說,已讀服務會隨著用戶量和內容數量的增長而平方級增長,而且響應時間要求很短,因此是一個有實現難度的系統。本文作者介紹了知乎已讀服務的架構設計和演進過程,並對很多技術取捨做了深入剖析,十分值得閱讀。

知乎已讀服務的前生今世與未來

作者簡介:孫曉光,知乎搜索後端負責人,目前承擔知乎搜索後端架構設計以及工程團隊的管理工作。曾多年從事私有云相關產品開發工作關注雲原生技術,TiKV 項目 Committer。

知乎從問答起步,在過去的8 年中逐步成長為一個大規模的綜合性知識內容平臺,目前,知乎上有多達 3000 萬個問題,共收穫了超過 1.3 億個回答,同時知乎還沉澱了數量眾多的文章、電子書以及其他付費內容。知乎通過個性化首頁推薦的方式在海量的信息中高效分發用戶感興趣的優質內容。為了避免給用戶推薦重複的內容,已讀服務會將所有知乎站上用戶深入閱讀或快速掠過的內容長期保存,並將這些數據應用於首頁推薦信息流和個性化推送的已讀過濾。

業務場景,技術挑戰

知乎已讀服務的前生今世與未來

首頁已讀過濾流程示意圖

從首頁使用已讀服務的流程我們可以看出這個服務的業務模式較為簡單,我們只需要簡單的以用戶為第一維度內容為第二緯度來查詢指定用戶是否已經閱讀過某個內容。但我們並沒因為業務簡單就在設計上放棄了靈活性和普適性。為此我們設計開發了一套支持BigTable 數據模型的 Cache Through 緩衝系統 RBase 來實現已讀服務,一方面充分利用 Cache 的高吞吐低時延能力,另一方面還可以利用靈活的 BigTable 數據模型來輔助業務快速演進。

知乎已讀服務的前生今世與未來

BigTable 數據模型

已讀服務雖然從業務模式看非常簡單,但它在技術上的挑戰確並不低。目前知乎已讀的數據規模已超萬億並以每天接近30 億的速度持續高速增長。與常見的“讀多寫少”的業務不同,已讀服務不僅需要在這樣的存量數據規模下提供在線查詢服務,還同時承載著每秒 4 萬條新紀錄寫入的衝擊。已讀內容過濾作為首頁信息流推薦中對響應時間影響較大的關鍵任務點,它的可用性和響應時間都需要滿足非常高的要求。

綜合業務需求和線上數據來看,已讀服務的要求和挑戰主要有以下幾點:

● 可用性要求「高」:服務於個性化首頁和個性化推送,最重要的流量分發渠道

● 寫入量「大」:峰值每秒寫入40K+ 行記錄,日新增記錄近 30 億條

● 歷史數據「長期」保存:目前已達一萬二千億條記錄

● 查詢吞吐「高」:在線查詢峰值30K QPS / 12M+ 條已讀檢查

● 響應時間「敏感」:90ms 超時

早期方案,架構演進

BloomFilter on Redis Cluster

知乎已讀服務的前生今世與未來

最初我們在Redis 集群上使用 BITSET 結構直接存儲已讀數據的 BloomFilter。首先由於缺乏多個位的批量操作,操作放大非常嚴重會消耗非常多的計算資源。其次使用全內存的方式存儲全量數據也拉高了整體成本。最後由於難以預估用戶的閱讀量增速無法以用戶為粒度合理控制 BloomFilter 的尺寸和 False Positive Rate。

HBase

考慮到BloomFilter on Redis 方案存在的問題,我們開始嘗試使用 HBase 來存儲用戶的閱讀歷史並提供在線查詢服務。已讀的業務需求可以非常直觀的映射到 BigTable 的數據模型上。我們將用戶 id 作為 row key,訪問的文檔 id 作為 qualifier 保存下來, 而 timestamp 則恰好可以用來記錄文檔的讀取時間。整個系統的可擴展性和成本都要顯著優於直接使用 Redis Cluster 存儲 BloomFilter 的方案。

隨著已讀數據量級和業務查詢量的迅速增長,已讀數據訪問極度稀疏的特點開始影響到了HBase 的 cache 命中率。HBase 的存儲模型在發生 cache miss 需要訪問存儲的情況下 IO 路徑很長。根據緩衝穿透的層次不同整個請求路徑上可能會經過數個 Java 進程,任何一個進程的 GC 和 IO 都會對這次訪問的 latency 產生顯著的影響,導致響應時間產生較大的波動,而大的響應時間波動是首頁難以接受的。

知乎已讀服務的前生今世與未來

HBase 緩衝穿透的 IO 路徑

在吸取了最初兩代已讀架構方案的經驗教訓後,我們開始設計實現新一代的已讀服務,在這次的設計中我們在可用性、性能以及擴展性上都設定了更高的目標尤其是以往系統表現不好的性能和擴展性方面我們希望看到更加長足的進步。

● 高可用

● ✓ HBase

● ✓BloomFilter on Redis Cluster

● 高性能

● ✘HBase

● ✓BloomFilter on Redis Cluster

● 易擴展

● ✓HBase

● ✘BloomFilter on Redis Cluster

下面就讓我們一起從高可用、高性能和易擴展這三個角度來思考如何構建一個更好的已讀服務滿足好業務的需求和挑戰。

高可用

當我們討論高可用的時候,也意味著我們已經意識到故障是無時無刻都在發生的,依賴傳統人工運維的方式來保證複雜系統的高可用是不現實的。我們需要以系統化的方式對各個組件的狀態進行探測感知他們發生的故障。並且我們需要為系統中的組件設計自愈機制,當故障發生時可以不經人工干預而自動的恢復。最後我們還需要隔離各種故障所產生的變化,讓業務側儘可能對故障的發生和恢復無感知。

知乎已讀服務的前生今世與未來

故障監測、自動恢復並隔離變化

高性能

對常見的系統來說,越核心的組件往往狀態越重擴展的代價也越大,層層攔截快速降低需要深入到核心組件的請求量對提高性能是非常有效的手段。首先我們通過緩衝分Slot 的方式來擴展集群所能緩衝的數據規模。接著進一步在 Slot 內通過多副本的方式提升單個 Slot 緩衝數據集的讀取吞吐,將大量的請求攔截在系統的緩衝層進行消化。如果請求不可避免的走到了最終的數據庫組件上,我們還可以利用效率較高的壓縮來繼續降低落到物理設備上的IO壓力。

知乎已讀服務的前生今世與未來

分層去併發

易擴展

提升系統擴展性的關鍵在於減少有狀態組件的範圍,在路由和服務發現組件的幫助下,系統中的無狀態組件可以非常輕鬆的擴展擴容。所以通過擴大無狀態服務的範圍,收縮重狀態服務的比例可以顯著的幫助我們提升整個系統的可擴展性。除此之外如果我們能夠設計一些可以從外部系統恢復狀態的弱狀態服務,那麼我們往往可以利用弱狀態的組件來部分替代重狀態組件。隨著弱狀態組件的擴大和重狀態組件的收縮,整個系統的可擴展性可以得到進一步的提升。

知乎已讀服務的前生今世與未來

弱狀態部分替代重狀態

RBase

在高可用、高性能和易擴展的設計理念下,我們設計實現了RBase 做為已讀服務的根基。現在讓我們來從 RBase 全局設計入手來了解高可用、高性能和易擴展的設計理念是如何落地的。

知乎已讀服務的前生今世與未來

RBase 架構

客戶端API 和 Proxy 是完全無狀態可隨時擴展的組件,最底層是由 MHA 管理的 MySQL 集群,中間存在大量可從數據庫或者副本中恢復的弱狀態組件。這些弱狀態組件中最核心的部分是分層的緩衝模塊,這些緩衝模塊的狀態可以從副本或數據庫中恢復重建因此他們的可擴展性仍然是非常優秀的。緩衝以外的組件則負責管理緩衝的一致性,在它們的協助下緩衝模塊可以完全避免無意義的 Cache Invalidate 提升緩存的命中率,從而極大緩解了傳導到底層數據庫系統上的壓力。整個架構中除了唯一的重狀態組件 MySQL 集群之外所有的組件都擁有自我恢復的能力。在擁有自我恢復能力和全局故障監測的前提下,我們使用 Kubernetes 來管理所有 RBase 的服務組件在機制上確保整個服務的高可用。

RBase 的設計中緩存是一個非常關鍵的組件,然而它們的組織管理方式又同常見的緩存系統不盡相同,這些設計上的不同體現著我們在高性能方面的思考。現在讓我們以一個典型的計算機系統的內存分層設計來引出我們在緩存系統設計上的思路。

知乎已讀服務的前生今世與未來

計算機系統內存分層示意圖

在過去的幾十年中工業界為了提升計算機系統的性能,在計算機系統中添加了更多的CPU 乃至更多的核心。隨著 CPU 內部處理能力的不斷增強我們還為 CPU 加上多級的緩存來彌補主存在帶寬和時延上同 CPU 的巨大差距。除此之外我們還進一步把主存連同 CPU 分成多組讓他們之間有更快的本地連接,只有當需要交叉訪問遠程內存的時候再通過互聯總線進行交流。在這樣一個系統裡大部分的讀寫操作都發生在離核心最近的 L1 或者 L2 cache 裡,而在修改主存中數據的時候則需要更加複雜的機制和更長的時間來達成緩衝的最終一致。為了能將計算機系統的性能發揮到極致工業界不斷的改進體系結構,在這個架構中處處都體現著設計的取捨和智慧。

我們利用在內存分層設計上獲得的靈感,在RBase 的緩存一致性上採用了類似的設計。類似計算機系統內存的數據庫層,相似的分層緩衝設計,類似的 cache through 以及 cache coherence 組件設計。大道至簡殊途同歸,通過學習借鑑體系結構經典成熟的思想將它應用到軟件系統設計中同樣可以得到非常好的效果。

核心組件,關鍵設計

接下來讓我們從細節來深入瞭解已讀系統中一些核心組件的關鍵設計。

Proxy

知乎已讀服務的前生今世與未來

Proxy 負責負載均衡和隔離故障

Proxy 是已讀服務的接入層組件,它用傳統的方式將緩衝按照用戶維度拆分成多個 slot 組織起來,每個 slot 負責數據集內的一個子集。Slot 內可以有多個副本來分擔同一批數據的讀取壓力,proxy 會在 slot 內對同一個會話綁定同一個副本來保證會話內的一致性,當副本發生故障時 proxy 優先選擇同一個 slot 內的其它副本來繼續承載請求,在極端情況發生 slot 內的所有副本同時失效時,proxy 還可以選擇其它 slot 的活躍節點來處理用戶的請求,這時我們付出了無法利用緩衝提高性能的代價來換取系統在極端場景下的可用性。

Cache

在由「用戶」和「內容類型」和「內容」所組成的空間中,由於「用戶」維度和「內容」維度的基數非常高,都在數億級別,即使記錄數在萬億這樣的數量級下,數據在整個三維空間內的分佈依然非常稀疏。單純依靠底層存儲系統的能力很難在尺寸巨大且極度稀疏的數據集上提供高吞吐的在線查詢,更難以滿足業務對低響應時間的要求。另外尺寸巨大且分佈稀疏的數據集對緩存系統的資源消耗和命中率的也提出了巨大的挑戰。

知乎已讀服務的前生今世與未來

已讀數據空間分佈極度稀疏

考慮到目前知乎站上沉澱的內容量級巨大,我們可以容忍false positive 但依舊為用戶召回到足夠多可能會感興趣的內容。基於這樣的業務特點我們可以將數據庫中存儲的原始數據轉化為 BloomFilter 緩衝起來,這極大的降低了內存的消耗在相同的資源狀況下可以緩衝更多的數據提高緩存的命中率。

知乎已讀服務的前生今世與未來

緩存Bloom Filter

提升緩存命中率的方式有很多種,除了前面提到的提升緩存數據密度增加可緩衝的數據量級之外,我們還可以通過避免不必要的緩存失效來進一步的提升緩存的效率。因此我們將緩存設計為write through cache 使用原地更新緩存的方式來避免 invalidate cache 操作。再配合數據變更訂閱我們可以在不失效緩衝的情況下確保同一份數據的多個緩衝副本能在很短的時間內達成最終一致。另一方面得益於 read through 的設計,我們可以將對同一份數據的多個併發查詢請求轉化成一次 cache miss 加多次緩衝讀取,進一步提升緩存的命中率降低穿透到底層數據庫系統的壓力。

知乎已讀服務的前生今世與未來

Cache Through

緩衝系統的核心工作是攔截住大量熱數據的訪問,因此維持緩衝數據的熱度是整個系統的穩定性的關鍵因素。但作為不斷迭代演進的業務系統,如何在系統滾動升級或者副本擴容的時候讓新啟動的緩衝節點也快速熱身進入狀態呢?雖然我們可以選擇逐步向新節點開放流量的方式避免冷緩衝的影響,但我們也會面臨故障後自動恢復的情景,這時我們沒有時間等待新的緩衝服務逐步進入狀態。考慮到這點我們在新節點啟動後會從當前slot 內挑選一個活躍的副本遷移全部或足夠多的狀態讓新節點快速進入工作狀態,避免因新節點緩衝不熱導致的響應時間抖動。

知乎已讀服務的前生今世與未來

緩衝狀態遷移

前面我們提到借鑑計算機系統中的內存分層設計,接下來我們從收益的角度來探討採用類似的分層緩衝設計在已讀服務上為系統帶來了什麼。現實中的緩衝系統不論如何提高命中率,我們始終都需要面對cache miss 的場景。多層緩衝的存在可以讓我們在不同層級的 Cache 上應用不同的配置策略,力圖在每一個新引入的 cache 層級上都進一步降低穿透到下一層的請求數量。在多層緩衝的幫助下我們可以讓不同的緩衝層級分別從空間維度和時間維度關注數據的熱度。我們甚至還可以在多數據中心部署的情況下,通過進一步增加緩衝層數利用多層緩衝的機制來極大的降低跨數據中心訪問數據的帶寬消耗和時延增加。

知乎已讀服務的前生今世與未來

跨數據中心部署

知乎作為一個內容社區,用戶的已讀數據是非常核心的行為數據。不但我們在首頁個性化推薦上有過濾需求,在個性化推送上也存在著類似的過濾需求。個性化推送是典型的離線任務,查詢吞吐更高但可以放鬆響應時間的要求,雖然他們所訪問的數據源相同但推送和首頁所訪問的數據在熱度分佈上存在著顯著的不同。為了讓業務之間不互相影響並且針對不同業務的數據訪問特徵選擇不同的緩衝策略,我們還進一步提供了cache 標籤隔離的機制來隔離離線寫入和多個不同的業務租戶的查詢。

知乎已讀服務的前生今世與未來

業務獨立的緩衝策略以及物理隔離

MySQL

在系統開發初期為了加快開發效率,我們在物理存儲層上選擇了在知乎內部應用最廣泛的MySQL。針對系統對高性能和高可用的要求,我們使用了分庫分表加MHA機制來提升系統的性能並保障系統的高可用。除此以外我們還根據已讀數據高寫入低刪除的特點選擇了更適合這個場景的 TokuDB 存儲引擎,得益於 TokuDB 引擎極高的壓縮比系統在一萬億記錄時單副本的總數據尺寸大約13T,平均一行記錄僅使用了 10 多字節的空間。在這個階段我們使用了 12 臺節點來承載這一萬億已讀數據,在這樣的一個集群規模下手工運維還是勉強可以接受的。

性能指標

到2019 年初當前一代的已讀服務已在線上穩定服務了首頁一年有餘,在各項業務指標上來看都是非常滿意的。目前已讀的流量已達每秒 4 萬行紀錄寫入, 3 萬獨立查詢 和 1200 萬個文檔判讀,在這樣的壓力下已讀服務響應時間的 P99 和 P999 仍然穩定的維持在 25ms 和 50ms。

"

導讀:對於很多大型網站來說,一些不起眼的小功能反而是實現的難點。對於知乎來說,已讀服務會隨著用戶量和內容數量的增長而平方級增長,而且響應時間要求很短,因此是一個有實現難度的系統。本文作者介紹了知乎已讀服務的架構設計和演進過程,並對很多技術取捨做了深入剖析,十分值得閱讀。

知乎已讀服務的前生今世與未來

作者簡介:孫曉光,知乎搜索後端負責人,目前承擔知乎搜索後端架構設計以及工程團隊的管理工作。曾多年從事私有云相關產品開發工作關注雲原生技術,TiKV 項目 Committer。

知乎從問答起步,在過去的8 年中逐步成長為一個大規模的綜合性知識內容平臺,目前,知乎上有多達 3000 萬個問題,共收穫了超過 1.3 億個回答,同時知乎還沉澱了數量眾多的文章、電子書以及其他付費內容。知乎通過個性化首頁推薦的方式在海量的信息中高效分發用戶感興趣的優質內容。為了避免給用戶推薦重複的內容,已讀服務會將所有知乎站上用戶深入閱讀或快速掠過的內容長期保存,並將這些數據應用於首頁推薦信息流和個性化推送的已讀過濾。

業務場景,技術挑戰

知乎已讀服務的前生今世與未來

首頁已讀過濾流程示意圖

從首頁使用已讀服務的流程我們可以看出這個服務的業務模式較為簡單,我們只需要簡單的以用戶為第一維度內容為第二緯度來查詢指定用戶是否已經閱讀過某個內容。但我們並沒因為業務簡單就在設計上放棄了靈活性和普適性。為此我們設計開發了一套支持BigTable 數據模型的 Cache Through 緩衝系統 RBase 來實現已讀服務,一方面充分利用 Cache 的高吞吐低時延能力,另一方面還可以利用靈活的 BigTable 數據模型來輔助業務快速演進。

知乎已讀服務的前生今世與未來

BigTable 數據模型

已讀服務雖然從業務模式看非常簡單,但它在技術上的挑戰確並不低。目前知乎已讀的數據規模已超萬億並以每天接近30 億的速度持續高速增長。與常見的“讀多寫少”的業務不同,已讀服務不僅需要在這樣的存量數據規模下提供在線查詢服務,還同時承載著每秒 4 萬條新紀錄寫入的衝擊。已讀內容過濾作為首頁信息流推薦中對響應時間影響較大的關鍵任務點,它的可用性和響應時間都需要滿足非常高的要求。

綜合業務需求和線上數據來看,已讀服務的要求和挑戰主要有以下幾點:

● 可用性要求「高」:服務於個性化首頁和個性化推送,最重要的流量分發渠道

● 寫入量「大」:峰值每秒寫入40K+ 行記錄,日新增記錄近 30 億條

● 歷史數據「長期」保存:目前已達一萬二千億條記錄

● 查詢吞吐「高」:在線查詢峰值30K QPS / 12M+ 條已讀檢查

● 響應時間「敏感」:90ms 超時

早期方案,架構演進

BloomFilter on Redis Cluster

知乎已讀服務的前生今世與未來

最初我們在Redis 集群上使用 BITSET 結構直接存儲已讀數據的 BloomFilter。首先由於缺乏多個位的批量操作,操作放大非常嚴重會消耗非常多的計算資源。其次使用全內存的方式存儲全量數據也拉高了整體成本。最後由於難以預估用戶的閱讀量增速無法以用戶為粒度合理控制 BloomFilter 的尺寸和 False Positive Rate。

HBase

考慮到BloomFilter on Redis 方案存在的問題,我們開始嘗試使用 HBase 來存儲用戶的閱讀歷史並提供在線查詢服務。已讀的業務需求可以非常直觀的映射到 BigTable 的數據模型上。我們將用戶 id 作為 row key,訪問的文檔 id 作為 qualifier 保存下來, 而 timestamp 則恰好可以用來記錄文檔的讀取時間。整個系統的可擴展性和成本都要顯著優於直接使用 Redis Cluster 存儲 BloomFilter 的方案。

隨著已讀數據量級和業務查詢量的迅速增長,已讀數據訪問極度稀疏的特點開始影響到了HBase 的 cache 命中率。HBase 的存儲模型在發生 cache miss 需要訪問存儲的情況下 IO 路徑很長。根據緩衝穿透的層次不同整個請求路徑上可能會經過數個 Java 進程,任何一個進程的 GC 和 IO 都會對這次訪問的 latency 產生顯著的影響,導致響應時間產生較大的波動,而大的響應時間波動是首頁難以接受的。

知乎已讀服務的前生今世與未來

HBase 緩衝穿透的 IO 路徑

在吸取了最初兩代已讀架構方案的經驗教訓後,我們開始設計實現新一代的已讀服務,在這次的設計中我們在可用性、性能以及擴展性上都設定了更高的目標尤其是以往系統表現不好的性能和擴展性方面我們希望看到更加長足的進步。

● 高可用

● ✓ HBase

● ✓BloomFilter on Redis Cluster

● 高性能

● ✘HBase

● ✓BloomFilter on Redis Cluster

● 易擴展

● ✓HBase

● ✘BloomFilter on Redis Cluster

下面就讓我們一起從高可用、高性能和易擴展這三個角度來思考如何構建一個更好的已讀服務滿足好業務的需求和挑戰。

高可用

當我們討論高可用的時候,也意味著我們已經意識到故障是無時無刻都在發生的,依賴傳統人工運維的方式來保證複雜系統的高可用是不現實的。我們需要以系統化的方式對各個組件的狀態進行探測感知他們發生的故障。並且我們需要為系統中的組件設計自愈機制,當故障發生時可以不經人工干預而自動的恢復。最後我們還需要隔離各種故障所產生的變化,讓業務側儘可能對故障的發生和恢復無感知。

知乎已讀服務的前生今世與未來

故障監測、自動恢復並隔離變化

高性能

對常見的系統來說,越核心的組件往往狀態越重擴展的代價也越大,層層攔截快速降低需要深入到核心組件的請求量對提高性能是非常有效的手段。首先我們通過緩衝分Slot 的方式來擴展集群所能緩衝的數據規模。接著進一步在 Slot 內通過多副本的方式提升單個 Slot 緩衝數據集的讀取吞吐,將大量的請求攔截在系統的緩衝層進行消化。如果請求不可避免的走到了最終的數據庫組件上,我們還可以利用效率較高的壓縮來繼續降低落到物理設備上的IO壓力。

知乎已讀服務的前生今世與未來

分層去併發

易擴展

提升系統擴展性的關鍵在於減少有狀態組件的範圍,在路由和服務發現組件的幫助下,系統中的無狀態組件可以非常輕鬆的擴展擴容。所以通過擴大無狀態服務的範圍,收縮重狀態服務的比例可以顯著的幫助我們提升整個系統的可擴展性。除此之外如果我們能夠設計一些可以從外部系統恢復狀態的弱狀態服務,那麼我們往往可以利用弱狀態的組件來部分替代重狀態組件。隨著弱狀態組件的擴大和重狀態組件的收縮,整個系統的可擴展性可以得到進一步的提升。

知乎已讀服務的前生今世與未來

弱狀態部分替代重狀態

RBase

在高可用、高性能和易擴展的設計理念下,我們設計實現了RBase 做為已讀服務的根基。現在讓我們來從 RBase 全局設計入手來了解高可用、高性能和易擴展的設計理念是如何落地的。

知乎已讀服務的前生今世與未來

RBase 架構

客戶端API 和 Proxy 是完全無狀態可隨時擴展的組件,最底層是由 MHA 管理的 MySQL 集群,中間存在大量可從數據庫或者副本中恢復的弱狀態組件。這些弱狀態組件中最核心的部分是分層的緩衝模塊,這些緩衝模塊的狀態可以從副本或數據庫中恢復重建因此他們的可擴展性仍然是非常優秀的。緩衝以外的組件則負責管理緩衝的一致性,在它們的協助下緩衝模塊可以完全避免無意義的 Cache Invalidate 提升緩存的命中率,從而極大緩解了傳導到底層數據庫系統上的壓力。整個架構中除了唯一的重狀態組件 MySQL 集群之外所有的組件都擁有自我恢復的能力。在擁有自我恢復能力和全局故障監測的前提下,我們使用 Kubernetes 來管理所有 RBase 的服務組件在機制上確保整個服務的高可用。

RBase 的設計中緩存是一個非常關鍵的組件,然而它們的組織管理方式又同常見的緩存系統不盡相同,這些設計上的不同體現著我們在高性能方面的思考。現在讓我們以一個典型的計算機系統的內存分層設計來引出我們在緩存系統設計上的思路。

知乎已讀服務的前生今世與未來

計算機系統內存分層示意圖

在過去的幾十年中工業界為了提升計算機系統的性能,在計算機系統中添加了更多的CPU 乃至更多的核心。隨著 CPU 內部處理能力的不斷增強我們還為 CPU 加上多級的緩存來彌補主存在帶寬和時延上同 CPU 的巨大差距。除此之外我們還進一步把主存連同 CPU 分成多組讓他們之間有更快的本地連接,只有當需要交叉訪問遠程內存的時候再通過互聯總線進行交流。在這樣一個系統裡大部分的讀寫操作都發生在離核心最近的 L1 或者 L2 cache 裡,而在修改主存中數據的時候則需要更加複雜的機制和更長的時間來達成緩衝的最終一致。為了能將計算機系統的性能發揮到極致工業界不斷的改進體系結構,在這個架構中處處都體現著設計的取捨和智慧。

我們利用在內存分層設計上獲得的靈感,在RBase 的緩存一致性上採用了類似的設計。類似計算機系統內存的數據庫層,相似的分層緩衝設計,類似的 cache through 以及 cache coherence 組件設計。大道至簡殊途同歸,通過學習借鑑體系結構經典成熟的思想將它應用到軟件系統設計中同樣可以得到非常好的效果。

核心組件,關鍵設計

接下來讓我們從細節來深入瞭解已讀系統中一些核心組件的關鍵設計。

Proxy

知乎已讀服務的前生今世與未來

Proxy 負責負載均衡和隔離故障

Proxy 是已讀服務的接入層組件,它用傳統的方式將緩衝按照用戶維度拆分成多個 slot 組織起來,每個 slot 負責數據集內的一個子集。Slot 內可以有多個副本來分擔同一批數據的讀取壓力,proxy 會在 slot 內對同一個會話綁定同一個副本來保證會話內的一致性,當副本發生故障時 proxy 優先選擇同一個 slot 內的其它副本來繼續承載請求,在極端情況發生 slot 內的所有副本同時失效時,proxy 還可以選擇其它 slot 的活躍節點來處理用戶的請求,這時我們付出了無法利用緩衝提高性能的代價來換取系統在極端場景下的可用性。

Cache

在由「用戶」和「內容類型」和「內容」所組成的空間中,由於「用戶」維度和「內容」維度的基數非常高,都在數億級別,即使記錄數在萬億這樣的數量級下,數據在整個三維空間內的分佈依然非常稀疏。單純依靠底層存儲系統的能力很難在尺寸巨大且極度稀疏的數據集上提供高吞吐的在線查詢,更難以滿足業務對低響應時間的要求。另外尺寸巨大且分佈稀疏的數據集對緩存系統的資源消耗和命中率的也提出了巨大的挑戰。

知乎已讀服務的前生今世與未來

已讀數據空間分佈極度稀疏

考慮到目前知乎站上沉澱的內容量級巨大,我們可以容忍false positive 但依舊為用戶召回到足夠多可能會感興趣的內容。基於這樣的業務特點我們可以將數據庫中存儲的原始數據轉化為 BloomFilter 緩衝起來,這極大的降低了內存的消耗在相同的資源狀況下可以緩衝更多的數據提高緩存的命中率。

知乎已讀服務的前生今世與未來

緩存Bloom Filter

提升緩存命中率的方式有很多種,除了前面提到的提升緩存數據密度增加可緩衝的數據量級之外,我們還可以通過避免不必要的緩存失效來進一步的提升緩存的效率。因此我們將緩存設計為write through cache 使用原地更新緩存的方式來避免 invalidate cache 操作。再配合數據變更訂閱我們可以在不失效緩衝的情況下確保同一份數據的多個緩衝副本能在很短的時間內達成最終一致。另一方面得益於 read through 的設計,我們可以將對同一份數據的多個併發查詢請求轉化成一次 cache miss 加多次緩衝讀取,進一步提升緩存的命中率降低穿透到底層數據庫系統的壓力。

知乎已讀服務的前生今世與未來

Cache Through

緩衝系統的核心工作是攔截住大量熱數據的訪問,因此維持緩衝數據的熱度是整個系統的穩定性的關鍵因素。但作為不斷迭代演進的業務系統,如何在系統滾動升級或者副本擴容的時候讓新啟動的緩衝節點也快速熱身進入狀態呢?雖然我們可以選擇逐步向新節點開放流量的方式避免冷緩衝的影響,但我們也會面臨故障後自動恢復的情景,這時我們沒有時間等待新的緩衝服務逐步進入狀態。考慮到這點我們在新節點啟動後會從當前slot 內挑選一個活躍的副本遷移全部或足夠多的狀態讓新節點快速進入工作狀態,避免因新節點緩衝不熱導致的響應時間抖動。

知乎已讀服務的前生今世與未來

緩衝狀態遷移

前面我們提到借鑑計算機系統中的內存分層設計,接下來我們從收益的角度來探討採用類似的分層緩衝設計在已讀服務上為系統帶來了什麼。現實中的緩衝系統不論如何提高命中率,我們始終都需要面對cache miss 的場景。多層緩衝的存在可以讓我們在不同層級的 Cache 上應用不同的配置策略,力圖在每一個新引入的 cache 層級上都進一步降低穿透到下一層的請求數量。在多層緩衝的幫助下我們可以讓不同的緩衝層級分別從空間維度和時間維度關注數據的熱度。我們甚至還可以在多數據中心部署的情況下,通過進一步增加緩衝層數利用多層緩衝的機制來極大的降低跨數據中心訪問數據的帶寬消耗和時延增加。

知乎已讀服務的前生今世與未來

跨數據中心部署

知乎作為一個內容社區,用戶的已讀數據是非常核心的行為數據。不但我們在首頁個性化推薦上有過濾需求,在個性化推送上也存在著類似的過濾需求。個性化推送是典型的離線任務,查詢吞吐更高但可以放鬆響應時間的要求,雖然他們所訪問的數據源相同但推送和首頁所訪問的數據在熱度分佈上存在著顯著的不同。為了讓業務之間不互相影響並且針對不同業務的數據訪問特徵選擇不同的緩衝策略,我們還進一步提供了cache 標籤隔離的機制來隔離離線寫入和多個不同的業務租戶的查詢。

知乎已讀服務的前生今世與未來

業務獨立的緩衝策略以及物理隔離

MySQL

在系統開發初期為了加快開發效率,我們在物理存儲層上選擇了在知乎內部應用最廣泛的MySQL。針對系統對高性能和高可用的要求,我們使用了分庫分表加MHA機制來提升系統的性能並保障系統的高可用。除此以外我們還根據已讀數據高寫入低刪除的特點選擇了更適合這個場景的 TokuDB 存儲引擎,得益於 TokuDB 引擎極高的壓縮比系統在一萬億記錄時單副本的總數據尺寸大約13T,平均一行記錄僅使用了 10 多字節的空間。在這個階段我們使用了 12 臺節點來承載這一萬億已讀數據,在這樣的一個集群規模下手工運維還是勉強可以接受的。

性能指標

到2019 年初當前一代的已讀服務已在線上穩定服務了首頁一年有餘,在各項業務指標上來看都是非常滿意的。目前已讀的流量已達每秒 4 萬行紀錄寫入, 3 萬獨立查詢 和 1200 萬個文檔判讀,在這樣的壓力下已讀服務響應時間的 P99 和 P999 仍然穩定的維持在 25ms 和 50ms。

知乎已讀服務的前生今世與未來

已讀服務核心業務指標

全面雲化,面向未來

從已讀的業務指標上看我們交出了一份還讓人滿意的答卷,但作為已讀服務的開發和運維人員我們深知目前的這套架構還存在著一些核心的痛點沒有解決好。首當其中的就是MySQL 的運維問題。我們不但需要考慮數據量繼續膨脹後需要再次分庫分表的擴展性問題,還需要考慮整個集群的高可用和節點發生物理故障後的恢復等一系列問題。在每月數據量近 1000 億持續膨脹帶來的壓力下我們的不安感與日俱增,迫切的需要一個完善的方案來解決 MySQL 集群的運維問題。其次已讀系統的整體架構都是面向在線業務設計的,這導致數據分析的工作很難被直接的應用在這樣的架構之上。帶著已讀服務的這些問題我們於近期開始了新一輪的迭代,本輪迭代的核心目標是將已讀服務全面雲化,達到全系統高可用規模隨需擴展的目標。

已讀服務最痛的MySQL 運維問題實質是單機數據庫在擴展性和可用性上不足所帶來的問題,那麼這個問題最直接了當的解決方式就是致力於解決這些問題的原生分佈式的數據庫。幸運的是近些年工業界在原生分佈式數據庫領域有了非常多的進展,而 CockroachDB 和 TiDB 則是在這個領域非常優秀的兩個開源項目。雖然他們的具體實現細節和技術路徑有所不同,但在大方向上看他們有著不少的相似點。

"

導讀:對於很多大型網站來說,一些不起眼的小功能反而是實現的難點。對於知乎來說,已讀服務會隨著用戶量和內容數量的增長而平方級增長,而且響應時間要求很短,因此是一個有實現難度的系統。本文作者介紹了知乎已讀服務的架構設計和演進過程,並對很多技術取捨做了深入剖析,十分值得閱讀。

知乎已讀服務的前生今世與未來

作者簡介:孫曉光,知乎搜索後端負責人,目前承擔知乎搜索後端架構設計以及工程團隊的管理工作。曾多年從事私有云相關產品開發工作關注雲原生技術,TiKV 項目 Committer。

知乎從問答起步,在過去的8 年中逐步成長為一個大規模的綜合性知識內容平臺,目前,知乎上有多達 3000 萬個問題,共收穫了超過 1.3 億個回答,同時知乎還沉澱了數量眾多的文章、電子書以及其他付費內容。知乎通過個性化首頁推薦的方式在海量的信息中高效分發用戶感興趣的優質內容。為了避免給用戶推薦重複的內容,已讀服務會將所有知乎站上用戶深入閱讀或快速掠過的內容長期保存,並將這些數據應用於首頁推薦信息流和個性化推送的已讀過濾。

業務場景,技術挑戰

知乎已讀服務的前生今世與未來

首頁已讀過濾流程示意圖

從首頁使用已讀服務的流程我們可以看出這個服務的業務模式較為簡單,我們只需要簡單的以用戶為第一維度內容為第二緯度來查詢指定用戶是否已經閱讀過某個內容。但我們並沒因為業務簡單就在設計上放棄了靈活性和普適性。為此我們設計開發了一套支持BigTable 數據模型的 Cache Through 緩衝系統 RBase 來實現已讀服務,一方面充分利用 Cache 的高吞吐低時延能力,另一方面還可以利用靈活的 BigTable 數據模型來輔助業務快速演進。

知乎已讀服務的前生今世與未來

BigTable 數據模型

已讀服務雖然從業務模式看非常簡單,但它在技術上的挑戰確並不低。目前知乎已讀的數據規模已超萬億並以每天接近30 億的速度持續高速增長。與常見的“讀多寫少”的業務不同,已讀服務不僅需要在這樣的存量數據規模下提供在線查詢服務,還同時承載著每秒 4 萬條新紀錄寫入的衝擊。已讀內容過濾作為首頁信息流推薦中對響應時間影響較大的關鍵任務點,它的可用性和響應時間都需要滿足非常高的要求。

綜合業務需求和線上數據來看,已讀服務的要求和挑戰主要有以下幾點:

● 可用性要求「高」:服務於個性化首頁和個性化推送,最重要的流量分發渠道

● 寫入量「大」:峰值每秒寫入40K+ 行記錄,日新增記錄近 30 億條

● 歷史數據「長期」保存:目前已達一萬二千億條記錄

● 查詢吞吐「高」:在線查詢峰值30K QPS / 12M+ 條已讀檢查

● 響應時間「敏感」:90ms 超時

早期方案,架構演進

BloomFilter on Redis Cluster

知乎已讀服務的前生今世與未來

最初我們在Redis 集群上使用 BITSET 結構直接存儲已讀數據的 BloomFilter。首先由於缺乏多個位的批量操作,操作放大非常嚴重會消耗非常多的計算資源。其次使用全內存的方式存儲全量數據也拉高了整體成本。最後由於難以預估用戶的閱讀量增速無法以用戶為粒度合理控制 BloomFilter 的尺寸和 False Positive Rate。

HBase

考慮到BloomFilter on Redis 方案存在的問題,我們開始嘗試使用 HBase 來存儲用戶的閱讀歷史並提供在線查詢服務。已讀的業務需求可以非常直觀的映射到 BigTable 的數據模型上。我們將用戶 id 作為 row key,訪問的文檔 id 作為 qualifier 保存下來, 而 timestamp 則恰好可以用來記錄文檔的讀取時間。整個系統的可擴展性和成本都要顯著優於直接使用 Redis Cluster 存儲 BloomFilter 的方案。

隨著已讀數據量級和業務查詢量的迅速增長,已讀數據訪問極度稀疏的特點開始影響到了HBase 的 cache 命中率。HBase 的存儲模型在發生 cache miss 需要訪問存儲的情況下 IO 路徑很長。根據緩衝穿透的層次不同整個請求路徑上可能會經過數個 Java 進程,任何一個進程的 GC 和 IO 都會對這次訪問的 latency 產生顯著的影響,導致響應時間產生較大的波動,而大的響應時間波動是首頁難以接受的。

知乎已讀服務的前生今世與未來

HBase 緩衝穿透的 IO 路徑

在吸取了最初兩代已讀架構方案的經驗教訓後,我們開始設計實現新一代的已讀服務,在這次的設計中我們在可用性、性能以及擴展性上都設定了更高的目標尤其是以往系統表現不好的性能和擴展性方面我們希望看到更加長足的進步。

● 高可用

● ✓ HBase

● ✓BloomFilter on Redis Cluster

● 高性能

● ✘HBase

● ✓BloomFilter on Redis Cluster

● 易擴展

● ✓HBase

● ✘BloomFilter on Redis Cluster

下面就讓我們一起從高可用、高性能和易擴展這三個角度來思考如何構建一個更好的已讀服務滿足好業務的需求和挑戰。

高可用

當我們討論高可用的時候,也意味著我們已經意識到故障是無時無刻都在發生的,依賴傳統人工運維的方式來保證複雜系統的高可用是不現實的。我們需要以系統化的方式對各個組件的狀態進行探測感知他們發生的故障。並且我們需要為系統中的組件設計自愈機制,當故障發生時可以不經人工干預而自動的恢復。最後我們還需要隔離各種故障所產生的變化,讓業務側儘可能對故障的發生和恢復無感知。

知乎已讀服務的前生今世與未來

故障監測、自動恢復並隔離變化

高性能

對常見的系統來說,越核心的組件往往狀態越重擴展的代價也越大,層層攔截快速降低需要深入到核心組件的請求量對提高性能是非常有效的手段。首先我們通過緩衝分Slot 的方式來擴展集群所能緩衝的數據規模。接著進一步在 Slot 內通過多副本的方式提升單個 Slot 緩衝數據集的讀取吞吐,將大量的請求攔截在系統的緩衝層進行消化。如果請求不可避免的走到了最終的數據庫組件上,我們還可以利用效率較高的壓縮來繼續降低落到物理設備上的IO壓力。

知乎已讀服務的前生今世與未來

分層去併發

易擴展

提升系統擴展性的關鍵在於減少有狀態組件的範圍,在路由和服務發現組件的幫助下,系統中的無狀態組件可以非常輕鬆的擴展擴容。所以通過擴大無狀態服務的範圍,收縮重狀態服務的比例可以顯著的幫助我們提升整個系統的可擴展性。除此之外如果我們能夠設計一些可以從外部系統恢復狀態的弱狀態服務,那麼我們往往可以利用弱狀態的組件來部分替代重狀態組件。隨著弱狀態組件的擴大和重狀態組件的收縮,整個系統的可擴展性可以得到進一步的提升。

知乎已讀服務的前生今世與未來

弱狀態部分替代重狀態

RBase

在高可用、高性能和易擴展的設計理念下,我們設計實現了RBase 做為已讀服務的根基。現在讓我們來從 RBase 全局設計入手來了解高可用、高性能和易擴展的設計理念是如何落地的。

知乎已讀服務的前生今世與未來

RBase 架構

客戶端API 和 Proxy 是完全無狀態可隨時擴展的組件,最底層是由 MHA 管理的 MySQL 集群,中間存在大量可從數據庫或者副本中恢復的弱狀態組件。這些弱狀態組件中最核心的部分是分層的緩衝模塊,這些緩衝模塊的狀態可以從副本或數據庫中恢復重建因此他們的可擴展性仍然是非常優秀的。緩衝以外的組件則負責管理緩衝的一致性,在它們的協助下緩衝模塊可以完全避免無意義的 Cache Invalidate 提升緩存的命中率,從而極大緩解了傳導到底層數據庫系統上的壓力。整個架構中除了唯一的重狀態組件 MySQL 集群之外所有的組件都擁有自我恢復的能力。在擁有自我恢復能力和全局故障監測的前提下,我們使用 Kubernetes 來管理所有 RBase 的服務組件在機制上確保整個服務的高可用。

RBase 的設計中緩存是一個非常關鍵的組件,然而它們的組織管理方式又同常見的緩存系統不盡相同,這些設計上的不同體現著我們在高性能方面的思考。現在讓我們以一個典型的計算機系統的內存分層設計來引出我們在緩存系統設計上的思路。

知乎已讀服務的前生今世與未來

計算機系統內存分層示意圖

在過去的幾十年中工業界為了提升計算機系統的性能,在計算機系統中添加了更多的CPU 乃至更多的核心。隨著 CPU 內部處理能力的不斷增強我們還為 CPU 加上多級的緩存來彌補主存在帶寬和時延上同 CPU 的巨大差距。除此之外我們還進一步把主存連同 CPU 分成多組讓他們之間有更快的本地連接,只有當需要交叉訪問遠程內存的時候再通過互聯總線進行交流。在這樣一個系統裡大部分的讀寫操作都發生在離核心最近的 L1 或者 L2 cache 裡,而在修改主存中數據的時候則需要更加複雜的機制和更長的時間來達成緩衝的最終一致。為了能將計算機系統的性能發揮到極致工業界不斷的改進體系結構,在這個架構中處處都體現著設計的取捨和智慧。

我們利用在內存分層設計上獲得的靈感,在RBase 的緩存一致性上採用了類似的設計。類似計算機系統內存的數據庫層,相似的分層緩衝設計,類似的 cache through 以及 cache coherence 組件設計。大道至簡殊途同歸,通過學習借鑑體系結構經典成熟的思想將它應用到軟件系統設計中同樣可以得到非常好的效果。

核心組件,關鍵設計

接下來讓我們從細節來深入瞭解已讀系統中一些核心組件的關鍵設計。

Proxy

知乎已讀服務的前生今世與未來

Proxy 負責負載均衡和隔離故障

Proxy 是已讀服務的接入層組件,它用傳統的方式將緩衝按照用戶維度拆分成多個 slot 組織起來,每個 slot 負責數據集內的一個子集。Slot 內可以有多個副本來分擔同一批數據的讀取壓力,proxy 會在 slot 內對同一個會話綁定同一個副本來保證會話內的一致性,當副本發生故障時 proxy 優先選擇同一個 slot 內的其它副本來繼續承載請求,在極端情況發生 slot 內的所有副本同時失效時,proxy 還可以選擇其它 slot 的活躍節點來處理用戶的請求,這時我們付出了無法利用緩衝提高性能的代價來換取系統在極端場景下的可用性。

Cache

在由「用戶」和「內容類型」和「內容」所組成的空間中,由於「用戶」維度和「內容」維度的基數非常高,都在數億級別,即使記錄數在萬億這樣的數量級下,數據在整個三維空間內的分佈依然非常稀疏。單純依靠底層存儲系統的能力很難在尺寸巨大且極度稀疏的數據集上提供高吞吐的在線查詢,更難以滿足業務對低響應時間的要求。另外尺寸巨大且分佈稀疏的數據集對緩存系統的資源消耗和命中率的也提出了巨大的挑戰。

知乎已讀服務的前生今世與未來

已讀數據空間分佈極度稀疏

考慮到目前知乎站上沉澱的內容量級巨大,我們可以容忍false positive 但依舊為用戶召回到足夠多可能會感興趣的內容。基於這樣的業務特點我們可以將數據庫中存儲的原始數據轉化為 BloomFilter 緩衝起來,這極大的降低了內存的消耗在相同的資源狀況下可以緩衝更多的數據提高緩存的命中率。

知乎已讀服務的前生今世與未來

緩存Bloom Filter

提升緩存命中率的方式有很多種,除了前面提到的提升緩存數據密度增加可緩衝的數據量級之外,我們還可以通過避免不必要的緩存失效來進一步的提升緩存的效率。因此我們將緩存設計為write through cache 使用原地更新緩存的方式來避免 invalidate cache 操作。再配合數據變更訂閱我們可以在不失效緩衝的情況下確保同一份數據的多個緩衝副本能在很短的時間內達成最終一致。另一方面得益於 read through 的設計,我們可以將對同一份數據的多個併發查詢請求轉化成一次 cache miss 加多次緩衝讀取,進一步提升緩存的命中率降低穿透到底層數據庫系統的壓力。

知乎已讀服務的前生今世與未來

Cache Through

緩衝系統的核心工作是攔截住大量熱數據的訪問,因此維持緩衝數據的熱度是整個系統的穩定性的關鍵因素。但作為不斷迭代演進的業務系統,如何在系統滾動升級或者副本擴容的時候讓新啟動的緩衝節點也快速熱身進入狀態呢?雖然我們可以選擇逐步向新節點開放流量的方式避免冷緩衝的影響,但我們也會面臨故障後自動恢復的情景,這時我們沒有時間等待新的緩衝服務逐步進入狀態。考慮到這點我們在新節點啟動後會從當前slot 內挑選一個活躍的副本遷移全部或足夠多的狀態讓新節點快速進入工作狀態,避免因新節點緩衝不熱導致的響應時間抖動。

知乎已讀服務的前生今世與未來

緩衝狀態遷移

前面我們提到借鑑計算機系統中的內存分層設計,接下來我們從收益的角度來探討採用類似的分層緩衝設計在已讀服務上為系統帶來了什麼。現實中的緩衝系統不論如何提高命中率,我們始終都需要面對cache miss 的場景。多層緩衝的存在可以讓我們在不同層級的 Cache 上應用不同的配置策略,力圖在每一個新引入的 cache 層級上都進一步降低穿透到下一層的請求數量。在多層緩衝的幫助下我們可以讓不同的緩衝層級分別從空間維度和時間維度關注數據的熱度。我們甚至還可以在多數據中心部署的情況下,通過進一步增加緩衝層數利用多層緩衝的機制來極大的降低跨數據中心訪問數據的帶寬消耗和時延增加。

知乎已讀服務的前生今世與未來

跨數據中心部署

知乎作為一個內容社區,用戶的已讀數據是非常核心的行為數據。不但我們在首頁個性化推薦上有過濾需求,在個性化推送上也存在著類似的過濾需求。個性化推送是典型的離線任務,查詢吞吐更高但可以放鬆響應時間的要求,雖然他們所訪問的數據源相同但推送和首頁所訪問的數據在熱度分佈上存在著顯著的不同。為了讓業務之間不互相影響並且針對不同業務的數據訪問特徵選擇不同的緩衝策略,我們還進一步提供了cache 標籤隔離的機制來隔離離線寫入和多個不同的業務租戶的查詢。

知乎已讀服務的前生今世與未來

業務獨立的緩衝策略以及物理隔離

MySQL

在系統開發初期為了加快開發效率,我們在物理存儲層上選擇了在知乎內部應用最廣泛的MySQL。針對系統對高性能和高可用的要求,我們使用了分庫分表加MHA機制來提升系統的性能並保障系統的高可用。除此以外我們還根據已讀數據高寫入低刪除的特點選擇了更適合這個場景的 TokuDB 存儲引擎,得益於 TokuDB 引擎極高的壓縮比系統在一萬億記錄時單副本的總數據尺寸大約13T,平均一行記錄僅使用了 10 多字節的空間。在這個階段我們使用了 12 臺節點來承載這一萬億已讀數據,在這樣的一個集群規模下手工運維還是勉強可以接受的。

性能指標

到2019 年初當前一代的已讀服務已在線上穩定服務了首頁一年有餘,在各項業務指標上來看都是非常滿意的。目前已讀的流量已達每秒 4 萬行紀錄寫入, 3 萬獨立查詢 和 1200 萬個文檔判讀,在這樣的壓力下已讀服務響應時間的 P99 和 P999 仍然穩定的維持在 25ms 和 50ms。

知乎已讀服務的前生今世與未來

已讀服務核心業務指標

全面雲化,面向未來

從已讀的業務指標上看我們交出了一份還讓人滿意的答卷,但作為已讀服務的開發和運維人員我們深知目前的這套架構還存在著一些核心的痛點沒有解決好。首當其中的就是MySQL 的運維問題。我們不但需要考慮數據量繼續膨脹後需要再次分庫分表的擴展性問題,還需要考慮整個集群的高可用和節點發生物理故障後的恢復等一系列問題。在每月數據量近 1000 億持續膨脹帶來的壓力下我們的不安感與日俱增,迫切的需要一個完善的方案來解決 MySQL 集群的運維問題。其次已讀系統的整體架構都是面向在線業務設計的,這導致數據分析的工作很難被直接的應用在這樣的架構之上。帶著已讀服務的這些問題我們於近期開始了新一輪的迭代,本輪迭代的核心目標是將已讀服務全面雲化,達到全系統高可用規模隨需擴展的目標。

已讀服務最痛的MySQL 運維問題實質是單機數據庫在擴展性和可用性上不足所帶來的問題,那麼這個問題最直接了當的解決方式就是致力於解決這些問題的原生分佈式的數據庫。幸運的是近些年工業界在原生分佈式數據庫領域有了非常多的進展,而 CockroachDB 和 TiDB 則是在這個領域非常優秀的兩個開源項目。雖然他們的具體實現細節和技術路徑有所不同,但在大方向上看他們有著不少的相似點。

知乎已讀服務的前生今世與未來

計算存儲分層的分佈式數據庫架構

考慮到已讀服務過去構建於MySQL 技術上,相比之下兼容 MySQL 的 TiDB 比 CockroachDB 對已讀服務有著更低的遷移門檻。除此之外 TiDB 背後的 PingCAP 作為一家中國境內的公司,在我們遇到困難的時候可以更加容易的尋求到幫助。基於這些考慮我們最終選擇將 TiDB 作為已讀服務 MySQL 集群遷移的目標。得益於 TiDB 對 MySQL 的良好兼容和生態工具的完善整個遷移工作並不複雜,除了工作量最大的數據遷移工作之外,開發上還需要調整 CDC 組件與 TiDB Binlog 相適配。

數據遷移

目前TiDB 官方推薦使用一站式的數據遷移工具 DM 來完成從 MySQL 到 TiDB 的全量數據遷移和增量數據同步。但考慮到在準備遷移時已讀服務數據已經達到一萬一千億行的規模,直接使用 DM 做邏輯式的初始全量數據遷移耗時可能會無法接受。在嘗試使用 DM 進行導入測試的結果也從數據上驗證了即便不考慮後期數據量變大後可能的速度下降,以邏輯的方式導入初始全量數據也需要耗時至少一個月。基於使用 DM 導入全量數據耗時預估我們作出了獨立使用 TiDB Lightning 遷移全量數據的決定。

"

導讀:對於很多大型網站來說,一些不起眼的小功能反而是實現的難點。對於知乎來說,已讀服務會隨著用戶量和內容數量的增長而平方級增長,而且響應時間要求很短,因此是一個有實現難度的系統。本文作者介紹了知乎已讀服務的架構設計和演進過程,並對很多技術取捨做了深入剖析,十分值得閱讀。

知乎已讀服務的前生今世與未來

作者簡介:孫曉光,知乎搜索後端負責人,目前承擔知乎搜索後端架構設計以及工程團隊的管理工作。曾多年從事私有云相關產品開發工作關注雲原生技術,TiKV 項目 Committer。

知乎從問答起步,在過去的8 年中逐步成長為一個大規模的綜合性知識內容平臺,目前,知乎上有多達 3000 萬個問題,共收穫了超過 1.3 億個回答,同時知乎還沉澱了數量眾多的文章、電子書以及其他付費內容。知乎通過個性化首頁推薦的方式在海量的信息中高效分發用戶感興趣的優質內容。為了避免給用戶推薦重複的內容,已讀服務會將所有知乎站上用戶深入閱讀或快速掠過的內容長期保存,並將這些數據應用於首頁推薦信息流和個性化推送的已讀過濾。

業務場景,技術挑戰

知乎已讀服務的前生今世與未來

首頁已讀過濾流程示意圖

從首頁使用已讀服務的流程我們可以看出這個服務的業務模式較為簡單,我們只需要簡單的以用戶為第一維度內容為第二緯度來查詢指定用戶是否已經閱讀過某個內容。但我們並沒因為業務簡單就在設計上放棄了靈活性和普適性。為此我們設計開發了一套支持BigTable 數據模型的 Cache Through 緩衝系統 RBase 來實現已讀服務,一方面充分利用 Cache 的高吞吐低時延能力,另一方面還可以利用靈活的 BigTable 數據模型來輔助業務快速演進。

知乎已讀服務的前生今世與未來

BigTable 數據模型

已讀服務雖然從業務模式看非常簡單,但它在技術上的挑戰確並不低。目前知乎已讀的數據規模已超萬億並以每天接近30 億的速度持續高速增長。與常見的“讀多寫少”的業務不同,已讀服務不僅需要在這樣的存量數據規模下提供在線查詢服務,還同時承載著每秒 4 萬條新紀錄寫入的衝擊。已讀內容過濾作為首頁信息流推薦中對響應時間影響較大的關鍵任務點,它的可用性和響應時間都需要滿足非常高的要求。

綜合業務需求和線上數據來看,已讀服務的要求和挑戰主要有以下幾點:

● 可用性要求「高」:服務於個性化首頁和個性化推送,最重要的流量分發渠道

● 寫入量「大」:峰值每秒寫入40K+ 行記錄,日新增記錄近 30 億條

● 歷史數據「長期」保存:目前已達一萬二千億條記錄

● 查詢吞吐「高」:在線查詢峰值30K QPS / 12M+ 條已讀檢查

● 響應時間「敏感」:90ms 超時

早期方案,架構演進

BloomFilter on Redis Cluster

知乎已讀服務的前生今世與未來

最初我們在Redis 集群上使用 BITSET 結構直接存儲已讀數據的 BloomFilter。首先由於缺乏多個位的批量操作,操作放大非常嚴重會消耗非常多的計算資源。其次使用全內存的方式存儲全量數據也拉高了整體成本。最後由於難以預估用戶的閱讀量增速無法以用戶為粒度合理控制 BloomFilter 的尺寸和 False Positive Rate。

HBase

考慮到BloomFilter on Redis 方案存在的問題,我們開始嘗試使用 HBase 來存儲用戶的閱讀歷史並提供在線查詢服務。已讀的業務需求可以非常直觀的映射到 BigTable 的數據模型上。我們將用戶 id 作為 row key,訪問的文檔 id 作為 qualifier 保存下來, 而 timestamp 則恰好可以用來記錄文檔的讀取時間。整個系統的可擴展性和成本都要顯著優於直接使用 Redis Cluster 存儲 BloomFilter 的方案。

隨著已讀數據量級和業務查詢量的迅速增長,已讀數據訪問極度稀疏的特點開始影響到了HBase 的 cache 命中率。HBase 的存儲模型在發生 cache miss 需要訪問存儲的情況下 IO 路徑很長。根據緩衝穿透的層次不同整個請求路徑上可能會經過數個 Java 進程,任何一個進程的 GC 和 IO 都會對這次訪問的 latency 產生顯著的影響,導致響應時間產生較大的波動,而大的響應時間波動是首頁難以接受的。

知乎已讀服務的前生今世與未來

HBase 緩衝穿透的 IO 路徑

在吸取了最初兩代已讀架構方案的經驗教訓後,我們開始設計實現新一代的已讀服務,在這次的設計中我們在可用性、性能以及擴展性上都設定了更高的目標尤其是以往系統表現不好的性能和擴展性方面我們希望看到更加長足的進步。

● 高可用

● ✓ HBase

● ✓BloomFilter on Redis Cluster

● 高性能

● ✘HBase

● ✓BloomFilter on Redis Cluster

● 易擴展

● ✓HBase

● ✘BloomFilter on Redis Cluster

下面就讓我們一起從高可用、高性能和易擴展這三個角度來思考如何構建一個更好的已讀服務滿足好業務的需求和挑戰。

高可用

當我們討論高可用的時候,也意味著我們已經意識到故障是無時無刻都在發生的,依賴傳統人工運維的方式來保證複雜系統的高可用是不現實的。我們需要以系統化的方式對各個組件的狀態進行探測感知他們發生的故障。並且我們需要為系統中的組件設計自愈機制,當故障發生時可以不經人工干預而自動的恢復。最後我們還需要隔離各種故障所產生的變化,讓業務側儘可能對故障的發生和恢復無感知。

知乎已讀服務的前生今世與未來

故障監測、自動恢復並隔離變化

高性能

對常見的系統來說,越核心的組件往往狀態越重擴展的代價也越大,層層攔截快速降低需要深入到核心組件的請求量對提高性能是非常有效的手段。首先我們通過緩衝分Slot 的方式來擴展集群所能緩衝的數據規模。接著進一步在 Slot 內通過多副本的方式提升單個 Slot 緩衝數據集的讀取吞吐,將大量的請求攔截在系統的緩衝層進行消化。如果請求不可避免的走到了最終的數據庫組件上,我們還可以利用效率較高的壓縮來繼續降低落到物理設備上的IO壓力。

知乎已讀服務的前生今世與未來

分層去併發

易擴展

提升系統擴展性的關鍵在於減少有狀態組件的範圍,在路由和服務發現組件的幫助下,系統中的無狀態組件可以非常輕鬆的擴展擴容。所以通過擴大無狀態服務的範圍,收縮重狀態服務的比例可以顯著的幫助我們提升整個系統的可擴展性。除此之外如果我們能夠設計一些可以從外部系統恢復狀態的弱狀態服務,那麼我們往往可以利用弱狀態的組件來部分替代重狀態組件。隨著弱狀態組件的擴大和重狀態組件的收縮,整個系統的可擴展性可以得到進一步的提升。

知乎已讀服務的前生今世與未來

弱狀態部分替代重狀態

RBase

在高可用、高性能和易擴展的設計理念下,我們設計實現了RBase 做為已讀服務的根基。現在讓我們來從 RBase 全局設計入手來了解高可用、高性能和易擴展的設計理念是如何落地的。

知乎已讀服務的前生今世與未來

RBase 架構

客戶端API 和 Proxy 是完全無狀態可隨時擴展的組件,最底層是由 MHA 管理的 MySQL 集群,中間存在大量可從數據庫或者副本中恢復的弱狀態組件。這些弱狀態組件中最核心的部分是分層的緩衝模塊,這些緩衝模塊的狀態可以從副本或數據庫中恢復重建因此他們的可擴展性仍然是非常優秀的。緩衝以外的組件則負責管理緩衝的一致性,在它們的協助下緩衝模塊可以完全避免無意義的 Cache Invalidate 提升緩存的命中率,從而極大緩解了傳導到底層數據庫系統上的壓力。整個架構中除了唯一的重狀態組件 MySQL 集群之外所有的組件都擁有自我恢復的能力。在擁有自我恢復能力和全局故障監測的前提下,我們使用 Kubernetes 來管理所有 RBase 的服務組件在機制上確保整個服務的高可用。

RBase 的設計中緩存是一個非常關鍵的組件,然而它們的組織管理方式又同常見的緩存系統不盡相同,這些設計上的不同體現著我們在高性能方面的思考。現在讓我們以一個典型的計算機系統的內存分層設計來引出我們在緩存系統設計上的思路。

知乎已讀服務的前生今世與未來

計算機系統內存分層示意圖

在過去的幾十年中工業界為了提升計算機系統的性能,在計算機系統中添加了更多的CPU 乃至更多的核心。隨著 CPU 內部處理能力的不斷增強我們還為 CPU 加上多級的緩存來彌補主存在帶寬和時延上同 CPU 的巨大差距。除此之外我們還進一步把主存連同 CPU 分成多組讓他們之間有更快的本地連接,只有當需要交叉訪問遠程內存的時候再通過互聯總線進行交流。在這樣一個系統裡大部分的讀寫操作都發生在離核心最近的 L1 或者 L2 cache 裡,而在修改主存中數據的時候則需要更加複雜的機制和更長的時間來達成緩衝的最終一致。為了能將計算機系統的性能發揮到極致工業界不斷的改進體系結構,在這個架構中處處都體現著設計的取捨和智慧。

我們利用在內存分層設計上獲得的靈感,在RBase 的緩存一致性上採用了類似的設計。類似計算機系統內存的數據庫層,相似的分層緩衝設計,類似的 cache through 以及 cache coherence 組件設計。大道至簡殊途同歸,通過學習借鑑體系結構經典成熟的思想將它應用到軟件系統設計中同樣可以得到非常好的效果。

核心組件,關鍵設計

接下來讓我們從細節來深入瞭解已讀系統中一些核心組件的關鍵設計。

Proxy

知乎已讀服務的前生今世與未來

Proxy 負責負載均衡和隔離故障

Proxy 是已讀服務的接入層組件,它用傳統的方式將緩衝按照用戶維度拆分成多個 slot 組織起來,每個 slot 負責數據集內的一個子集。Slot 內可以有多個副本來分擔同一批數據的讀取壓力,proxy 會在 slot 內對同一個會話綁定同一個副本來保證會話內的一致性,當副本發生故障時 proxy 優先選擇同一個 slot 內的其它副本來繼續承載請求,在極端情況發生 slot 內的所有副本同時失效時,proxy 還可以選擇其它 slot 的活躍節點來處理用戶的請求,這時我們付出了無法利用緩衝提高性能的代價來換取系統在極端場景下的可用性。

Cache

在由「用戶」和「內容類型」和「內容」所組成的空間中,由於「用戶」維度和「內容」維度的基數非常高,都在數億級別,即使記錄數在萬億這樣的數量級下,數據在整個三維空間內的分佈依然非常稀疏。單純依靠底層存儲系統的能力很難在尺寸巨大且極度稀疏的數據集上提供高吞吐的在線查詢,更難以滿足業務對低響應時間的要求。另外尺寸巨大且分佈稀疏的數據集對緩存系統的資源消耗和命中率的也提出了巨大的挑戰。

知乎已讀服務的前生今世與未來

已讀數據空間分佈極度稀疏

考慮到目前知乎站上沉澱的內容量級巨大,我們可以容忍false positive 但依舊為用戶召回到足夠多可能會感興趣的內容。基於這樣的業務特點我們可以將數據庫中存儲的原始數據轉化為 BloomFilter 緩衝起來,這極大的降低了內存的消耗在相同的資源狀況下可以緩衝更多的數據提高緩存的命中率。

知乎已讀服務的前生今世與未來

緩存Bloom Filter

提升緩存命中率的方式有很多種,除了前面提到的提升緩存數據密度增加可緩衝的數據量級之外,我們還可以通過避免不必要的緩存失效來進一步的提升緩存的效率。因此我們將緩存設計為write through cache 使用原地更新緩存的方式來避免 invalidate cache 操作。再配合數據變更訂閱我們可以在不失效緩衝的情況下確保同一份數據的多個緩衝副本能在很短的時間內達成最終一致。另一方面得益於 read through 的設計,我們可以將對同一份數據的多個併發查詢請求轉化成一次 cache miss 加多次緩衝讀取,進一步提升緩存的命中率降低穿透到底層數據庫系統的壓力。

知乎已讀服務的前生今世與未來

Cache Through

緩衝系統的核心工作是攔截住大量熱數據的訪問,因此維持緩衝數據的熱度是整個系統的穩定性的關鍵因素。但作為不斷迭代演進的業務系統,如何在系統滾動升級或者副本擴容的時候讓新啟動的緩衝節點也快速熱身進入狀態呢?雖然我們可以選擇逐步向新節點開放流量的方式避免冷緩衝的影響,但我們也會面臨故障後自動恢復的情景,這時我們沒有時間等待新的緩衝服務逐步進入狀態。考慮到這點我們在新節點啟動後會從當前slot 內挑選一個活躍的副本遷移全部或足夠多的狀態讓新節點快速進入工作狀態,避免因新節點緩衝不熱導致的響應時間抖動。

知乎已讀服務的前生今世與未來

緩衝狀態遷移

前面我們提到借鑑計算機系統中的內存分層設計,接下來我們從收益的角度來探討採用類似的分層緩衝設計在已讀服務上為系統帶來了什麼。現實中的緩衝系統不論如何提高命中率,我們始終都需要面對cache miss 的場景。多層緩衝的存在可以讓我們在不同層級的 Cache 上應用不同的配置策略,力圖在每一個新引入的 cache 層級上都進一步降低穿透到下一層的請求數量。在多層緩衝的幫助下我們可以讓不同的緩衝層級分別從空間維度和時間維度關注數據的熱度。我們甚至還可以在多數據中心部署的情況下,通過進一步增加緩衝層數利用多層緩衝的機制來極大的降低跨數據中心訪問數據的帶寬消耗和時延增加。

知乎已讀服務的前生今世與未來

跨數據中心部署

知乎作為一個內容社區,用戶的已讀數據是非常核心的行為數據。不但我們在首頁個性化推薦上有過濾需求,在個性化推送上也存在著類似的過濾需求。個性化推送是典型的離線任務,查詢吞吐更高但可以放鬆響應時間的要求,雖然他們所訪問的數據源相同但推送和首頁所訪問的數據在熱度分佈上存在著顯著的不同。為了讓業務之間不互相影響並且針對不同業務的數據訪問特徵選擇不同的緩衝策略,我們還進一步提供了cache 標籤隔離的機制來隔離離線寫入和多個不同的業務租戶的查詢。

知乎已讀服務的前生今世與未來

業務獨立的緩衝策略以及物理隔離

MySQL

在系統開發初期為了加快開發效率,我們在物理存儲層上選擇了在知乎內部應用最廣泛的MySQL。針對系統對高性能和高可用的要求,我們使用了分庫分表加MHA機制來提升系統的性能並保障系統的高可用。除此以外我們還根據已讀數據高寫入低刪除的特點選擇了更適合這個場景的 TokuDB 存儲引擎,得益於 TokuDB 引擎極高的壓縮比系統在一萬億記錄時單副本的總數據尺寸大約13T,平均一行記錄僅使用了 10 多字節的空間。在這個階段我們使用了 12 臺節點來承載這一萬億已讀數據,在這樣的一個集群規模下手工運維還是勉強可以接受的。

性能指標

到2019 年初當前一代的已讀服務已在線上穩定服務了首頁一年有餘,在各項業務指標上來看都是非常滿意的。目前已讀的流量已達每秒 4 萬行紀錄寫入, 3 萬獨立查詢 和 1200 萬個文檔判讀,在這樣的壓力下已讀服務響應時間的 P99 和 P999 仍然穩定的維持在 25ms 和 50ms。

知乎已讀服務的前生今世與未來

已讀服務核心業務指標

全面雲化,面向未來

從已讀的業務指標上看我們交出了一份還讓人滿意的答卷,但作為已讀服務的開發和運維人員我們深知目前的這套架構還存在著一些核心的痛點沒有解決好。首當其中的就是MySQL 的運維問題。我們不但需要考慮數據量繼續膨脹後需要再次分庫分表的擴展性問題,還需要考慮整個集群的高可用和節點發生物理故障後的恢復等一系列問題。在每月數據量近 1000 億持續膨脹帶來的壓力下我們的不安感與日俱增,迫切的需要一個完善的方案來解決 MySQL 集群的運維問題。其次已讀系統的整體架構都是面向在線業務設計的,這導致數據分析的工作很難被直接的應用在這樣的架構之上。帶著已讀服務的這些問題我們於近期開始了新一輪的迭代,本輪迭代的核心目標是將已讀服務全面雲化,達到全系統高可用規模隨需擴展的目標。

已讀服務最痛的MySQL 運維問題實質是單機數據庫在擴展性和可用性上不足所帶來的問題,那麼這個問題最直接了當的解決方式就是致力於解決這些問題的原生分佈式的數據庫。幸運的是近些年工業界在原生分佈式數據庫領域有了非常多的進展,而 CockroachDB 和 TiDB 則是在這個領域非常優秀的兩個開源項目。雖然他們的具體實現細節和技術路徑有所不同,但在大方向上看他們有著不少的相似點。

知乎已讀服務的前生今世與未來

計算存儲分層的分佈式數據庫架構

考慮到已讀服務過去構建於MySQL 技術上,相比之下兼容 MySQL 的 TiDB 比 CockroachDB 對已讀服務有著更低的遷移門檻。除此之外 TiDB 背後的 PingCAP 作為一家中國境內的公司,在我們遇到困難的時候可以更加容易的尋求到幫助。基於這些考慮我們最終選擇將 TiDB 作為已讀服務 MySQL 集群遷移的目標。得益於 TiDB 對 MySQL 的良好兼容和生態工具的完善整個遷移工作並不複雜,除了工作量最大的數據遷移工作之外,開發上還需要調整 CDC 組件與 TiDB Binlog 相適配。

數據遷移

目前TiDB 官方推薦使用一站式的數據遷移工具 DM 來完成從 MySQL 到 TiDB 的全量數據遷移和增量數據同步。但考慮到在準備遷移時已讀服務數據已經達到一萬一千億行的規模,直接使用 DM 做邏輯式的初始全量數據遷移耗時可能會無法接受。在嘗試使用 DM 進行導入測試的結果也從數據上驗證了即便不考慮後期數據量變大後可能的速度下降,以邏輯的方式導入初始全量數據也需要耗時至少一個月。基於使用 DM 導入全量數據耗時預估我們作出了獨立使用 TiDB Lightning 遷移全量數據的決定。

知乎已讀服務的前生今世與未來

MySQL 到 TiDB 數據遷移

目前TiDB Lightning 還尚未同 DM 進行整合,因此整個遷移的過程存在不少的人工操作流程。遷移過程需要首先啟動 DM 開始收集 MySQL 上的增量 Binlog,然後使用 TiDB Lightning 快速導入歷史全量數據到 TiDB。當全量數據導入完成後由 DM 負責將全量數據遷移過程中 MySQL 側產生的增量數據同步到 TiDB 中並維持兩邊數據的一致性。整個遷移過程看起來很簡單但在實際操作中我們也遇到了一些考慮不周的地方導致了多次導入失敗。

TiDB Lightning 導入數據的工作實際需要兩個獨立的程序 tidb-lightning 和 tikv-importer 配合完成,而這兩個程序都屬於資源消耗密集的應用。在倒入初期我們可以用來做數據遷移的服務器並不多,再加上過於輕視 TiDB Lightning 龐大的資源需求,我們嘗試了多種部署模式來試圖使用盡量少的服務器來完成數據導入工作。但這些嘗試無一例外的都失敗了,下面是我們嘗試並最終失敗了的部署方式希望能給大家一些幫助避免遇到同樣的問題。

最初我們試圖在同一臺機器上跑多套tidb-lightning 和 tikv-importer 進程進程同時倒入多個 MySQL 上的數據,這種方式最終以機器上的內存消耗殆盡進程被 OOM Killer 殺掉失敗而告終。接下來我們嘗試在多個 tidb-lightning 進程間複用同一個 tikv-importer,並利用多個 tidb-lightning 進程 encode 數據時不需要 tikv-importer 的特點打時間差提高 tikv-importer 的利用率。通過錯開 tidb-lightning 進程的啟動時間我們在數據導入的初期階段順利的提高了 tikv-importer 服務器的利用率。由於不同 tidb-lightning 任務的實際執行時間不盡相同,在導入進行了一段時間後很難避免多個 tidb-lightning 任務同時向同一個 tikv-importer 節點提交倒入請求導致目標服務資源不足引起失敗或被 OOM Killer 殺掉的現象。在經歷了這些失敗後我們認識到 TiDB Lightning 驚人的導入速度背後也對應著相當高的資源需求,想要提升導入數據的速度必須要給予足夠多的硬件資源。認識到這一點之後我們調整了一下策略,嘗試將部分原本計劃部署 TiKV 的節點改為 TiDB Lightning 數據導入節點。而節點減少導致存儲空間不足的問題我們通過暫時調整集群的副本數為 1 來減少 TiKV 在存儲空間上的需求並預期在導入完成後再恢復副本數到 3。這次嘗試我們成功的導入了全量的數據,並開始使用 dm 增量同步 MySQL 中新發生的寫入。為了避免對實時的增量同步產生過大的衝擊導致無法跟上 MySQL 的數據生產速度,我們對副本恢復設定了速度限制。在這樣情況下將 45T 數據從 1 副本增加到 3 副本的過程需要消耗的時間過長,甚至遠超我們最初在數據導入上獲得的收益。除此以外在副本完全恢復完成前的時間,如果任何一臺節點發生硬件故障都有可能會導致某些 region 數據丟失最終使得整個遷移過程功虧於潰。

在吸取了多次導入失敗的教訓後我們最終按照實際的硬件資源情況選擇了更為保守的導入方案。獨佔使用8 臺硬件配置滿足要求的物理機部署 TiDB Lightning,同時導入 4 個 MySQL 實例的數據,總共 16 個 MySQL 實例上的一萬一千億行紀錄遷移最終耗時 4 天完成。在 dm 的幫助下,導入全量數據的 4 天中所產生的全部增量數據在不到一天的時間全部導入完成,16 個 MySQL 實例上新寫入的數據也會由 dm 近實時的同步到 TiDB 集群上來。

業務遷移

MySQL 同 TiDB 的同步穩定運轉起來後我們開始了業務流量遷移的工作。同遷移過程類似流量切換的過程也並不是一帆風順的。當我們信心滿滿地把線上 100% 流量一次性全部切換到 TiDB 上後發現查詢的響應時間產生了非常顯著的上漲,業務方的調用超時也迅速增加並觸發了業務報警,於是我們立刻會滾了流量到 MySQL 集群並開始排查響應時間陡增的問題。

已讀服務只有在cache miss 時才需要穿透到存儲層查詢數據重建緩存,而重建緩存中則需要一次性的拿出制定用戶過往所有已經讀過內容的全量數據。在實際的業務場景中許多知乎的重度老用戶可能在過去的數年中累計看到過幾萬、十幾萬甚至幾十萬條不同的內容,讀取這樣的全量數據很難在業務要求的幾十毫秒內完成。我們通過將 cache miss 請求的處理分為兩個獨立的步驟,第一個步驟是阻塞式的直接查詢當前請求所需要的數據,與此同時我們還會異步發起一個用於緩衝重建的全量數據查詢請求用來填充緩衝,滿足當前條件的阻塞式查詢完成後會立刻響應用戶的查詢請求。已讀服務的這種工作模式決定了我們的核心優化目標是阻塞式的那個小查詢的響應時間。因此我們根據業務對不同查詢在響應時間上的要求區分為 Low/Normal/High 三種優先級不同的 SQL 語句,使用 hint 的方式告知 TiDB 和 TiKV 對不同的 query 使用不同的任務隊列在資源層面區分隔離避免每個級別任務不受其它級別任務的影響。除此之外我們還進一步調整業務端邏輯通過使用低精度 TSO 和複用 PreparedStatement 的方式來進一步的減少網絡上的 roundtrip 將同步查詢的 latency 壓縮到極致。

除了前面的在線查詢側邏輯調整之外我們還需要將MySQL Binlog 適配到 TiDB Binlog 確保變更事件可以正確的推送給訂閱方。TiDB Binlog 的開發適配非常簡單,只需消費 kafka 上的消息並根據 protobuf 的定義轉換成我們內部的變更消息格式整體適配工作就完成了。在這部分適配工作的驗證過程中我們發現 TiDB Binlog 為了維持事務全局有序的約束只會使用 Kafka 的第 0 號分區,在我們的業務寫入吞吐下只使用一個分區很容易超出 kafka 對應 broker 的處理能力導致 binlog 事件寫入和消費的延遲。為此我們對 TiDB Binlog 的 drainer 組件做了一些臨時的調整根據配置選擇按照 database 或者 table 進行選擇目標分區達到均衡負載的目的。驗證過程中 PingCAP 的同學也及時調整根據我們的反饋做了諸多的改善,在 TiDB Binlog 最終上線時 TiDB Binlog 的事件時延和吞吐都有了非常顯著的加強。

遷移效果

"

導讀:對於很多大型網站來說,一些不起眼的小功能反而是實現的難點。對於知乎來說,已讀服務會隨著用戶量和內容數量的增長而平方級增長,而且響應時間要求很短,因此是一個有實現難度的系統。本文作者介紹了知乎已讀服務的架構設計和演進過程,並對很多技術取捨做了深入剖析,十分值得閱讀。

知乎已讀服務的前生今世與未來

作者簡介:孫曉光,知乎搜索後端負責人,目前承擔知乎搜索後端架構設計以及工程團隊的管理工作。曾多年從事私有云相關產品開發工作關注雲原生技術,TiKV 項目 Committer。

知乎從問答起步,在過去的8 年中逐步成長為一個大規模的綜合性知識內容平臺,目前,知乎上有多達 3000 萬個問題,共收穫了超過 1.3 億個回答,同時知乎還沉澱了數量眾多的文章、電子書以及其他付費內容。知乎通過個性化首頁推薦的方式在海量的信息中高效分發用戶感興趣的優質內容。為了避免給用戶推薦重複的內容,已讀服務會將所有知乎站上用戶深入閱讀或快速掠過的內容長期保存,並將這些數據應用於首頁推薦信息流和個性化推送的已讀過濾。

業務場景,技術挑戰

知乎已讀服務的前生今世與未來

首頁已讀過濾流程示意圖

從首頁使用已讀服務的流程我們可以看出這個服務的業務模式較為簡單,我們只需要簡單的以用戶為第一維度內容為第二緯度來查詢指定用戶是否已經閱讀過某個內容。但我們並沒因為業務簡單就在設計上放棄了靈活性和普適性。為此我們設計開發了一套支持BigTable 數據模型的 Cache Through 緩衝系統 RBase 來實現已讀服務,一方面充分利用 Cache 的高吞吐低時延能力,另一方面還可以利用靈活的 BigTable 數據模型來輔助業務快速演進。

知乎已讀服務的前生今世與未來

BigTable 數據模型

已讀服務雖然從業務模式看非常簡單,但它在技術上的挑戰確並不低。目前知乎已讀的數據規模已超萬億並以每天接近30 億的速度持續高速增長。與常見的“讀多寫少”的業務不同,已讀服務不僅需要在這樣的存量數據規模下提供在線查詢服務,還同時承載著每秒 4 萬條新紀錄寫入的衝擊。已讀內容過濾作為首頁信息流推薦中對響應時間影響較大的關鍵任務點,它的可用性和響應時間都需要滿足非常高的要求。

綜合業務需求和線上數據來看,已讀服務的要求和挑戰主要有以下幾點:

● 可用性要求「高」:服務於個性化首頁和個性化推送,最重要的流量分發渠道

● 寫入量「大」:峰值每秒寫入40K+ 行記錄,日新增記錄近 30 億條

● 歷史數據「長期」保存:目前已達一萬二千億條記錄

● 查詢吞吐「高」:在線查詢峰值30K QPS / 12M+ 條已讀檢查

● 響應時間「敏感」:90ms 超時

早期方案,架構演進

BloomFilter on Redis Cluster

知乎已讀服務的前生今世與未來

最初我們在Redis 集群上使用 BITSET 結構直接存儲已讀數據的 BloomFilter。首先由於缺乏多個位的批量操作,操作放大非常嚴重會消耗非常多的計算資源。其次使用全內存的方式存儲全量數據也拉高了整體成本。最後由於難以預估用戶的閱讀量增速無法以用戶為粒度合理控制 BloomFilter 的尺寸和 False Positive Rate。

HBase

考慮到BloomFilter on Redis 方案存在的問題,我們開始嘗試使用 HBase 來存儲用戶的閱讀歷史並提供在線查詢服務。已讀的業務需求可以非常直觀的映射到 BigTable 的數據模型上。我們將用戶 id 作為 row key,訪問的文檔 id 作為 qualifier 保存下來, 而 timestamp 則恰好可以用來記錄文檔的讀取時間。整個系統的可擴展性和成本都要顯著優於直接使用 Redis Cluster 存儲 BloomFilter 的方案。

隨著已讀數據量級和業務查詢量的迅速增長,已讀數據訪問極度稀疏的特點開始影響到了HBase 的 cache 命中率。HBase 的存儲模型在發生 cache miss 需要訪問存儲的情況下 IO 路徑很長。根據緩衝穿透的層次不同整個請求路徑上可能會經過數個 Java 進程,任何一個進程的 GC 和 IO 都會對這次訪問的 latency 產生顯著的影響,導致響應時間產生較大的波動,而大的響應時間波動是首頁難以接受的。

知乎已讀服務的前生今世與未來

HBase 緩衝穿透的 IO 路徑

在吸取了最初兩代已讀架構方案的經驗教訓後,我們開始設計實現新一代的已讀服務,在這次的設計中我們在可用性、性能以及擴展性上都設定了更高的目標尤其是以往系統表現不好的性能和擴展性方面我們希望看到更加長足的進步。

● 高可用

● ✓ HBase

● ✓BloomFilter on Redis Cluster

● 高性能

● ✘HBase

● ✓BloomFilter on Redis Cluster

● 易擴展

● ✓HBase

● ✘BloomFilter on Redis Cluster

下面就讓我們一起從高可用、高性能和易擴展這三個角度來思考如何構建一個更好的已讀服務滿足好業務的需求和挑戰。

高可用

當我們討論高可用的時候,也意味著我們已經意識到故障是無時無刻都在發生的,依賴傳統人工運維的方式來保證複雜系統的高可用是不現實的。我們需要以系統化的方式對各個組件的狀態進行探測感知他們發生的故障。並且我們需要為系統中的組件設計自愈機制,當故障發生時可以不經人工干預而自動的恢復。最後我們還需要隔離各種故障所產生的變化,讓業務側儘可能對故障的發生和恢復無感知。

知乎已讀服務的前生今世與未來

故障監測、自動恢復並隔離變化

高性能

對常見的系統來說,越核心的組件往往狀態越重擴展的代價也越大,層層攔截快速降低需要深入到核心組件的請求量對提高性能是非常有效的手段。首先我們通過緩衝分Slot 的方式來擴展集群所能緩衝的數據規模。接著進一步在 Slot 內通過多副本的方式提升單個 Slot 緩衝數據集的讀取吞吐,將大量的請求攔截在系統的緩衝層進行消化。如果請求不可避免的走到了最終的數據庫組件上,我們還可以利用效率較高的壓縮來繼續降低落到物理設備上的IO壓力。

知乎已讀服務的前生今世與未來

分層去併發

易擴展

提升系統擴展性的關鍵在於減少有狀態組件的範圍,在路由和服務發現組件的幫助下,系統中的無狀態組件可以非常輕鬆的擴展擴容。所以通過擴大無狀態服務的範圍,收縮重狀態服務的比例可以顯著的幫助我們提升整個系統的可擴展性。除此之外如果我們能夠設計一些可以從外部系統恢復狀態的弱狀態服務,那麼我們往往可以利用弱狀態的組件來部分替代重狀態組件。隨著弱狀態組件的擴大和重狀態組件的收縮,整個系統的可擴展性可以得到進一步的提升。

知乎已讀服務的前生今世與未來

弱狀態部分替代重狀態

RBase

在高可用、高性能和易擴展的設計理念下,我們設計實現了RBase 做為已讀服務的根基。現在讓我們來從 RBase 全局設計入手來了解高可用、高性能和易擴展的設計理念是如何落地的。

知乎已讀服務的前生今世與未來

RBase 架構

客戶端API 和 Proxy 是完全無狀態可隨時擴展的組件,最底層是由 MHA 管理的 MySQL 集群,中間存在大量可從數據庫或者副本中恢復的弱狀態組件。這些弱狀態組件中最核心的部分是分層的緩衝模塊,這些緩衝模塊的狀態可以從副本或數據庫中恢復重建因此他們的可擴展性仍然是非常優秀的。緩衝以外的組件則負責管理緩衝的一致性,在它們的協助下緩衝模塊可以完全避免無意義的 Cache Invalidate 提升緩存的命中率,從而極大緩解了傳導到底層數據庫系統上的壓力。整個架構中除了唯一的重狀態組件 MySQL 集群之外所有的組件都擁有自我恢復的能力。在擁有自我恢復能力和全局故障監測的前提下,我們使用 Kubernetes 來管理所有 RBase 的服務組件在機制上確保整個服務的高可用。

RBase 的設計中緩存是一個非常關鍵的組件,然而它們的組織管理方式又同常見的緩存系統不盡相同,這些設計上的不同體現著我們在高性能方面的思考。現在讓我們以一個典型的計算機系統的內存分層設計來引出我們在緩存系統設計上的思路。

知乎已讀服務的前生今世與未來

計算機系統內存分層示意圖

在過去的幾十年中工業界為了提升計算機系統的性能,在計算機系統中添加了更多的CPU 乃至更多的核心。隨著 CPU 內部處理能力的不斷增強我們還為 CPU 加上多級的緩存來彌補主存在帶寬和時延上同 CPU 的巨大差距。除此之外我們還進一步把主存連同 CPU 分成多組讓他們之間有更快的本地連接,只有當需要交叉訪問遠程內存的時候再通過互聯總線進行交流。在這樣一個系統裡大部分的讀寫操作都發生在離核心最近的 L1 或者 L2 cache 裡,而在修改主存中數據的時候則需要更加複雜的機制和更長的時間來達成緩衝的最終一致。為了能將計算機系統的性能發揮到極致工業界不斷的改進體系結構,在這個架構中處處都體現著設計的取捨和智慧。

我們利用在內存分層設計上獲得的靈感,在RBase 的緩存一致性上採用了類似的設計。類似計算機系統內存的數據庫層,相似的分層緩衝設計,類似的 cache through 以及 cache coherence 組件設計。大道至簡殊途同歸,通過學習借鑑體系結構經典成熟的思想將它應用到軟件系統設計中同樣可以得到非常好的效果。

核心組件,關鍵設計

接下來讓我們從細節來深入瞭解已讀系統中一些核心組件的關鍵設計。

Proxy

知乎已讀服務的前生今世與未來

Proxy 負責負載均衡和隔離故障

Proxy 是已讀服務的接入層組件,它用傳統的方式將緩衝按照用戶維度拆分成多個 slot 組織起來,每個 slot 負責數據集內的一個子集。Slot 內可以有多個副本來分擔同一批數據的讀取壓力,proxy 會在 slot 內對同一個會話綁定同一個副本來保證會話內的一致性,當副本發生故障時 proxy 優先選擇同一個 slot 內的其它副本來繼續承載請求,在極端情況發生 slot 內的所有副本同時失效時,proxy 還可以選擇其它 slot 的活躍節點來處理用戶的請求,這時我們付出了無法利用緩衝提高性能的代價來換取系統在極端場景下的可用性。

Cache

在由「用戶」和「內容類型」和「內容」所組成的空間中,由於「用戶」維度和「內容」維度的基數非常高,都在數億級別,即使記錄數在萬億這樣的數量級下,數據在整個三維空間內的分佈依然非常稀疏。單純依靠底層存儲系統的能力很難在尺寸巨大且極度稀疏的數據集上提供高吞吐的在線查詢,更難以滿足業務對低響應時間的要求。另外尺寸巨大且分佈稀疏的數據集對緩存系統的資源消耗和命中率的也提出了巨大的挑戰。

知乎已讀服務的前生今世與未來

已讀數據空間分佈極度稀疏

考慮到目前知乎站上沉澱的內容量級巨大,我們可以容忍false positive 但依舊為用戶召回到足夠多可能會感興趣的內容。基於這樣的業務特點我們可以將數據庫中存儲的原始數據轉化為 BloomFilter 緩衝起來,這極大的降低了內存的消耗在相同的資源狀況下可以緩衝更多的數據提高緩存的命中率。

知乎已讀服務的前生今世與未來

緩存Bloom Filter

提升緩存命中率的方式有很多種,除了前面提到的提升緩存數據密度增加可緩衝的數據量級之外,我們還可以通過避免不必要的緩存失效來進一步的提升緩存的效率。因此我們將緩存設計為write through cache 使用原地更新緩存的方式來避免 invalidate cache 操作。再配合數據變更訂閱我們可以在不失效緩衝的情況下確保同一份數據的多個緩衝副本能在很短的時間內達成最終一致。另一方面得益於 read through 的設計,我們可以將對同一份數據的多個併發查詢請求轉化成一次 cache miss 加多次緩衝讀取,進一步提升緩存的命中率降低穿透到底層數據庫系統的壓力。

知乎已讀服務的前生今世與未來

Cache Through

緩衝系統的核心工作是攔截住大量熱數據的訪問,因此維持緩衝數據的熱度是整個系統的穩定性的關鍵因素。但作為不斷迭代演進的業務系統,如何在系統滾動升級或者副本擴容的時候讓新啟動的緩衝節點也快速熱身進入狀態呢?雖然我們可以選擇逐步向新節點開放流量的方式避免冷緩衝的影響,但我們也會面臨故障後自動恢復的情景,這時我們沒有時間等待新的緩衝服務逐步進入狀態。考慮到這點我們在新節點啟動後會從當前slot 內挑選一個活躍的副本遷移全部或足夠多的狀態讓新節點快速進入工作狀態,避免因新節點緩衝不熱導致的響應時間抖動。

知乎已讀服務的前生今世與未來

緩衝狀態遷移

前面我們提到借鑑計算機系統中的內存分層設計,接下來我們從收益的角度來探討採用類似的分層緩衝設計在已讀服務上為系統帶來了什麼。現實中的緩衝系統不論如何提高命中率,我們始終都需要面對cache miss 的場景。多層緩衝的存在可以讓我們在不同層級的 Cache 上應用不同的配置策略,力圖在每一個新引入的 cache 層級上都進一步降低穿透到下一層的請求數量。在多層緩衝的幫助下我們可以讓不同的緩衝層級分別從空間維度和時間維度關注數據的熱度。我們甚至還可以在多數據中心部署的情況下,通過進一步增加緩衝層數利用多層緩衝的機制來極大的降低跨數據中心訪問數據的帶寬消耗和時延增加。

知乎已讀服務的前生今世與未來

跨數據中心部署

知乎作為一個內容社區,用戶的已讀數據是非常核心的行為數據。不但我們在首頁個性化推薦上有過濾需求,在個性化推送上也存在著類似的過濾需求。個性化推送是典型的離線任務,查詢吞吐更高但可以放鬆響應時間的要求,雖然他們所訪問的數據源相同但推送和首頁所訪問的數據在熱度分佈上存在著顯著的不同。為了讓業務之間不互相影響並且針對不同業務的數據訪問特徵選擇不同的緩衝策略,我們還進一步提供了cache 標籤隔離的機制來隔離離線寫入和多個不同的業務租戶的查詢。

知乎已讀服務的前生今世與未來

業務獨立的緩衝策略以及物理隔離

MySQL

在系統開發初期為了加快開發效率,我們在物理存儲層上選擇了在知乎內部應用最廣泛的MySQL。針對系統對高性能和高可用的要求,我們使用了分庫分表加MHA機制來提升系統的性能並保障系統的高可用。除此以外我們還根據已讀數據高寫入低刪除的特點選擇了更適合這個場景的 TokuDB 存儲引擎,得益於 TokuDB 引擎極高的壓縮比系統在一萬億記錄時單副本的總數據尺寸大約13T,平均一行記錄僅使用了 10 多字節的空間。在這個階段我們使用了 12 臺節點來承載這一萬億已讀數據,在這樣的一個集群規模下手工運維還是勉強可以接受的。

性能指標

到2019 年初當前一代的已讀服務已在線上穩定服務了首頁一年有餘,在各項業務指標上來看都是非常滿意的。目前已讀的流量已達每秒 4 萬行紀錄寫入, 3 萬獨立查詢 和 1200 萬個文檔判讀,在這樣的壓力下已讀服務響應時間的 P99 和 P999 仍然穩定的維持在 25ms 和 50ms。

知乎已讀服務的前生今世與未來

已讀服務核心業務指標

全面雲化,面向未來

從已讀的業務指標上看我們交出了一份還讓人滿意的答卷,但作為已讀服務的開發和運維人員我們深知目前的這套架構還存在著一些核心的痛點沒有解決好。首當其中的就是MySQL 的運維問題。我們不但需要考慮數據量繼續膨脹後需要再次分庫分表的擴展性問題,還需要考慮整個集群的高可用和節點發生物理故障後的恢復等一系列問題。在每月數據量近 1000 億持續膨脹帶來的壓力下我們的不安感與日俱增,迫切的需要一個完善的方案來解決 MySQL 集群的運維問題。其次已讀系統的整體架構都是面向在線業務設計的,這導致數據分析的工作很難被直接的應用在這樣的架構之上。帶著已讀服務的這些問題我們於近期開始了新一輪的迭代,本輪迭代的核心目標是將已讀服務全面雲化,達到全系統高可用規模隨需擴展的目標。

已讀服務最痛的MySQL 運維問題實質是單機數據庫在擴展性和可用性上不足所帶來的問題,那麼這個問題最直接了當的解決方式就是致力於解決這些問題的原生分佈式的數據庫。幸運的是近些年工業界在原生分佈式數據庫領域有了非常多的進展,而 CockroachDB 和 TiDB 則是在這個領域非常優秀的兩個開源項目。雖然他們的具體實現細節和技術路徑有所不同,但在大方向上看他們有著不少的相似點。

知乎已讀服務的前生今世與未來

計算存儲分層的分佈式數據庫架構

考慮到已讀服務過去構建於MySQL 技術上,相比之下兼容 MySQL 的 TiDB 比 CockroachDB 對已讀服務有著更低的遷移門檻。除此之外 TiDB 背後的 PingCAP 作為一家中國境內的公司,在我們遇到困難的時候可以更加容易的尋求到幫助。基於這些考慮我們最終選擇將 TiDB 作為已讀服務 MySQL 集群遷移的目標。得益於 TiDB 對 MySQL 的良好兼容和生態工具的完善整個遷移工作並不複雜,除了工作量最大的數據遷移工作之外,開發上還需要調整 CDC 組件與 TiDB Binlog 相適配。

數據遷移

目前TiDB 官方推薦使用一站式的數據遷移工具 DM 來完成從 MySQL 到 TiDB 的全量數據遷移和增量數據同步。但考慮到在準備遷移時已讀服務數據已經達到一萬一千億行的規模,直接使用 DM 做邏輯式的初始全量數據遷移耗時可能會無法接受。在嘗試使用 DM 進行導入測試的結果也從數據上驗證了即便不考慮後期數據量變大後可能的速度下降,以邏輯的方式導入初始全量數據也需要耗時至少一個月。基於使用 DM 導入全量數據耗時預估我們作出了獨立使用 TiDB Lightning 遷移全量數據的決定。

知乎已讀服務的前生今世與未來

MySQL 到 TiDB 數據遷移

目前TiDB Lightning 還尚未同 DM 進行整合,因此整個遷移的過程存在不少的人工操作流程。遷移過程需要首先啟動 DM 開始收集 MySQL 上的增量 Binlog,然後使用 TiDB Lightning 快速導入歷史全量數據到 TiDB。當全量數據導入完成後由 DM 負責將全量數據遷移過程中 MySQL 側產生的增量數據同步到 TiDB 中並維持兩邊數據的一致性。整個遷移過程看起來很簡單但在實際操作中我們也遇到了一些考慮不周的地方導致了多次導入失敗。

TiDB Lightning 導入數據的工作實際需要兩個獨立的程序 tidb-lightning 和 tikv-importer 配合完成,而這兩個程序都屬於資源消耗密集的應用。在倒入初期我們可以用來做數據遷移的服務器並不多,再加上過於輕視 TiDB Lightning 龐大的資源需求,我們嘗試了多種部署模式來試圖使用盡量少的服務器來完成數據導入工作。但這些嘗試無一例外的都失敗了,下面是我們嘗試並最終失敗了的部署方式希望能給大家一些幫助避免遇到同樣的問題。

最初我們試圖在同一臺機器上跑多套tidb-lightning 和 tikv-importer 進程進程同時倒入多個 MySQL 上的數據,這種方式最終以機器上的內存消耗殆盡進程被 OOM Killer 殺掉失敗而告終。接下來我們嘗試在多個 tidb-lightning 進程間複用同一個 tikv-importer,並利用多個 tidb-lightning 進程 encode 數據時不需要 tikv-importer 的特點打時間差提高 tikv-importer 的利用率。通過錯開 tidb-lightning 進程的啟動時間我們在數據導入的初期階段順利的提高了 tikv-importer 服務器的利用率。由於不同 tidb-lightning 任務的實際執行時間不盡相同,在導入進行了一段時間後很難避免多個 tidb-lightning 任務同時向同一個 tikv-importer 節點提交倒入請求導致目標服務資源不足引起失敗或被 OOM Killer 殺掉的現象。在經歷了這些失敗後我們認識到 TiDB Lightning 驚人的導入速度背後也對應著相當高的資源需求,想要提升導入數據的速度必須要給予足夠多的硬件資源。認識到這一點之後我們調整了一下策略,嘗試將部分原本計劃部署 TiKV 的節點改為 TiDB Lightning 數據導入節點。而節點減少導致存儲空間不足的問題我們通過暫時調整集群的副本數為 1 來減少 TiKV 在存儲空間上的需求並預期在導入完成後再恢復副本數到 3。這次嘗試我們成功的導入了全量的數據,並開始使用 dm 增量同步 MySQL 中新發生的寫入。為了避免對實時的增量同步產生過大的衝擊導致無法跟上 MySQL 的數據生產速度,我們對副本恢復設定了速度限制。在這樣情況下將 45T 數據從 1 副本增加到 3 副本的過程需要消耗的時間過長,甚至遠超我們最初在數據導入上獲得的收益。除此以外在副本完全恢復完成前的時間,如果任何一臺節點發生硬件故障都有可能會導致某些 region 數據丟失最終使得整個遷移過程功虧於潰。

在吸取了多次導入失敗的教訓後我們最終按照實際的硬件資源情況選擇了更為保守的導入方案。獨佔使用8 臺硬件配置滿足要求的物理機部署 TiDB Lightning,同時導入 4 個 MySQL 實例的數據,總共 16 個 MySQL 實例上的一萬一千億行紀錄遷移最終耗時 4 天完成。在 dm 的幫助下,導入全量數據的 4 天中所產生的全部增量數據在不到一天的時間全部導入完成,16 個 MySQL 實例上新寫入的數據也會由 dm 近實時的同步到 TiDB 集群上來。

業務遷移

MySQL 同 TiDB 的同步穩定運轉起來後我們開始了業務流量遷移的工作。同遷移過程類似流量切換的過程也並不是一帆風順的。當我們信心滿滿地把線上 100% 流量一次性全部切換到 TiDB 上後發現查詢的響應時間產生了非常顯著的上漲,業務方的調用超時也迅速增加並觸發了業務報警,於是我們立刻會滾了流量到 MySQL 集群並開始排查響應時間陡增的問題。

已讀服務只有在cache miss 時才需要穿透到存儲層查詢數據重建緩存,而重建緩存中則需要一次性的拿出制定用戶過往所有已經讀過內容的全量數據。在實際的業務場景中許多知乎的重度老用戶可能在過去的數年中累計看到過幾萬、十幾萬甚至幾十萬條不同的內容,讀取這樣的全量數據很難在業務要求的幾十毫秒內完成。我們通過將 cache miss 請求的處理分為兩個獨立的步驟,第一個步驟是阻塞式的直接查詢當前請求所需要的數據,與此同時我們還會異步發起一個用於緩衝重建的全量數據查詢請求用來填充緩衝,滿足當前條件的阻塞式查詢完成後會立刻響應用戶的查詢請求。已讀服務的這種工作模式決定了我們的核心優化目標是阻塞式的那個小查詢的響應時間。因此我們根據業務對不同查詢在響應時間上的要求區分為 Low/Normal/High 三種優先級不同的 SQL 語句,使用 hint 的方式告知 TiDB 和 TiKV 對不同的 query 使用不同的任務隊列在資源層面區分隔離避免每個級別任務不受其它級別任務的影響。除此之外我們還進一步調整業務端邏輯通過使用低精度 TSO 和複用 PreparedStatement 的方式來進一步的減少網絡上的 roundtrip 將同步查詢的 latency 壓縮到極致。

除了前面的在線查詢側邏輯調整之外我們還需要將MySQL Binlog 適配到 TiDB Binlog 確保變更事件可以正確的推送給訂閱方。TiDB Binlog 的開發適配非常簡單,只需消費 kafka 上的消息並根據 protobuf 的定義轉換成我們內部的變更消息格式整體適配工作就完成了。在這部分適配工作的驗證過程中我們發現 TiDB Binlog 為了維持事務全局有序的約束只會使用 Kafka 的第 0 號分區,在我們的業務寫入吞吐下只使用一個分區很容易超出 kafka 對應 broker 的處理能力導致 binlog 事件寫入和消費的延遲。為此我們對 TiDB Binlog 的 drainer 組件做了一些臨時的調整根據配置選擇按照 database 或者 table 進行選擇目標分區達到均衡負載的目的。驗證過程中 PingCAP 的同學也及時調整根據我們的反饋做了諸多的改善,在 TiDB Binlog 最終上線時 TiDB Binlog 的事件時延和吞吐都有了非常顯著的加強。

遷移效果

知乎已讀服務的前生今世與未來

遷移到TiDB 後的核心業務指標

經過兩個月的遷移和灰度放量後,已讀服務成功的從MySQL 遷移到了 TiDB 並且核心指標維持在同 MySQL 相似的水平線上。遷移整體完成後已讀服務全部組件都擁有了高可用規模隨需擴展的能力,為將來更好的服務首頁的流量增長打下了堅實的基礎。

關於TiDB 3.0

在知乎內部採用同已讀相同的架構我們還支撐了一套用於反作弊的風控類業務。同已讀服務極端的歷史數據規模不同,反作弊業務有著更加極端的寫入吞吐但只需在線查詢最近48 小時入庫的數據。TiDB 3.0 的一些新特性在反作弊業務上比 2.1 有了質的提升,因此我們從 TiDB 3.0 rc1 開始就在反作弊業務上將 TiDB 3.0 引入到了生產環境並在 rc2 之後不久開啟了 Titan 存儲引擎。TiDB 3.0 的 gRPC Batch Message 和多線程 Raft Store 提升了集群吞吐能力讓我們可以用更少的資源投入來解決同樣的業務問題,Titan 引擎極大的改善了業務的讀寫響應時間的穩定性。除此之外 3.0 的分區表功能也可以更好的利用業務 48 小時查詢時效的業務特點來提升查詢效率。

"

導讀:對於很多大型網站來說,一些不起眼的小功能反而是實現的難點。對於知乎來說,已讀服務會隨著用戶量和內容數量的增長而平方級增長,而且響應時間要求很短,因此是一個有實現難度的系統。本文作者介紹了知乎已讀服務的架構設計和演進過程,並對很多技術取捨做了深入剖析,十分值得閱讀。

知乎已讀服務的前生今世與未來

作者簡介:孫曉光,知乎搜索後端負責人,目前承擔知乎搜索後端架構設計以及工程團隊的管理工作。曾多年從事私有云相關產品開發工作關注雲原生技術,TiKV 項目 Committer。

知乎從問答起步,在過去的8 年中逐步成長為一個大規模的綜合性知識內容平臺,目前,知乎上有多達 3000 萬個問題,共收穫了超過 1.3 億個回答,同時知乎還沉澱了數量眾多的文章、電子書以及其他付費內容。知乎通過個性化首頁推薦的方式在海量的信息中高效分發用戶感興趣的優質內容。為了避免給用戶推薦重複的內容,已讀服務會將所有知乎站上用戶深入閱讀或快速掠過的內容長期保存,並將這些數據應用於首頁推薦信息流和個性化推送的已讀過濾。

業務場景,技術挑戰

知乎已讀服務的前生今世與未來

首頁已讀過濾流程示意圖

從首頁使用已讀服務的流程我們可以看出這個服務的業務模式較為簡單,我們只需要簡單的以用戶為第一維度內容為第二緯度來查詢指定用戶是否已經閱讀過某個內容。但我們並沒因為業務簡單就在設計上放棄了靈活性和普適性。為此我們設計開發了一套支持BigTable 數據模型的 Cache Through 緩衝系統 RBase 來實現已讀服務,一方面充分利用 Cache 的高吞吐低時延能力,另一方面還可以利用靈活的 BigTable 數據模型來輔助業務快速演進。

知乎已讀服務的前生今世與未來

BigTable 數據模型

已讀服務雖然從業務模式看非常簡單,但它在技術上的挑戰確並不低。目前知乎已讀的數據規模已超萬億並以每天接近30 億的速度持續高速增長。與常見的“讀多寫少”的業務不同,已讀服務不僅需要在這樣的存量數據規模下提供在線查詢服務,還同時承載著每秒 4 萬條新紀錄寫入的衝擊。已讀內容過濾作為首頁信息流推薦中對響應時間影響較大的關鍵任務點,它的可用性和響應時間都需要滿足非常高的要求。

綜合業務需求和線上數據來看,已讀服務的要求和挑戰主要有以下幾點:

● 可用性要求「高」:服務於個性化首頁和個性化推送,最重要的流量分發渠道

● 寫入量「大」:峰值每秒寫入40K+ 行記錄,日新增記錄近 30 億條

● 歷史數據「長期」保存:目前已達一萬二千億條記錄

● 查詢吞吐「高」:在線查詢峰值30K QPS / 12M+ 條已讀檢查

● 響應時間「敏感」:90ms 超時

早期方案,架構演進

BloomFilter on Redis Cluster

知乎已讀服務的前生今世與未來

最初我們在Redis 集群上使用 BITSET 結構直接存儲已讀數據的 BloomFilter。首先由於缺乏多個位的批量操作,操作放大非常嚴重會消耗非常多的計算資源。其次使用全內存的方式存儲全量數據也拉高了整體成本。最後由於難以預估用戶的閱讀量增速無法以用戶為粒度合理控制 BloomFilter 的尺寸和 False Positive Rate。

HBase

考慮到BloomFilter on Redis 方案存在的問題,我們開始嘗試使用 HBase 來存儲用戶的閱讀歷史並提供在線查詢服務。已讀的業務需求可以非常直觀的映射到 BigTable 的數據模型上。我們將用戶 id 作為 row key,訪問的文檔 id 作為 qualifier 保存下來, 而 timestamp 則恰好可以用來記錄文檔的讀取時間。整個系統的可擴展性和成本都要顯著優於直接使用 Redis Cluster 存儲 BloomFilter 的方案。

隨著已讀數據量級和業務查詢量的迅速增長,已讀數據訪問極度稀疏的特點開始影響到了HBase 的 cache 命中率。HBase 的存儲模型在發生 cache miss 需要訪問存儲的情況下 IO 路徑很長。根據緩衝穿透的層次不同整個請求路徑上可能會經過數個 Java 進程,任何一個進程的 GC 和 IO 都會對這次訪問的 latency 產生顯著的影響,導致響應時間產生較大的波動,而大的響應時間波動是首頁難以接受的。

知乎已讀服務的前生今世與未來

HBase 緩衝穿透的 IO 路徑

在吸取了最初兩代已讀架構方案的經驗教訓後,我們開始設計實現新一代的已讀服務,在這次的設計中我們在可用性、性能以及擴展性上都設定了更高的目標尤其是以往系統表現不好的性能和擴展性方面我們希望看到更加長足的進步。

● 高可用

● ✓ HBase

● ✓BloomFilter on Redis Cluster

● 高性能

● ✘HBase

● ✓BloomFilter on Redis Cluster

● 易擴展

● ✓HBase

● ✘BloomFilter on Redis Cluster

下面就讓我們一起從高可用、高性能和易擴展這三個角度來思考如何構建一個更好的已讀服務滿足好業務的需求和挑戰。

高可用

當我們討論高可用的時候,也意味著我們已經意識到故障是無時無刻都在發生的,依賴傳統人工運維的方式來保證複雜系統的高可用是不現實的。我們需要以系統化的方式對各個組件的狀態進行探測感知他們發生的故障。並且我們需要為系統中的組件設計自愈機制,當故障發生時可以不經人工干預而自動的恢復。最後我們還需要隔離各種故障所產生的變化,讓業務側儘可能對故障的發生和恢復無感知。

知乎已讀服務的前生今世與未來

故障監測、自動恢復並隔離變化

高性能

對常見的系統來說,越核心的組件往往狀態越重擴展的代價也越大,層層攔截快速降低需要深入到核心組件的請求量對提高性能是非常有效的手段。首先我們通過緩衝分Slot 的方式來擴展集群所能緩衝的數據規模。接著進一步在 Slot 內通過多副本的方式提升單個 Slot 緩衝數據集的讀取吞吐,將大量的請求攔截在系統的緩衝層進行消化。如果請求不可避免的走到了最終的數據庫組件上,我們還可以利用效率較高的壓縮來繼續降低落到物理設備上的IO壓力。

知乎已讀服務的前生今世與未來

分層去併發

易擴展

提升系統擴展性的關鍵在於減少有狀態組件的範圍,在路由和服務發現組件的幫助下,系統中的無狀態組件可以非常輕鬆的擴展擴容。所以通過擴大無狀態服務的範圍,收縮重狀態服務的比例可以顯著的幫助我們提升整個系統的可擴展性。除此之外如果我們能夠設計一些可以從外部系統恢復狀態的弱狀態服務,那麼我們往往可以利用弱狀態的組件來部分替代重狀態組件。隨著弱狀態組件的擴大和重狀態組件的收縮,整個系統的可擴展性可以得到進一步的提升。

知乎已讀服務的前生今世與未來

弱狀態部分替代重狀態

RBase

在高可用、高性能和易擴展的設計理念下,我們設計實現了RBase 做為已讀服務的根基。現在讓我們來從 RBase 全局設計入手來了解高可用、高性能和易擴展的設計理念是如何落地的。

知乎已讀服務的前生今世與未來

RBase 架構

客戶端API 和 Proxy 是完全無狀態可隨時擴展的組件,最底層是由 MHA 管理的 MySQL 集群,中間存在大量可從數據庫或者副本中恢復的弱狀態組件。這些弱狀態組件中最核心的部分是分層的緩衝模塊,這些緩衝模塊的狀態可以從副本或數據庫中恢復重建因此他們的可擴展性仍然是非常優秀的。緩衝以外的組件則負責管理緩衝的一致性,在它們的協助下緩衝模塊可以完全避免無意義的 Cache Invalidate 提升緩存的命中率,從而極大緩解了傳導到底層數據庫系統上的壓力。整個架構中除了唯一的重狀態組件 MySQL 集群之外所有的組件都擁有自我恢復的能力。在擁有自我恢復能力和全局故障監測的前提下,我們使用 Kubernetes 來管理所有 RBase 的服務組件在機制上確保整個服務的高可用。

RBase 的設計中緩存是一個非常關鍵的組件,然而它們的組織管理方式又同常見的緩存系統不盡相同,這些設計上的不同體現著我們在高性能方面的思考。現在讓我們以一個典型的計算機系統的內存分層設計來引出我們在緩存系統設計上的思路。

知乎已讀服務的前生今世與未來

計算機系統內存分層示意圖

在過去的幾十年中工業界為了提升計算機系統的性能,在計算機系統中添加了更多的CPU 乃至更多的核心。隨著 CPU 內部處理能力的不斷增強我們還為 CPU 加上多級的緩存來彌補主存在帶寬和時延上同 CPU 的巨大差距。除此之外我們還進一步把主存連同 CPU 分成多組讓他們之間有更快的本地連接,只有當需要交叉訪問遠程內存的時候再通過互聯總線進行交流。在這樣一個系統裡大部分的讀寫操作都發生在離核心最近的 L1 或者 L2 cache 裡,而在修改主存中數據的時候則需要更加複雜的機制和更長的時間來達成緩衝的最終一致。為了能將計算機系統的性能發揮到極致工業界不斷的改進體系結構,在這個架構中處處都體現著設計的取捨和智慧。

我們利用在內存分層設計上獲得的靈感,在RBase 的緩存一致性上採用了類似的設計。類似計算機系統內存的數據庫層,相似的分層緩衝設計,類似的 cache through 以及 cache coherence 組件設計。大道至簡殊途同歸,通過學習借鑑體系結構經典成熟的思想將它應用到軟件系統設計中同樣可以得到非常好的效果。

核心組件,關鍵設計

接下來讓我們從細節來深入瞭解已讀系統中一些核心組件的關鍵設計。

Proxy

知乎已讀服務的前生今世與未來

Proxy 負責負載均衡和隔離故障

Proxy 是已讀服務的接入層組件,它用傳統的方式將緩衝按照用戶維度拆分成多個 slot 組織起來,每個 slot 負責數據集內的一個子集。Slot 內可以有多個副本來分擔同一批數據的讀取壓力,proxy 會在 slot 內對同一個會話綁定同一個副本來保證會話內的一致性,當副本發生故障時 proxy 優先選擇同一個 slot 內的其它副本來繼續承載請求,在極端情況發生 slot 內的所有副本同時失效時,proxy 還可以選擇其它 slot 的活躍節點來處理用戶的請求,這時我們付出了無法利用緩衝提高性能的代價來換取系統在極端場景下的可用性。

Cache

在由「用戶」和「內容類型」和「內容」所組成的空間中,由於「用戶」維度和「內容」維度的基數非常高,都在數億級別,即使記錄數在萬億這樣的數量級下,數據在整個三維空間內的分佈依然非常稀疏。單純依靠底層存儲系統的能力很難在尺寸巨大且極度稀疏的數據集上提供高吞吐的在線查詢,更難以滿足業務對低響應時間的要求。另外尺寸巨大且分佈稀疏的數據集對緩存系統的資源消耗和命中率的也提出了巨大的挑戰。

知乎已讀服務的前生今世與未來

已讀數據空間分佈極度稀疏

考慮到目前知乎站上沉澱的內容量級巨大,我們可以容忍false positive 但依舊為用戶召回到足夠多可能會感興趣的內容。基於這樣的業務特點我們可以將數據庫中存儲的原始數據轉化為 BloomFilter 緩衝起來,這極大的降低了內存的消耗在相同的資源狀況下可以緩衝更多的數據提高緩存的命中率。

知乎已讀服務的前生今世與未來

緩存Bloom Filter

提升緩存命中率的方式有很多種,除了前面提到的提升緩存數據密度增加可緩衝的數據量級之外,我們還可以通過避免不必要的緩存失效來進一步的提升緩存的效率。因此我們將緩存設計為write through cache 使用原地更新緩存的方式來避免 invalidate cache 操作。再配合數據變更訂閱我們可以在不失效緩衝的情況下確保同一份數據的多個緩衝副本能在很短的時間內達成最終一致。另一方面得益於 read through 的設計,我們可以將對同一份數據的多個併發查詢請求轉化成一次 cache miss 加多次緩衝讀取,進一步提升緩存的命中率降低穿透到底層數據庫系統的壓力。

知乎已讀服務的前生今世與未來

Cache Through

緩衝系統的核心工作是攔截住大量熱數據的訪問,因此維持緩衝數據的熱度是整個系統的穩定性的關鍵因素。但作為不斷迭代演進的業務系統,如何在系統滾動升級或者副本擴容的時候讓新啟動的緩衝節點也快速熱身進入狀態呢?雖然我們可以選擇逐步向新節點開放流量的方式避免冷緩衝的影響,但我們也會面臨故障後自動恢復的情景,這時我們沒有時間等待新的緩衝服務逐步進入狀態。考慮到這點我們在新節點啟動後會從當前slot 內挑選一個活躍的副本遷移全部或足夠多的狀態讓新節點快速進入工作狀態,避免因新節點緩衝不熱導致的響應時間抖動。

知乎已讀服務的前生今世與未來

緩衝狀態遷移

前面我們提到借鑑計算機系統中的內存分層設計,接下來我們從收益的角度來探討採用類似的分層緩衝設計在已讀服務上為系統帶來了什麼。現實中的緩衝系統不論如何提高命中率,我們始終都需要面對cache miss 的場景。多層緩衝的存在可以讓我們在不同層級的 Cache 上應用不同的配置策略,力圖在每一個新引入的 cache 層級上都進一步降低穿透到下一層的請求數量。在多層緩衝的幫助下我們可以讓不同的緩衝層級分別從空間維度和時間維度關注數據的熱度。我們甚至還可以在多數據中心部署的情況下,通過進一步增加緩衝層數利用多層緩衝的機制來極大的降低跨數據中心訪問數據的帶寬消耗和時延增加。

知乎已讀服務的前生今世與未來

跨數據中心部署

知乎作為一個內容社區,用戶的已讀數據是非常核心的行為數據。不但我們在首頁個性化推薦上有過濾需求,在個性化推送上也存在著類似的過濾需求。個性化推送是典型的離線任務,查詢吞吐更高但可以放鬆響應時間的要求,雖然他們所訪問的數據源相同但推送和首頁所訪問的數據在熱度分佈上存在著顯著的不同。為了讓業務之間不互相影響並且針對不同業務的數據訪問特徵選擇不同的緩衝策略,我們還進一步提供了cache 標籤隔離的機制來隔離離線寫入和多個不同的業務租戶的查詢。

知乎已讀服務的前生今世與未來

業務獨立的緩衝策略以及物理隔離

MySQL

在系統開發初期為了加快開發效率,我們在物理存儲層上選擇了在知乎內部應用最廣泛的MySQL。針對系統對高性能和高可用的要求,我們使用了分庫分表加MHA機制來提升系統的性能並保障系統的高可用。除此以外我們還根據已讀數據高寫入低刪除的特點選擇了更適合這個場景的 TokuDB 存儲引擎,得益於 TokuDB 引擎極高的壓縮比系統在一萬億記錄時單副本的總數據尺寸大約13T,平均一行記錄僅使用了 10 多字節的空間。在這個階段我們使用了 12 臺節點來承載這一萬億已讀數據,在這樣的一個集群規模下手工運維還是勉強可以接受的。

性能指標

到2019 年初當前一代的已讀服務已在線上穩定服務了首頁一年有餘,在各項業務指標上來看都是非常滿意的。目前已讀的流量已達每秒 4 萬行紀錄寫入, 3 萬獨立查詢 和 1200 萬個文檔判讀,在這樣的壓力下已讀服務響應時間的 P99 和 P999 仍然穩定的維持在 25ms 和 50ms。

知乎已讀服務的前生今世與未來

已讀服務核心業務指標

全面雲化,面向未來

從已讀的業務指標上看我們交出了一份還讓人滿意的答卷,但作為已讀服務的開發和運維人員我們深知目前的這套架構還存在著一些核心的痛點沒有解決好。首當其中的就是MySQL 的運維問題。我們不但需要考慮數據量繼續膨脹後需要再次分庫分表的擴展性問題,還需要考慮整個集群的高可用和節點發生物理故障後的恢復等一系列問題。在每月數據量近 1000 億持續膨脹帶來的壓力下我們的不安感與日俱增,迫切的需要一個完善的方案來解決 MySQL 集群的運維問題。其次已讀系統的整體架構都是面向在線業務設計的,這導致數據分析的工作很難被直接的應用在這樣的架構之上。帶著已讀服務的這些問題我們於近期開始了新一輪的迭代,本輪迭代的核心目標是將已讀服務全面雲化,達到全系統高可用規模隨需擴展的目標。

已讀服務最痛的MySQL 運維問題實質是單機數據庫在擴展性和可用性上不足所帶來的問題,那麼這個問題最直接了當的解決方式就是致力於解決這些問題的原生分佈式的數據庫。幸運的是近些年工業界在原生分佈式數據庫領域有了非常多的進展,而 CockroachDB 和 TiDB 則是在這個領域非常優秀的兩個開源項目。雖然他們的具體實現細節和技術路徑有所不同,但在大方向上看他們有著不少的相似點。

知乎已讀服務的前生今世與未來

計算存儲分層的分佈式數據庫架構

考慮到已讀服務過去構建於MySQL 技術上,相比之下兼容 MySQL 的 TiDB 比 CockroachDB 對已讀服務有著更低的遷移門檻。除此之外 TiDB 背後的 PingCAP 作為一家中國境內的公司,在我們遇到困難的時候可以更加容易的尋求到幫助。基於這些考慮我們最終選擇將 TiDB 作為已讀服務 MySQL 集群遷移的目標。得益於 TiDB 對 MySQL 的良好兼容和生態工具的完善整個遷移工作並不複雜,除了工作量最大的數據遷移工作之外,開發上還需要調整 CDC 組件與 TiDB Binlog 相適配。

數據遷移

目前TiDB 官方推薦使用一站式的數據遷移工具 DM 來完成從 MySQL 到 TiDB 的全量數據遷移和增量數據同步。但考慮到在準備遷移時已讀服務數據已經達到一萬一千億行的規模,直接使用 DM 做邏輯式的初始全量數據遷移耗時可能會無法接受。在嘗試使用 DM 進行導入測試的結果也從數據上驗證了即便不考慮後期數據量變大後可能的速度下降,以邏輯的方式導入初始全量數據也需要耗時至少一個月。基於使用 DM 導入全量數據耗時預估我們作出了獨立使用 TiDB Lightning 遷移全量數據的決定。

知乎已讀服務的前生今世與未來

MySQL 到 TiDB 數據遷移

目前TiDB Lightning 還尚未同 DM 進行整合,因此整個遷移的過程存在不少的人工操作流程。遷移過程需要首先啟動 DM 開始收集 MySQL 上的增量 Binlog,然後使用 TiDB Lightning 快速導入歷史全量數據到 TiDB。當全量數據導入完成後由 DM 負責將全量數據遷移過程中 MySQL 側產生的增量數據同步到 TiDB 中並維持兩邊數據的一致性。整個遷移過程看起來很簡單但在實際操作中我們也遇到了一些考慮不周的地方導致了多次導入失敗。

TiDB Lightning 導入數據的工作實際需要兩個獨立的程序 tidb-lightning 和 tikv-importer 配合完成,而這兩個程序都屬於資源消耗密集的應用。在倒入初期我們可以用來做數據遷移的服務器並不多,再加上過於輕視 TiDB Lightning 龐大的資源需求,我們嘗試了多種部署模式來試圖使用盡量少的服務器來完成數據導入工作。但這些嘗試無一例外的都失敗了,下面是我們嘗試並最終失敗了的部署方式希望能給大家一些幫助避免遇到同樣的問題。

最初我們試圖在同一臺機器上跑多套tidb-lightning 和 tikv-importer 進程進程同時倒入多個 MySQL 上的數據,這種方式最終以機器上的內存消耗殆盡進程被 OOM Killer 殺掉失敗而告終。接下來我們嘗試在多個 tidb-lightning 進程間複用同一個 tikv-importer,並利用多個 tidb-lightning 進程 encode 數據時不需要 tikv-importer 的特點打時間差提高 tikv-importer 的利用率。通過錯開 tidb-lightning 進程的啟動時間我們在數據導入的初期階段順利的提高了 tikv-importer 服務器的利用率。由於不同 tidb-lightning 任務的實際執行時間不盡相同,在導入進行了一段時間後很難避免多個 tidb-lightning 任務同時向同一個 tikv-importer 節點提交倒入請求導致目標服務資源不足引起失敗或被 OOM Killer 殺掉的現象。在經歷了這些失敗後我們認識到 TiDB Lightning 驚人的導入速度背後也對應著相當高的資源需求,想要提升導入數據的速度必須要給予足夠多的硬件資源。認識到這一點之後我們調整了一下策略,嘗試將部分原本計劃部署 TiKV 的節點改為 TiDB Lightning 數據導入節點。而節點減少導致存儲空間不足的問題我們通過暫時調整集群的副本數為 1 來減少 TiKV 在存儲空間上的需求並預期在導入完成後再恢復副本數到 3。這次嘗試我們成功的導入了全量的數據,並開始使用 dm 增量同步 MySQL 中新發生的寫入。為了避免對實時的增量同步產生過大的衝擊導致無法跟上 MySQL 的數據生產速度,我們對副本恢復設定了速度限制。在這樣情況下將 45T 數據從 1 副本增加到 3 副本的過程需要消耗的時間過長,甚至遠超我們最初在數據導入上獲得的收益。除此以外在副本完全恢復完成前的時間,如果任何一臺節點發生硬件故障都有可能會導致某些 region 數據丟失最終使得整個遷移過程功虧於潰。

在吸取了多次導入失敗的教訓後我們最終按照實際的硬件資源情況選擇了更為保守的導入方案。獨佔使用8 臺硬件配置滿足要求的物理機部署 TiDB Lightning,同時導入 4 個 MySQL 實例的數據,總共 16 個 MySQL 實例上的一萬一千億行紀錄遷移最終耗時 4 天完成。在 dm 的幫助下,導入全量數據的 4 天中所產生的全部增量數據在不到一天的時間全部導入完成,16 個 MySQL 實例上新寫入的數據也會由 dm 近實時的同步到 TiDB 集群上來。

業務遷移

MySQL 同 TiDB 的同步穩定運轉起來後我們開始了業務流量遷移的工作。同遷移過程類似流量切換的過程也並不是一帆風順的。當我們信心滿滿地把線上 100% 流量一次性全部切換到 TiDB 上後發現查詢的響應時間產生了非常顯著的上漲,業務方的調用超時也迅速增加並觸發了業務報警,於是我們立刻會滾了流量到 MySQL 集群並開始排查響應時間陡增的問題。

已讀服務只有在cache miss 時才需要穿透到存儲層查詢數據重建緩存,而重建緩存中則需要一次性的拿出制定用戶過往所有已經讀過內容的全量數據。在實際的業務場景中許多知乎的重度老用戶可能在過去的數年中累計看到過幾萬、十幾萬甚至幾十萬條不同的內容,讀取這樣的全量數據很難在業務要求的幾十毫秒內完成。我們通過將 cache miss 請求的處理分為兩個獨立的步驟,第一個步驟是阻塞式的直接查詢當前請求所需要的數據,與此同時我們還會異步發起一個用於緩衝重建的全量數據查詢請求用來填充緩衝,滿足當前條件的阻塞式查詢完成後會立刻響應用戶的查詢請求。已讀服務的這種工作模式決定了我們的核心優化目標是阻塞式的那個小查詢的響應時間。因此我們根據業務對不同查詢在響應時間上的要求區分為 Low/Normal/High 三種優先級不同的 SQL 語句,使用 hint 的方式告知 TiDB 和 TiKV 對不同的 query 使用不同的任務隊列在資源層面區分隔離避免每個級別任務不受其它級別任務的影響。除此之外我們還進一步調整業務端邏輯通過使用低精度 TSO 和複用 PreparedStatement 的方式來進一步的減少網絡上的 roundtrip 將同步查詢的 latency 壓縮到極致。

除了前面的在線查詢側邏輯調整之外我們還需要將MySQL Binlog 適配到 TiDB Binlog 確保變更事件可以正確的推送給訂閱方。TiDB Binlog 的開發適配非常簡單,只需消費 kafka 上的消息並根據 protobuf 的定義轉換成我們內部的變更消息格式整體適配工作就完成了。在這部分適配工作的驗證過程中我們發現 TiDB Binlog 為了維持事務全局有序的約束只會使用 Kafka 的第 0 號分區,在我們的業務寫入吞吐下只使用一個分區很容易超出 kafka 對應 broker 的處理能力導致 binlog 事件寫入和消費的延遲。為此我們對 TiDB Binlog 的 drainer 組件做了一些臨時的調整根據配置選擇按照 database 或者 table 進行選擇目標分區達到均衡負載的目的。驗證過程中 PingCAP 的同學也及時調整根據我們的反饋做了諸多的改善,在 TiDB Binlog 最終上線時 TiDB Binlog 的事件時延和吞吐都有了非常顯著的加強。

遷移效果

知乎已讀服務的前生今世與未來

遷移到TiDB 後的核心業務指標

經過兩個月的遷移和灰度放量後,已讀服務成功的從MySQL 遷移到了 TiDB 並且核心指標維持在同 MySQL 相似的水平線上。遷移整體完成後已讀服務全部組件都擁有了高可用規模隨需擴展的能力,為將來更好的服務首頁的流量增長打下了堅實的基礎。

關於TiDB 3.0

在知乎內部採用同已讀相同的架構我們還支撐了一套用於反作弊的風控類業務。同已讀服務極端的歷史數據規模不同,反作弊業務有著更加極端的寫入吞吐但只需在線查詢最近48 小時入庫的數據。TiDB 3.0 的一些新特性在反作弊業務上比 2.1 有了質的提升,因此我們從 TiDB 3.0 rc1 開始就在反作弊業務上將 TiDB 3.0 引入到了生產環境並在 rc2 之後不久開啟了 Titan 存儲引擎。TiDB 3.0 的 gRPC Batch Message 和多線程 Raft Store 提升了集群吞吐能力讓我們可以用更少的資源投入來解決同樣的業務問題,Titan 引擎極大的改善了業務的讀寫響應時間的穩定性。除此之外 3.0 的分區表功能也可以更好的利用業務 48 小時查詢時效的業務特點來提升查詢效率。

知乎已讀服務的前生今世與未來

開啟Titan 引擎的效果

在反作弊業務上從TiDB 3.0 收穫的紅利很大程度對已讀服務也同樣適用,我們計劃在 3.0 GA 後將已讀服務的集群進行升級並預期獲得較大的資源使用效率的提升。

結語

在已讀服務開發上線以及一年多的演進過程中我們收穫到了一些經驗和教訓,更好的理解了作為支撐部門如何更好的支持自己的下游業務。首先要理解業務需求有針對性的根據業務特點來設計支撐系統,但在設計上我們仍然要提煉抽象出更有普適性的架構。作為支撐性業務從最初就要關注高可用,為業務提供一個穩固的後方。同樣我們還需要關注高性能可伸縮,為業務未來的發展掃清障礙。最後在這個雲原生的時代,即便是業務研發也應當抱著開放的心態去積極的擁抱新技術,Cloud Native from Ground Up。

高可用架構

改變互聯網的構建方式

"

相關推薦

推薦中...