前言
近兩年,KUDU 在大數據平臺的應用越來越廣泛。在阿里、小米、網易等公司的大數據架構中,KUDU 都有著不可替代的地位。本文通過分析 KUDU 的設計, 試圖解釋為什么 KUDU 會被廣泛應用于大數據領域,因為還沒有研究過 KUDU 的代碼,下面的介紹是根據 KUDU 的論文和網上的一些資料學習自己理解所得,如有不實之處,勞請指正。
背景
在 KUDU 之前,大數據主要以兩種方式存儲:
- 靜態數據:以 HDFS 引擎作為存儲引擎,適用于高吞吐量的離線大數據分析場景。這類存儲的局限性是數據無法進行隨機的讀寫。
- 動態數據:以 HBase、Cassandra 作為存儲引擎,適用于大數據隨機讀寫場景。這類存儲的局限性是批量讀取吞吐量遠不如 HDFS,不適用于批量數據分析的場景。
從上面分析可知,這兩種數據在存儲方式上完全不同,進而導致使用場景完全不同,但在真實的場景中,邊界可能沒有那么清晰,面對既需要隨機讀寫,又需要批量分析的大數據場景,該如何選擇呢?這個場景中,單種存儲引擎無法滿足業務需求,我們需要通過多種大數據工具組合來滿足這一需求,一個常見的方案是:
如上圖所示,數據實時寫入 HBase,實時的數據更新也在 HBase 完成,為了應對 OLAP 需求,我們定時(通常是 T+1 或者 T+H)將 HBase 數據寫成靜態的文件(如:Parquet)導入到 OLAP 引擎(如:HDFS)。這一架構能滿足既需要隨機讀寫,又可以支持 OLAP 分析的場景,但他有如下缺點:
- 架構復雜。從架構上看,數據在 HBase、消息隊列、HDFS 間流轉,涉及環節太多,運維成本很高。并且每個環節需要保證高可用,都需要維護多個副本,存儲空間也有一定的浪費。最后數據在多個系統上,對數據安全策略、監控等都提出了挑戰。
- 時效性低。數據從 HBase 導出成靜態文件是周期性的,一般這個周期是一天(或一小時),在時效性上不是很高。
- 難以應對后續的更新。真實場景中,總會有數據是「延遲」到達的。如果這些數據之前已經從 HBase 導出到 HDFS,新到的變更數據就難以處理了,一個方案是把原有數據應用上新的變更后重寫一遍,但這代價又很高。
為了解決上述架構的這些問題,KUDU 應運而生。KUDU 的定位是 「Fast Analytics on Fast Data」,是一個既支持隨機讀寫、又支持 OLAP 分析的大數據存儲引擎。
從上圖可以看出,KUDU 是一個「折中」的產品,在 HDFS 和 HBase 這兩個偏科生中平衡了隨機讀寫和批量分析的性能。從 KUDU 的誕生可以說明一個觀點:底層的技術發展很多時候都是上層的業務推動的,脫離業務的技術很可能是「空中樓閣」。
概覽
數據模型
KUDU 的數據模型與傳統的關系型數據庫類似,一個 KUDU 集群由多個表組成,每個表由多個字段組成,一個表必須指定一個由若干個(>=1)字段組成的主鍵,如下圖:
KUDU 表中的每個字段是強類型的,而不是 HBase 那樣所有字段都認為是 bytes。這樣做的好處是可以對不同類型數據進行不同的編碼,節省空間。同時,因為 KUDU 的使用場景是 OLAP 分析,有一個數據類型對下游的分析工具也更加友好。
核心 API
KUDU 的對外 API 主要分為寫跟讀兩部分。其中寫包括:Insert、Update、Delete,所有寫操作都必須指定主鍵;讀 KUDU 對外只提供了 Scan 操作,Scan 時用戶可以指定一個或多個過濾器,用于過濾數據。
一致性模型
跟大多數關系型數據庫一樣,KUDU 也是通過 MVCC(Multi-Version Concurrency Control)來實現內部的事務隔離。KUDU 默認的一致性模型是 Snapshot Consistency,即客戶端可以一致的訪問到某個時間點的一個快照。
如果有更高的外部一致性(external consistency)需求,KUDU 目前還沒有實現,不過 KUDU 提供了一些設計方案。這里先介紹下外部一致性,它是指:多個事務并發執行達到串行效果,并且保證修改時間戳嚴格按照事務發生先后順序,即如果有先后兩個事務 A、B, A 發生在 B 之前,那么對于客戶端來說,要么看到 A,要么看到 A、B,不會只看到 B 而看不到 A。KUDU 提供了兩個實現外部一致性的方案:
- 方案一:在各個 Client 之間傳播帶有時間戳的 token,大致思路是 Client 提交完一個寫請求后,生成一個帶時間戳的 token,然后把這個 token 傳播給其他客戶端,其他客戶端請求的時候可以帶上這個 token。
- 方案二:類似 Google Spanner 的方案,通過 commit-wait 機制實現外部一致性。
這里我們衍生介紹下 Google Spanner 是如何實現分布式事務的外部一致性的。首先我們先明確下分布式事務外部一致性這個問題的由來。首先,在數據庫中,我們出于性能考慮,一般我們對讀不加排他鎖,只對寫進行加排他鎖,這就會帶來一個問題,數據在讀取的時候可能正在被修改,導致同一事務中多次讀取到的數據可能不一致,為了解決這個問題,我們引入了 MVCC。在單機系統中,通過 MVCC 就能解決外部一致性問題,因為每個事務都有一個在本機生成的一個時間戳,根據事務的時間戳先后,我們就能判斷出事務發生的先后順序。但是在分布式系統中,要實現外部一致性就沒有那么簡單了,核心問題是事務在不同的機器上執行,而不同機器的本地時鐘是有誤差的,因此就算是真實發生的事務順序是 A->B,但是在事務持久化的時候記錄的時間戳可能是 B < A,這時如果一個事務 C 來讀取數據,可能只讀到 B 而沒有讀到 A。從上面的分析我們可以發現,分布式系統中保證事務的外部一致性的核心是一個精確的事務版本(時間戳),而最大的難點也在這里,計算機上的時鐘不是一個絕對精確的時間,它跟標準時間是有一定的隨機的誤差的,導致分布式系統中不同機器之間的時間有偏差。Google Spanner 的解決思路是把不同機器的誤差時間控制在一個很小的確定的范圍內,再配合 commit-wait 機制來實現外部一致性。
控制時間誤差的方案稱為 TrueTime,它通過硬件(GPS 和原子鐘)和軟件結合,保證獲取到的時間在較小誤差(±4ms)內絕對正確,具體的實現這里就不展開了,有興趣的同學可以自行找資料研究。TrueTime 對外只提供 3 個 API,如下:
這里最主要的 API 是 TT.now(),它范圍當前絕對精確時間的上下界,表示當前絕對精確時間在 TT.now().earliest 和 TT.now().latest 之間。
有了一個有界誤差的 TrueTime 后,就可以通過 commit-wait 機制來實現外部一致性了,具體的方案如下:
如上圖所示,在一個事務開始獲取鎖執行后,生成事務的時間版本 s=TT.now().latest,然后開始執行事務的具體操作,但是一個事務的結束并不只由事務本身的時間消耗決定,它還要保證后續的事務時間版本不會早于自己,因此,事務需要等待直到 TT.now().earliest > s 后,才算真正結束。根據整個 commit-wait 過程我們可以知道,整個事務提交過程需要等待 2 倍的平均誤差時間(ε),TrueTime 的平均誤差時間是 4 ms,因此一次 commit-wait 需要至少 8 ms。
之前我們提到,KUDU 也借鑒 Spanner 使用 commit-wait 機制實現外部一致性,但是 commit-wait 強依賴于 TrueTime,而 TrueTime 需要各種昂貴的硬件設備支持,目前 KUDU 通過純軟件算法的方式來實現時鐘算法,為 HybridTime,但這個方案時間誤差較大,考慮到 commit-wait 需要等待 2ε 時間,因此誤差一大實際場景使用限制就很多了。
架構
整體架構
KUDU 中存在兩個角色
- Mater Server:負責集群管理、元數據管理等功能
- Tablet Server:負責數據存儲,并提供數據讀寫服務
為了實現分區容錯性,跟其他大數據產品一樣,對于每個角色,在 KUDU 中都可以設置特定數量(一般是 3 或 5)的副本。各副本間通過 Raft 協議來保證數據一致性。Raft 協議與 ZAB 類似,都是 Paxos 協議的工程簡化版本,具體細節有興趣的同學可以搜索相關資料學習。
KUDU Client 在與服務端交互時,先從 Master Server 獲取元數據信息,然后去 Tablet Server 讀寫數據,如下圖:
數據分區策略
與大多數大數據存儲引擎類似,KUDU 對表進行橫向分區,KUDU 表會被橫向切分存儲在多個 tablets 中。不過相比與其他存儲引擎,KUDU 提供了更加豐富靈活的數據分區策略。
一般數據分區策略主要有兩種,一種是 Range Partitioning,按照字段值范圍進行分區,HBase 就采用了這種方式,如下圖:
Range Partitioning 的優勢是在數據進行批量讀的時候,可以把大部分的讀變成同一個 tablet 中的順序讀,能夠提升數據讀取的吞吐量。并且按照范圍進行分區,我們可以很方便的進行分區擴展。其劣勢是同一個范圍內的數據寫入都會落在單個 tablet 上,寫的壓力大,速度慢。
另一種分區策略是 Hash Partitioning,按照字段的 Hash 值進行分區,Cassandra 采用了這個方式,見下圖:
與 Range Partitioning 相反,由于是 Hash 分區,數據的寫入會被均勻的分散到各個 tablet 中,寫入速度快。但是對于順序讀的場景這一策略就不太適用了,因為數據分散,一次順序讀需要將各個 tablet 中的數據分別讀取并組合,吞吐量低。并且 Hash 分區無法應對分區擴展的情況。
各種分區策略的優劣對比見下圖:
既然各分區策略各有優劣,能否將不同分區策略進行組合,取長補短呢?這也是 KUDU 的思路,KUDU 支持用戶對一個表指定一個范圍分區規則和多個 Hash 分區規則,如下圖:
存儲
存儲設計目標
- 快速的列掃描
- 低延遲的隨機更新
- 穩定的性能表現
存儲方式
KUDU 是一個列式存儲的存儲引擎,其數據存儲方式如下:
列式存儲的數據庫很適合于 OLAP 場景,其特點如下:
-
優勢
- 查詢少量列時 IO 少,速度快
- 數據壓縮比高
- 便于查詢引擎性能優化:延遲物化、直接操作壓縮數據、向量化執行
-
劣勢
- 查詢列太多時性能下降(KUDU 建議列數不超過 300 )
- 不適合 OLTP 場景
存儲實現
與其他大數據存儲引擎類似,KUDU 的存儲也是通過 LSM 樹(Log-Structured Merge Tree)來實現的。KUDU 的最小存儲單元是 RowSets,KUDU 中存在兩種 RowSets:MemRowSets、DiskRowSets,數據先寫內存中的 MemRowSet,MemRowSet 滿了后刷到磁盤成為一個 DiskRowSet,DiskRowSet 一經寫入,就無法修改了。見下圖:
當然上面只是最粗粒度的一個寫入過程,為了解釋 KUDU 的為什么既能支持隨機讀寫,又能支持大數據量的 OLAP 分析,我們需要更進一步進行解剖分析。我們需求探究的主要兩個問題是:
- 如何應對數據變更?
- 如何優化讀寫性能以滿足 OLAP 場景?
應對數據變更
首先上面我們講了,DiskRowSet 是不可修改了,那么 KUDU 要如何應對數據的更新呢?在 KUDU 中,把 DiskRowSet 分為了兩部分:base data、delta stores。base data 負責存儲基礎數據,delta stores負責存儲 base data 中的變更數據。整個數據更新方案如下:
如上圖所示,數據從 MemRowSet 刷到磁盤后就形成了一份 DiskRowSet(只包含 base data),每份 DiskRowSet 在內存中都會有一個對應的 DeltaMemStore,負責記錄此 DiskRowSet 后續的數據變更(更新、刪除)。DeltaMemStore 內部維護一個 B-樹索引,映射到每個 row_offset 對應的數據變更。DeltaMemStore 數據增長到一定程度后轉化成二進制文件存儲到磁盤,形成一個 DeltaFile,隨著 base data 對應數據的不斷變更,DeltaFile 逐漸增長。
優化讀寫性能
首先我們從 KUDU 的 DiskRowSet 數據結構上分析:
從上圖可知,在具體的數據(列數據、變更記錄)上,KUDU 都做了 B- 樹索引,以提高隨機讀寫的性能。在 base data 中,KUDU 還針對主鍵做了好幾類索引(實際上由于 delta store 只記錄變更數據,base data 中對主鍵的索引即本 DiskRowSet 中全局的主鍵索引):
- 主鍵范圍索引:記錄本 DiskRowSet 中主鍵的范圍,用于粗粒度過濾一些主鍵范圍
- 布隆過濾器:通過主鍵的布隆過濾器來實現不存在數據的過濾
- 主鍵索引:要精確定位一個主鍵是否存在,以及具體在 DiskRowSet 中的位置(即:row_offset),通過以 B-樹為數據結構的主鍵索引來快速查找。
隨著時間的推移,KUDU 中的小文件會越來越多,主要包括各個 DiskRowSet 中的 base data,還有每個 base data 對應的若干份 DeltaFile。小文件的增多會影響 KUDU 的性能,特別是 DeltaFile 中還有很多重復的數據。為了提高性能,KUDU 會進行定期 compaction,compaction 主要包括兩部分:
- DeltaFile compaction:過多的 DeltaFile 影響讀性能,定期將 DeltaFile 合并回 base data 可以提升性能。在通常情況下,會發生頻繁變更的字段是集中在少數幾個字段中的,而 KUDU 是列式存儲的,因此 KUDU 還在 DeltaFile compaction 時做了優化,文件合并時只合并部分變更列到 base data 中對應的列。
- DiskRowSet compaction:除了 DeltaFile,定期將 DiskRowSet 合并也能提升性能,一個原因是合并時我們可以將被刪除的數據徹底的刪除,而且可以減少同樣 key 范圍內數據的文件數,提升索引的效率。
當用戶的查詢存在列的過濾條件時,KUDU 還可以在查詢時進行 延遲物化(Lazy Materialization )來提升性能。舉例說明,現在我們有這樣一張表:
用戶的 SQL 是這樣的:
SELECT * FROM tb WHERE sex=‘男’ ADN age > 20
KUDU 中數據查詢過程是這樣的:
- 掃描 sex 列,過濾出要查詢的行 [1,3]
- 掃描 age 列,過濾出要查詢的行 [3,4]
- 過濾條件相交,得到 3
- 真正讀取 id=3 行對應的所有列信息,組裝
上述查詢中,KUDU 真正需要去物理讀取的數據只有 id=3 這一行,這樣就減少了 IO 數量。
讀寫過程
數據寫過程
如上圖,當 Client 請求寫數據時,先根據主鍵從 Mater Server 中獲取要訪問的目標 Tablets,然后到依次對應的 Tablet 獲取數據。因為 KUDU 表存在主鍵約束,所以需要進行主鍵是否已經存在的判斷,這里就涉及到之前說的索引結構對讀寫的優化了。一個 Tablet 中存在很多個 RowSets,為了提升性能,我們要盡可能地減少要掃描的 RowSets 數量。首先,我們先通過每個 RowSet 中記錄的主鍵的(最大最小)范圍,過濾掉一批不存在目標主鍵的 RowSets,然后在根據 RowSet 中的布隆過濾器,過濾掉確定不存在目標主鍵的 RowSets,最后再通過 RowSets 中的 B-樹索引,精確定位目標主鍵是否存在。如果主鍵已經存在,則報錯(主鍵重復),否則就進行寫數據(寫 MemRowSet)。
數據更新過程
數據更新的核心是定位到待更新數據的位置,這塊與寫入的時候類似,就不展開了,等定位到具體位置后,然后將變更寫到對應的 delta store 中。
數據讀過程
如上圖,數據讀取過程大致如下:先根據要掃描數據的主鍵范圍,定位到目標的
Tablets,然后讀取 Tablets 中的 RowSets。在讀取每個 RowSet 時,先根據主鍵過濾要 scan 范圍,然后加載范圍內的 base data,再找到對應的 delta stores,應用所有變更,最后 union 上 MenRowSet 中的內容,返回數據給 Client。
應用案例
這里介紹一個小米使用 KUDU 的案例。具體的業務場景是這樣的:
收集手機App和后臺服務發送的 RPC 跟蹤事件數據,然后構建一個服務監控和問題診斷的工具。
- 高寫入吞吐:每天大于200億條記錄
- 為了能夠盡快定位和解決問題,要求系統能夠查詢最新的數據并能快速返回結果
- 為了方便問題診斷,要求系統能夠查詢/搜索明細數據(而不只是統計信息)
在使用 KUDU 前,小米的架構是這樣的:
一部分源系統數據是通過Scribe(日志聚合系統)把數據寫到HDFS,另一部分源系統數據直接寫入HBase。然后通過Hive/MR/Spark作業把兩部分數據合并,給離線數倉和 OLAP 分析。
在使用 KUDU 后,架構簡化成了:
從上圖我們可以看到,所有的數據存儲都集中到的 KUDU 一個上,減少了整體的架構復雜度,同時,也大大提升了實時性。