作者:和君
引言
ClickHouse是近年來備受關注的開源列式資料庫,主要用于資料分析(OLAP)領域。目前國内各個大廠紛紛跟進大規模使用:
- 今日頭條内部用ClickHouse來做使用者行為分析,内部一共幾千個ClickHouse節點,單叢集最大1200節點,總資料量幾十PB,日增原始資料300TB左右。
- 騰訊内部用ClickHouse做遊戲資料分析,并且為之建立了一整套監控運維體系。
- 攜程内部從18年7月份開始接入試用,目前80%的業務都跑在ClickHouse上。每天資料增量十多億,近百萬次查詢請求。
- 快手内部也在使用ClickHouse,存儲總量大約10PB, 每天新增200TB, 90%查詢小于3S。
- 阿裡内部專門孵化了相應的雲資料庫ClickHouse,并且在包括手機淘寶流量分析在内的衆多業務被廣泛使用。
在國外,Yandex内部有數百節點用于做使用者點選行為分析,CloudFlare、Spotify等頭部公司也在使用。
在開源的短短幾年時間内,ClickHouse就俘獲了諸多大廠的“芳心”,并且在Github上的活躍度超越了衆多老牌的經典開源項目,如Presto、Druid、Impala、Geenplum等;其受歡迎程度和社群火熱程度可見一斑。
而這些現象背後的重要原因之一就是它的極緻性能,極大地加速了業務開發速度,本文嘗試解讀ClickHouse存儲層的設計與實作,剖析它的性能奧妙。
ClickHouse的元件架構
下圖是一個典型的ClickHouse叢集部署結構圖,符合經典的share-nothing架構。

整個叢集分為多個shard(分片),不同shard之間資料彼此隔離;在一個shard内部,可配置一個或多個replica(副本),互為副本的2個replica之間通過專有複制協定保持最終一緻性。
ClickHouse根據表引擎将表分為本地表和分布式表,兩種表在建表時都需要在所有節點上分别建立。其中本地表隻負責目前所在server上的寫入、查詢請求;而分布式表則會按照特定規則,将寫入請求和查詢請求進行拆解,分發給所有server,并且最終彙總請求結果。
ClickHouse寫傳入連結路
ClickHouse提供2種寫入方法,1)寫本地表;2)寫分布式表。
寫本地表方式,需要業務層感覺底層所有server的IP,并且自行處理資料的分片操作。由于每個節點都可以分别直接寫入,這種方式使得叢集的整體寫入能力與節點數完全成正比,提供了非常高的吞吐能力和定制靈活性。但是相對而言,也增加了業務層的依賴,引入了更多複雜性,尤其是節點failover容錯處理、擴縮容資料re-balance、寫入和查詢需要分别使用不同表引擎等都要在業務上自行處理。
而寫分布式表則相對簡單,業務層隻需要将資料寫入單一endpoint及單一一張分布式表即可,不需要感覺底層server拓撲結構等實作細節。寫分布式表也有很好的性能表現,在不需要極高寫入吞吐能力的業務場景中,建議直接寫入分布式表降低業務複雜度。
以下闡述分布式表的寫入實作原理。
ClickHouse使用Block作為資料處理的核心抽象,表示在記憶體中的多個列的資料,其中列的資料在記憶體中也采用列存格式進行存儲。示意圖如下:其中header部分包含block相關元資訊,而id UInt8、name String、_date Date則是三個不同類型列的資料表示。
在Block之上,封裝了能夠進行流式IO的stream接口,分别是IBlockInputStream、IBlockOutputStream,接口的不同對應實作不同功能。
當收到INSERT INTO請求時,ClickHouse會構造一個完整的stream pipeline,每一個stream實作相應的邏輯:
InputStreamFromASTInsertQuery #将insert into請求封裝為InputStream作為資料源
-> CountingBlockOutputStream #統計寫入block count
-> SquashingBlockOutputStream #積攢寫入block,直到達到特定記憶體門檻值,提升寫入吞吐
-> AddingDefaultBlockOutputStream #用default值補全缺失列
-> CheckConstraintsBlockOutputStream #檢查各種限制限制是否滿足
-> PushingToViewsBlockOutputStream #如有物化視圖,則将資料寫入到物化視圖中
-> DistributedBlockOutputStream #将block寫入到分布式表中
注:*左右滑動閱覽
在以上過程中,ClickHouse非常注重細節優化,處處為性能考慮。在SQL解析時,ClickHouse并不會一次性将完整的INSERT INTO table(cols) values(rows)解析完畢,而是先讀取insert into table(cols)這些短小的頭部資訊來建構block結構,values部分的大量資料則采用流式解析,降低記憶體開銷。在多個stream之間傳遞block時,實作了copy-on-write機制,盡最大可能減少記憶體拷貝。在記憶體中采用列存存儲結構,為後續在磁盤上直接落盤為列存格式做好準備。
SquashingBlockOutputStream将用戶端的若幹小寫,轉化為大batch,提升寫盤吞吐、降低寫入放大、加速資料Compaction。
預設情況下,分布式表寫入是異步轉發的。DistributedBlockOutputStream将Block按照建表DDL中指定的規則(如hash或random)切分為多個分片,每個分片對應本地的一個子目錄,将對應資料落盤為子目錄下的.bin檔案,寫入完成後就傳回client成功。随後分布式表的背景線程,掃描這些檔案夾并将.bin檔案推送給相應的分片server。.bin檔案的存儲格式示意如下:
ClickHouse存儲格式
ClickHouse采用列存格式作為單機存儲,并且采用了類LSM tree的結構來進行組織與合并。一張MergeTree本地表,從磁盤檔案構成如下圖所示。
本地表的資料被劃分為多個Data PART,每個Data PART對應一個磁盤目錄。Data PART在落盤後,就是immutable的,不再變化。ClickHouse背景會排程MergerThread将多個小的Data PART不斷合并起來,形成更大的Data PART,進而獲得更高的壓縮率、更快的查詢速度。當每次向本地表中進行一次insert請求時,就會産生一個新的Data PART,也即新增一個目錄。如果insert的batch size太小,且insert頻率很高,可能會導緻目錄數過多進而耗盡inode,也會降低背景資料合并的性能,這也是為什麼ClickHouse推薦使用大batch進行寫入且每秒不超過1次的原因。
在Data PART内部存儲着各個列的資料,由于采用了列存格式,是以不同列使用完全獨立的實體檔案。每個列至少有2個檔案構成,分别是.bin 和 .mrk檔案。其中.bin是資料檔案,儲存着實際的data;而.mrk是中繼資料檔案,儲存着資料的metadata。此外,ClickHouse還支援primary index、skip index等索引機制,是以也可能存在着對應的pk.idx,skip_idx.idx檔案。
在資料寫入過程中,資料被按照index_granularity切分為多個顆粒(granularity),預設值為8192行對應一個顆粒。多個顆粒在記憶體buffer中積攢到了一定大小(由參數min_compress_block_size控制,預設64KB),會觸發資料的壓縮、落盤等操作,形成一個block。每個顆粒會對應一個mark,該mark主要存儲着2項資訊:1)目前block在壓縮後的實體檔案中的offset,2)目前granularity在解壓後block中的offset。是以Block是ClickHouse與磁盤進行IO互動、壓縮/解壓縮的最小機關,而granularity是ClickHouse在記憶體中進行資料掃描的最小機關。
如果有ORDER BY key或Primary key,則ClickHouse在Block資料落盤前,會将資料按照ORDER BY key進行排序。主鍵索引pk.idx中存儲着每個mark對應的第一行資料,也即在每個顆粒中各個列的最小值。
當存在其他類型的稀疏索引時,會額外增加一個<col>_<type>.idx檔案,用來記錄對應顆粒的統計資訊。比如:
- minmax會記錄各個顆粒的最小、最大值;
- set會記錄各個顆粒中的distinct值;
- bloomfilter會使用近似算法記錄對應顆粒中,某個值是否存在;
在查找時,如果query包含主鍵索引條件,則首先在pk.idx中進行二分查找,找到符合條件的顆粒mark,并從mark檔案中擷取block offset、granularity offset等中繼資料資訊,進而将資料從磁盤讀入記憶體進行查找操作。類似的,如果條件命中skip index,則借助于index中的minmax、set等信心,定位出符合條件的顆粒mark,進而執行IO操作。借助于mark檔案,ClickHouse在定位出符合條件的顆粒之後,可以将顆粒平均分派給多個線程進行并行處理,最大化利用磁盤的IO吞吐和CPU的多核處理能力。
總結
本文主要從整體架構、寫傳入連結路、存儲格式等幾個方面介紹了ClickHouse存儲層的設計,ClickHouse巧妙地結合了列式存儲、稀疏索引、多核并行掃描等技術,最大化壓榨硬體能力,在OLAP場景中性能優勢非常明顯。