Kudu:一個融合低延遲寫入和高效能分析的儲存系統

PingCAP發表於2017-05-09

Kudu 是一個基於 Raft 的分散式儲存系統,它致力於融合低延遲寫入和高效能分析這兩種場景,並且能很好的嵌入到 Hadoop 生態系統裡面,跟其他系統譬如 Cloudera Impala,Apache Spark 等對接。

Kudu 很類似 TiDB。最開始,TiDB 是為了 OLTP 系統設計的,但後來發現我們 OLAP 的功能也越來越強大,所以就有了融合 OLTP 和 OLAP 的想法,當然這條路並不是那麼容易,我們還有很多工作要做。因為 Kudu 的理念跟我們類似,所以我也很有興趣去研究一下它,這裡主要是依據 Kudu 在 2015 釋出的 paper,因為 Kudu 是開源的,並且在不斷的更新,所以現在程式碼裡面一些實現可能還跟 paper 不一樣了,但這裡僅僅先說一下我對 paper 的理解,實際的程式碼我後續研究了在詳細說明。

為什麼需要 Kudu?

結構化資料儲存系統在 Hadoop 生態系統裡面,通常分為兩類:

  • 靜態資料,資料通常都是使用二進位制格式存放到 HDFS 上面,譬如 Apache Avro,Apache Parquet。但無論是 HDFS 還是相關的系統,都是為高吞吐連續訪問資料這些場景設計的,都沒有很好的支援單獨 record 的更新,或者是提供好的隨機訪問的能力。

  • 動態資料,資料通常都是使用半結構化的方式儲存,譬如 Apache HBase,Apache Cassandra。這些系統都能低延遲的讀寫單獨的 record,但是對於一些像 SQL 分析這樣需要連續大量讀取資料的場景,顯得有點捉緊見拙。

上面的兩種系統,各有自己的側重點,一類是低延遲的隨機訪問特定資料,而另一類就是高吞吐的分析大量資料。之前,我們並沒有這樣的系統可以融合上面兩種情況,所以通常的做法就是使用 pipeline,譬如我們非常熟悉的 Kafka,通常我們會將資料快速寫到 HBase 等系統裡面,然後通過 pipeline,在匯出給其它分析系統。雖然我們在一定層面上面,我們其實通過 pipeline 來對整個系統進行了解耦,但總歸要維護多套系統。而且資料更新之後,並不能直接實時的進行分析處理,有延遲的開銷。所以在某些層面上面,並不是一個很好的解決方案。

Kudu 致力於解決上面的問題,它提供了簡單的來處理資料的插入,更新和刪除,同時提供了 table scan 來處理資料分析。通常如果一個系統要融合兩個特性,很有可能就會陷入兩邊都做,兩邊都沒做好的窘境,但 Kudu 很好的在融合上面取得了平衡,那麼它是如何做到的呢?

Keyword

Tables 和 schemas

Kudu 提供了 table 的概念。使用者可以建立多個 table,每個 table 都有一個預先定義好的 schema。Schema 裡面定義了這個 table 多個 column,每個 column 都有名字,型別,是否允許 null 等。一些 columns 組成了 primary key。

可以看到,Kudu 的資料模型非常類似關聯式資料庫,在使用之前,使用者必須首先建立一個 table,訪問不存在的 table 或者 column 都會報錯。使用者可以使用 DDL 語句新增或者刪除 column,但不能刪除包含 primary key 的 column。

但在 Paper 裡面說到 Kudu 不支援二級索引以及除了 primary key 之外的唯一索引,這個後續可以通過更新的程式碼來確定下。

其實我這裡非常關注的是 Kudu 的 Online DDL 是如何做的,只是 Paper 裡面貌似沒有提及,後面只能看程式碼了。

API

Kudu 提供了 Insert,Update 和 Delete 的 write API。不支援多行事務 API,這個不知道最新的能支援了沒有,因為僅僅能對單行資料操作,還遠遠不夠。

Kudu 提供了 Scan read API 讓使用者去讀取資料。使用者可以指定一些特定的條件來過濾結果,譬如用一個常量跟一個 column 裡面的值比較,或者一段 primary key 的範圍等條件。

提供 API 的好處在於實現簡單,但對於使用者來說,其實更好的使用方式仍然是 SQL,一些複雜的查詢最好能通過 SQL 搞定,而不是讓使用者自己去 scan 資料,然後自己組裝。

一致性模型

Kudu 提供兩種一致性模型:snapshot consistency 和 external consistency。

預設 Kudu 提供 Snapshot consistency, 它具有更好的讀效能,但可能會有 write skew 問題。而 External consistency 則能夠完全保證整個系統的 linearizability,也就是當寫入一條資料之後,後面的任何讀取都一定能讀到最新的資料。

為了實現 External consistency,Kudu 提供了幾種方法:

  • 在 clients 之間顯示的傳遞時間戳。當寫入一條資料之後,使用者用要求 client 去拿一個時間戳作為 token,然後通過一個 external channel 的方式傳遞給另一個 client。然後另一個 client 就可以通過這個 token 去讀取資料,這樣就一定能保證讀取到最新的資料了。不過這個方法實在是有點複雜。

  • 提供類似 Spanner 的 commit-wait 機制。當寫入一條資料之後,client 需要等待一段時間來確定寫入成功。Kudu 並沒有採用 Spanner TrueTime 的方案,而是使用了 HybridTime 的方案。HybridTime 依賴 NTP,這個可能導致 wait 的時間很長,但 Kudu 認為未來隨著 read-time clock 的完善,這應該不是問題了。

Kudu 是我已知的第二個採用 HybridTime 來解決 External consistency 的產品,第一個當然就是 CockroachDB 了。TiDB 跟他們不一樣,我們採用的是全域性授時的方案,這個會簡單很多,但其實也有跟 PD 互動的網路開銷。後續TiDB 可能使用類似 Spanner 的 GPS + 原子鐘,現階段相關硬體的製造方式 Google 並沒有說明,但其實難度不大。因為已經有很多硬體廠商主動找我們希望一起合作提供,只是比較貴,而現階段我們大多數客戶並沒有跨全球事務這種場景。

Kudu 的一致性模型依賴時間戳,這應該是現在所有分散式系統通用的做法。Kudu 並沒有給使用者保留時間戳的概念,主要是覺得使用者很可能會困惑,畢竟不是所有的使用者都能很好的理解 MVCC 這些概念。當然,對於 read API,還是允許使用者指定特定的一個時間戳,這樣就能讀取到歷史資料。這個 TiDB 也是類似的做法,使用者不知道時間戳,只是我們額外提供了一個設定 snapshot 的操作,讓使用者指定生成某個時間點的快照,讀取那個時間點的資料。這個功能已經幫很多公司恢復了因為錯誤操作寫壞的資料了。

架構

上面說了一些 Kudu 的 keyword, 現在來說說 Kudu 的整體架構。Kudu 類似 GFS,提供了一個單獨的 Master 服務,用來管理整個叢集的元資訊,同時有多個 Tablet 服務,用來儲存實際的資料。

分割槽

Kudu 支援對資料按照 Range 以及 Hash 的方式進行分割槽。 每個大的 table 都可以通過這種方式將資料分不到不同的 Tablet 上面。當使用者建立一個表的時候,同時也可以指定特定的 partition schema,partition schema 會將 primary key 對映成對應的 partition key。每個 Tablet 上面會覆蓋一段或者多段 partition keys 的range。當 client 需要運算元據的時候,它可以很方便的就知道這個資料在哪一個 Tablet 上面。

一個 partition schema 可以包括 0 或者多個 hash-partitioning 規則和最多一個 range-partitioning 規則。使用者可以根據自己實際的場景來設定不同的 partition 規則。

譬如有一行資料是 (host, metric, time, value),time 是單調遞增的,如果我們將 time 按照 hash 的方式分割槽,雖然能保證資料分散到不同的 Tablets 上面,但如果我們想查詢某一段時間區間的資料,就得需要全部掃描所有的 Tablets 了。所以通常對於 time,我們都是採用 range 的分割槽方式。但 range 的方式會有 hot range 的問題,也就是同一個時間會有大量的資料寫到一個 range 上面,而這個 hot range 是沒法通過 scale out 來緩解的,所以我們可以將 (host, metric) 按照 hash 分割槽,這樣就在 write 和 read 之間提供了一個平衡。

通過多個 partition 規則組合,能很好的應對一些場景,但同時這個這對使用者的要求比較高,他們必須更加了解 Kudu,瞭解自己的整個系統資料會如何的寫入以及查詢。現在 TiDB 還只是單純的支援 range 的分割槽方式,但未來不排除也引入 hash。

Raft

Kudu 使用 Raft 演算法來保證分散式環境下面資料一致性,這裡就不再詳細的說明 Raft 演算法了,因為有太多的資料了。

Kudu 的 heartbeat 是 500 毫秒,election timeout 是 1500 毫秒,這個時間其實很頻繁,如果 Raft group 到了一定量級,網路開銷會比較大。另外,Kudu 稍微做了一些 Raft 的改動:

  • 使用了 exponential back-off 演算法來處理 leader re-election 問題。

  • 當一個新的 leader 跟 follower 進行互動的時候,Raft 會嘗試先找到這兩個節點的 log 分叉點,然後 leader 再從這個點去傳送 log。Kudu 直接是通過 committedIndex 這個點來傳送。

對於 membership change,Kudu 採用的是 one-by-one 演算法,也就是每次只對一個節點進行變更。這個演算法的好處是不像 joint consensus 那樣複雜,容易實現,但其實還是會有一些在極端情況下面的 corner case 問題。

當新增一個新的節點之後,Kudu 首先要走一個 remote bootstrap 流程。

  1. 將新的節點加入到 Raft 的 configuration 裡面

  2. Leader 傳送 StartEmoteBootstrap RPC,新的 follower 開始拉去 snapshot 和之後的 log

  3. Follower 接受完所有資料並 apply 成功之後,開始響應 Raft RPC

可以看到,這個流程跟 TiKV 的做法類似,這個其實有一個缺陷的。假設我們有三個節點,加入第四個之後,如果新的節點還沒 apply 完 snapshot,這時候掛掉了一個節點,那麼整個叢集其實是沒法工作的。

為了解決這個問題,Kudu 引入了 PRR_VOTER 概念。當新的節點加入的時候,它是 PRE_VOTE 狀態,這個節點不會參與到 Raft Vote 裡面,只有當這個節點接受成功 snapshot 之後,才會變成 VOTER

當刪除一個節點的時候,Leader 直接提交一個新的 configuration,刪除這個節點,當這個 log 被 committed 之後,這個節點就把刪除了。被刪除的節點有可能不知道自己已經被刪除了,如果它長時間沒有收到其他的節點發過來的訊息,就會問下 Master 自己還在不在,如果不在了,就自己幹掉自己。這個做法跟 TiKV 也是類似的。

Master

Kudu 的 Master 是整個叢集最核心的東西,類似於 TiKV 裡面的 PD。在分散式系統裡面,一些系統採用了無中心化的架構設計方案,但我個人覺得,有一箇中心化的單點,能更好的用全域性視角來控制和排程整個系統,而且實現起來很簡單。

在 Kudu 裡面,Master 自己也是一個單一的 Tablet table,只是對使用者不可見。它儲存了整個叢集的元資訊,並且為了效能,會將其全部快取到記憶體上面。因為對於叢集來說,元資訊的量其實並不大,所以在很長一段時間,Master 都不會有 scale 的風險。同時 Master 也是採用 Raft 機制複製,來保證單點問題。

這個設計其實跟 PD 是一樣的,PD 也將所有的元資訊放到記憶體。同時,PD 內部整合 etcd,來保證整個系統的可用性。跟 Kudu Master 不一樣的地方在於,PD 是一個獨立的元件,而 Kudu 的 Master 其實還是整合在 Kudu 叢集裡面的。

Kudu 的 Master 主要負責以下幾個事情:

Catalog manager

Master 的 catalog table 會管理所有 table 的一些元資訊,譬如當前 table schema 的版本,table 的 state(creating,running,deleting 等),以及這個 table 在哪些 Tables 上面。

當使用者要建立一個 table 的時候,首先 Master 在 catalog table 上面寫入需要建立 table 的記錄,table 的 state 為 CREATING。然後非同步的去選擇 Tablet servers 去建立相關的元資訊。如果中間 Master 掛掉了,table 記錄裡面的 CREATING state 會表明這個 table 還在建立中,新的 Master leader 會繼續這個流程。

Cluster coordinator

當 Tablet server 啟動之後,會給 Master 註冊,並且持續的給 Master 進行心跳彙報消後續的狀態變化。

雖然 Master 是整個系統的中心,但它其實是一個觀察者,它的很多資訊都需要依賴 Tablet server 的上報,因為只有 Tablet server 自己知道當前自己有哪一些 tablet 在進行 Raft 複製,Raft 的操作是否執行成功,當前 tablet 的版本等。因為 Tablet 的狀態變更依賴 Raft,每一次變更其實就在 Raft log 上面有一個對應的 index,所以上報給 Master 的訊息一定是冪等的,因為 Master 自己會比較 tablet 上報的 log index 跟當前自己儲存的 index,如果上報的 log index 是舊的,那麼會直接丟棄。

這個設計的好處在於極大的簡化了整個系統的設計,如果要 Master 自己去負責管理整個叢集的狀態變更,譬如 Master 給一個 tablet 傳送增加副本的命令,然後等待這個操作完成,在繼續處理後面的流程。整個系統光異常處理,都會變得特別複雜,譬如我們需要關注網路是不是斷開了,超時了到底是成功了還是失敗了,要不要再去 tablet 上面查一下?

相反,如果 Master 只是給 tablet 傳送一個新增副本的命令,然後不管了,剩下的事情就是一段時間後讓 tablet 自己上報回來,如果成功了繼續後面的處理,不成功則嘗試在加一次。雖然依賴 tablet 的上報會有延遲(通常情況,只要有變動,tablet 會及時的上報通知,所以這個延遲其實挺小的),整個架構簡單了很多。

其實看到這裡的時候,我覺得非常的熟悉,因為我們也是採用的這一套架構方案。最開始設計 PD 的時候,我們還設想的是 PD 主動去控制 TiKV,也就是我上面說的那套複雜的發命令流程。但後來發現實在是太複雜了,於是改成 TiKV 主動上報,這樣 PD 其實就是一個無狀態的服務了,無狀態的服務好處就是如果掛了,新啟動的 PD 能立刻恢復(當然,實際還是要做一些很多優化工作的)。

Tablet directory

因為 Master 知道叢集所有的資訊,所以當 client 需要讀寫資料的時候,它一定要先跟 Master 問一下對應的資料在哪一個 Tablet server 的 tablet 上面,然後才能傳送對應的命令。

如果每次操作都從 Master 獲取資訊,那麼 Master 鐵定會成為一個效能瓶頸,鑑於 tablet 的變更不是特別的頻繁,所以很多時候,client 會快取訪問的 tablet 資訊,這樣下次再訪問的時候就不用從 Master 再次獲取。

因為 tablet 也可能會變化,譬如 leader 跑到了另一個 server 上面,或者 tablet 已經不在當前 server 上面,client 會收到相關的錯誤,這時候,client 就重新再去 Master 獲取一下最新的路由資訊。

這個跟我們的做法仍然是一樣的,client 快取最近的路由資訊,當路由失效的時候,重新去 PD 獲取一下。當然,如果只是單純的 leader 變更,其實返回的錯誤裡面通常就會帶上新的 leader 資訊,這時候 client 直接重新整理快取,在直接訪問了。

Tablet storage

Tablet server 是 Kudu 用來存放實際資料的服務,為了更好的效能,Kudu 自己實現了一套 tablet storage,而沒有用現有的開源解決方案。Tablet storage 目標主要包括:

  • 快速的按照 Column 掃描資料

  • 低延遲的隨機更新

  • 一致的效能

RowSets

Tablets 在 Kudu 裡面被切分成更小的單元,叫做 RowSets。一些 RowSets 只存在於記憶體,叫做 MemRowSets,而另一些則是使用 disk 和 memory 共享存放,叫做 DiskRowSets。任何一行資料只存在一個 RowSets 裡面。

在任何時候,一個 tablet 僅有一個單獨的 MemRowSet 用來儲存最近插入的資料。後臺有一個執行緒會定期的將 這些 MemRowSets 刷到 disk 上面。

當一個 MemRowSet 被刷到 disk 之後,一個新的空的 MemRowSet 被建立出來。之前的 MemRowSet 在刷到 disk 之後,就變成了 DiskRowSet。當刷的同時,如果有新的寫入,仍然會寫到這個正在刷的 MemRowSet 上面,Kudu 有一套機制能夠保證新寫入的資料也能一起被刷到 disk 上面。

MemRowSet

MemRowSet 是一個支援併發,提供鎖優化的 B-tree,主要基於 MassTree,也有一些不同:

  1. 因為 Kudu 使用的是 MVCC,所以任何的刪除其實也是插入,所以這個 tree 沒有刪除操作。

  2. 不支援任意的 in-place 資料變更操作,除非這次操作不會改變 value 的大小。

  3. 將 Leaf link 起來,類似 B+-tree,這樣對於 scan 會有明顯的效能提升。

  4. 並沒有完全實現 trie of trees,是隻是使用了一個單一 tree,因為 Kudu 並沒有太多高頻隨機訪問的場景。

DiskRowSet

當 MemRowSets 被刷到 disk 之後,就變成了 DiskRowSets。當 MemRowSets 被刷到 disk 的時候,Kudu 發現超過 32 MB 了就滾動一個新的 DiskRowSet。因為 MemRowSet 是順序的,所以 DiskRowSets 也是順序的,各滾動的 DiskRowSet 裡面的 primary keys 都是不相交的。

一個 DiskRowSet 包含 base data 和 delta data。Base data 按照 column 組織,也就是通常我們說的列存。各個 column 會被獨立的寫到 disk 裡面一段連續的 block 上面,資料會被切分成多個 page,使用一個 B-tree 進行高效索引。

除了刷使用者自定義的 column,Kudu 還預設將 primary key index 寫到一個 column,同時使用 Bloom filter 來保證能快速通過找到 primary key。

為了簡單,當 column 的資料刷到 disk,它就是預設 immutable 的了,但在刷的過程中,有可能有更新的資料,Kudu 將這些資料放到一個 delta stores 上面。Delta stores 可能在記憶體 DeltaMemStores,或者 disk DeltaFiles。

Delta store 維護的一個 map,key 是 (row_offset, timestamp),value 就是 RowChangeList 記錄。Row offset 就是 row 在 RowSet 裡面的索引,譬如,有最小 primary key 的 row 在 RowSet 裡面是排在最前面的,它的 offset 就是 0。Timestamp 就是通常的 MVCC timestamp。

當需要給 DiskRowSet 更新資料的時候,Kudu 首先通過 primary key 找到對應的 row。通過 B-tree 索引,能知道哪一個 page 包含了這個 row,在 page 裡面,可以計算 row 在整個 DiskRowSet 的 offset,然後就把這個 offset 插入到 DeltaMemStore 裡面。

當 DeltaMemStore 超過了一個閥值,一個新的 DeltaMemStore 就會生成,原先的就會被刷到 disk,變成 immutable DeltaFile。

每個 DiskRowSet 都有一個 Bloom filter,便於快速的定位一個 key 是否存在於該DiskRowSet 裡面。DIskRowSet 還儲存了最小和最大的 primary key,這樣外面就能通過 key 落在哪一個 key range 裡面,快速的定位到這個 key 屬於哪一個 DiskRowSet。

Compaction

當做查詢操作的時候,Kudu 也會從 DeltaStore 上面讀取資料,所以如果 DeltaStore 太多,整個讀效能會急劇下降。為了解決這個問題,Kudu 在後臺會定期的將 delta data 做 compaction,merge 到 base data 裡面。

同時,Kudu 還會定期的將一些 DIskRowSets 做 compaction,生成新的 DiskRowSets,對 RowSet 做 compaction 能直接去掉 deleted rows,同時也能減少重疊的 DiskRowSets,加速讀操作。

總結

上面對 Kudu 大概進行了介紹,主要還是參考 Kudu 自己的論文。Kudu 在設計上面跟 TiKV 非常類似,所以對於很多設計,我是特別能理解為啥要這麼做的,譬如 Master 的資訊是通過 tablet 上報這種的。Kudu 對 Raft 在實現上面做了一些優化,以及在資料 partition 上面也有不錯的做法,這些都是後面能借鑑的。

對於 Tablet Storage,雖然 Kudu 是自己實現的,但我發現,很多方面其實跟 RocksDB 差不了多少,類似 LSM 架構,只是可能這套系統專門為 Kudu 做了定製優化,而不像 RocksDB 那樣具有普適性。對於 storage 來說,現在我們還是考慮使用 RocksDB。

另外,Kudu 採用的是列存,也就是每個列的資料單獨聚合存放到一起,而 TiDB 這邊還是主要使用的行存,也就是儲存整行資料。列存對於 OLAP 非常友好,但在刷盤的時候壓力可能會比較大,如果一個 table 有很多 column,寫入效能可會有點影響。行存則是對於 OLTP 比較友好,但在讀取的時候會將整行資料全讀出來,在一些分析場景下壓力會有點大。但無論列存還是行存,都是為滿足不同的業務場景而服務的,TiDB 後續其實可以考慮的是行列混存,這樣就能適配不同的場景了,只是這個目標比較遠大,希望感興趣的同學一起加入來實現。

相關文章