ClickHouse 是一個真正的列式數(shù)據庫管理系統(tǒng)(DBMS)。在 ClickHouse 中,數(shù)據始終是按列存儲的,包括矢量(向量或列塊)執(zhí)行的過程。只要有可能,操作都是基于矢量進行分派的,而不是單個的值,這被稱為?矢量化查詢執(zhí)行?,它有利于降低實際的數(shù)據處理開銷。
這個想法并不新鮮,其可以追溯到?APL?編程語言及其后代:A +、J、K?和?Q。矢量編程被大量用于科學數(shù)據處理中。即使在關系型數(shù)據庫中,這個想法也不是什么新的東西:比如,矢量編程也被大量用于?Vectorwise?系統(tǒng)中。
通常有兩種不同的加速查詢處理的方法:矢量化查詢執(zhí)行和運行時代碼生成。在后者中,動態(tài)地為每一類查詢生成代碼,消除了間接分派和動態(tài)分派。這兩種方法中,并沒有哪一種嚴格地比另一種好。運行時代碼生成可以更好地將多個操作融合在一起,從而充分利用 CPU 執(zhí)行單元和流水線。矢量化查詢執(zhí)行不是特別實用,因為它涉及必須寫到緩存并讀回的臨時向量。如果 L2 緩存容納不下臨時數(shù)據,那么這將成為一個問題。但矢量化查詢執(zhí)行更容易利用 CPU 的 SIMD 功能。朋友寫的一篇研究論文表明,將兩種方法結合起來是更好的選擇。ClickHouse 使用了矢量化查詢執(zhí)行,同時初步提供了有限的運行時動態(tài)代碼生成。更多好文章,關注“數(shù)據中臺研習社”公眾號。
列(Columns)
要表示內存中的列(實際上是列塊),需使用?IColumn?接口。該接口提供了用于實現(xiàn)各種關系操作符的輔助方法。幾乎所有的操作都是不可變的:這些操作不會更改原始列,但是會創(chuàng)建一個新的修改后的列。比如,IColumn::filter?方法接受過濾字節(jié)掩碼,用于?WHERE?和?HAVING?關系操作符中。另外的例子:IColumn::permute?方法支持?ORDER BY?實現(xiàn),IColumn::cut?方法支持?LIMIT?實現(xiàn)等等。
不同的?IColumn?實現(xiàn)(ColumnUInt8、ColumnString?等)負責不同的列內存布局。內存布局通常是一個連續(xù)的數(shù)組。對于數(shù)據類型為整型的列,只是一個連續(xù)的數(shù)組,比如?std::vector。對于?String?列和?Array?列,則由兩個向量組成:其中一個向量連續(xù)存儲所有的?String?或數(shù)組元素,另一個存儲每一個?String?或?Array?的起始元素在第一個向量中的偏移。而?ColumnConst?則僅在內存中存儲一個值,但是看起來像一個列。
字段
盡管如此,有時候也可能需要處理單個值。表示單個值,可以使用?Field。Field?是?UInt64、Int64、Float64、String?和?Array?組成的聯(lián)合。IColumn?擁有?operator[]?方法來獲取第?n?個值成為一個?Field,同時也擁有?insert?方法將一個?Field?追加到一個列的末尾。這些方法并不高效,因為它們需要處理表示單一值的臨時?Field?對象,但是有更高效的方法比如?insertFrom?和?insertRangeFrom?等。
Field?中并沒有足夠的關于一個表(table)的特定數(shù)據類型的信息。比如,UInt8、UInt16、UInt32?和?UInt64?在?Field?中均表示為?UInt64。
抽象漏洞
IColumn?具有用于數(shù)據的常見關系轉換的方法,但這些方法并不能夠滿足所有需求。比如,ColumnUInt64?沒有用于計算兩列和的方法,ColumnString?沒有用于進行子串搜索的方法。這些無法計算的例程在?Icolumn?之外實現(xiàn)。
列(Columns)上的各種函數(shù)可以通過使用?Icolumn?的方法來提取?Field?值,或根據特定的?Icolumn?實現(xiàn)的數(shù)據內存布局的知識,以一種通用但不高效的方式實現(xiàn)。為此,函數(shù)將會轉換為特定的?IColumn?類型并直接處理內部表示。比如,ColumnUInt64?具有?getData?方法,該方法返回一個指向列的內部數(shù)組的引用,然后一個單獨的例程可以直接讀寫或填充該數(shù)組。實際上,?抽象漏洞(leaky abstractions)?允許我們以更高效的方式來實現(xiàn)各種特定的例程。更多好文章,關注“數(shù)據中臺研習社”公眾號。
數(shù)據類型
IDataType?負責序列化和反序列化:讀寫二進制或文本形式的列或單個值構成的塊。IDataType?直接與表的數(shù)據類型相對應。比如,有?DataTypeUInt32、DataTypeDateTime、DataTypeString?等數(shù)據類型。
IDataType?與?IColumn?之間的關聯(lián)并不大。不同的數(shù)據類型在內存中能夠用相同的?IColumn?實現(xiàn)來表示。比如,DataTypeUInt32?和?DataTypeDateTime?都是用?ColumnUInt32?或?ColumnConstUInt32?來表示的。另外,相同的數(shù)據類型也可以用不同的?IColumn?實現(xiàn)來表示。比如,DataTypeUInt8?既可以使用?ColumnUInt8?來表示,也可以使用過?ColumnConstUInt8?來表示。
IDataType?僅存儲元數(shù)據。比如,DataTypeUInt8?不存儲任何東西(除了 vptr);DataTypeFixedString?僅存儲?N(固定長度字符串的串長度)。
IDataType?具有針對各種數(shù)據格式的輔助函數(shù)。比如如下一些輔助函數(shù):序列化一個值并加上可能的引號;序列化一個值用于 JSON 格式;序列化一個值作為 XML 格式的一部分。輔助函數(shù)與數(shù)據格式并沒有直接的對應。比如,兩種不同的數(shù)據格式?Pretty?和?TabSeparated?均可以使用?IDataType?接口提供的?serializeTextEscaped?這一輔助函數(shù)。
塊(Block)
Block?是表示內存中表的子集(chunk)的容器,是由三元組:(IColumn, IDataType, 列名)?構成的集合。在查詢執(zhí)行期間,數(shù)據是按?Block?進行處理的。如果我們有一個?Block,那么就有了數(shù)據(在?IColumn?對象中),有了數(shù)據的類型信息告訴我們如何處理該列,同時也有了列名(來自表的原始列名,或人為指定的用于臨時計算結果的名字)。
當我們遍歷一個塊中的列進行某些函數(shù)計算時,會把結果列加入到塊中,但不會更改函數(shù)參數(shù)中的列,因為操作是不可變的。之后,不需要的列可以從塊中刪除,但不是修改。這對于消除公共子表達式非常方便。
Block?用于處理數(shù)據塊。注意,對于相同類型的計算,列名和類型對不同的塊保持相同,僅列數(shù)據不同。最好把塊數(shù)據(block data)和塊頭(block header)分離開來,因為小塊大小會因復制共享指針和列名而帶來很高的臨時字符串開銷。更多好文章,關注“數(shù)據中臺研習社”公眾號。
塊流(Block Streams)
塊流用于處理數(shù)據。我們可以使用塊流從某個地方讀取數(shù)據,執(zhí)行數(shù)據轉換,或將數(shù)據寫到某個地方。IBlockInputStream?具有?read?方法,其能夠在數(shù)據可用時獲取下一個塊。IBlockOutputStream?具有?write?方法,其能夠將塊寫到某處。
塊流負責:
讀或寫一個表。表僅返回一個流用于讀寫塊。
完成數(shù)據格式化。比如,如果你打算將數(shù)據以?Pretty?格式輸出到終端,你可以創(chuàng)建一個塊輸出流,將塊寫入該流中,然后進行格式化。
執(zhí)行數(shù)據轉換。假設你現(xiàn)在有?IBlockInputStream?并且打算創(chuàng)建一個過濾流,那么你可以創(chuàng)建一個?FilterBlockInputStream?并用?IBlockInputStream?進行初始化。之后,當你從?FilterBlockInputStream?中拉取塊時,會從你的流中提取一個塊,對其進行過濾,然后將過濾后的塊返回給你。查詢執(zhí)行流水線就是以這種方式表示的。
還有一些更復雜的轉換。比如,當你從?AggregatingBlockInputStream?拉取數(shù)據時,會從數(shù)據源讀取全部數(shù)據進行聚集,然后將聚集后的數(shù)據流返回給你。另一個例子:UnionBlockInputStream?的構造函數(shù)接受多個輸入源和多個線程,其能夠啟動多線程從多個輸入源并行讀取數(shù)據。
塊流使用?pull?方法來控制流:當你從第一個流中拉取塊時,它會接著從嵌套的流中拉取所需的塊,然后整個執(zhí)行流水線開始工作。?pull?和?push?都不是最好的方案,因為控制流不是明確的,這限制了各種功能的實現(xiàn),比如多個查詢同步執(zhí)行(多個流水線合并到一起)。這個限制可以通過協(xié)程或直接運行互相等待的線程來解決。如果控制流明確,那么我們會有更多的可能性:如果我們定位了數(shù)據從一個計算單元傳遞到那些外部的計算單元中其中一個計算單元的邏輯。閱讀這篇文章來獲取更多的想法。
我們需要注意,查詢執(zhí)行流水線在每一步都會創(chuàng)建臨時數(shù)據。我們要盡量使塊的大小足夠小,從而 CPU 緩存能夠容納下臨時數(shù)據。在這個假設下,與其他計算相比,讀寫臨時數(shù)據幾乎是沒有任何開銷的。我們也可以考慮一種替代方案:將流水線中的多個操作融合在一起,使流水線盡可能短,并刪除大量臨時數(shù)據。這可能是一個優(yōu)點,但同時也有缺點。比如,拆分流水線使得中間數(shù)據緩存、獲取同時運行的類似查詢的中間數(shù)據以及相似查詢的流水線合并等功能很容易實現(xiàn)。
格式(Formats)
數(shù)據格式同塊流一起實現(xiàn)。既有僅用于向客戶端輸出數(shù)據的?展示?格式,如?IBlockOutputStream?提供的?Pretty?格式,也有其它輸入輸出格式,比如?TabSeparated?或?JSONEachRow。
此外還有行流:IRowInputStream?和?IRowOutputStream。它們允許你按行 pull/push 數(shù)據,而不是按塊。行流只需要簡單地面向行格式實現(xiàn)。包裝器?BlockInputStreamFromRowInputStream?和?BlockOutputStreamFromRowOutputStream?允許你將面向行的流轉換為正常的面向塊的流。更多好文章,關注“數(shù)據中臺研習社”公眾號。
I/O
對于面向字節(jié)的輸入輸出,有?ReadBuffer?和?WriteBuffer?這兩個抽象類。它們用來替代 C++ 的?iostream。不用擔心:每個成熟的 C++ 項目都會有充分的理由使用某些東西來代替?iostream。
ReadBuffer?和?WriteBuffer?由一個連續(xù)的緩沖區(qū)和指向緩沖區(qū)中某個位置的一個指針組成。實現(xiàn)中,緩沖區(qū)可能擁有內存,也可能不擁有內存。有一個虛方法會使用隨后的數(shù)據來填充緩沖區(qū)(針對?ReadBuffer)或刷新緩沖區(qū)(針對?WriteBuffer),該虛方法很少被調用。
ReadBuffer?和?WriteBuffer?的實現(xiàn)用于處理文件、文件描述符和網絡套接字(socket),也用于實現(xiàn)壓縮(CompressedWriteBuffer?在寫入數(shù)據前需要先用一個?WriteBuffer?進行初始化并進行壓縮)和其它用途。ConcatReadBuffer、LimitReadBuffer?和?HashingWriteBuffer?的用途正如其名字所描述的一樣。
ReadBuffer?和?WriteBuffer?僅處理字節(jié)。為了實現(xiàn)格式化輸入和輸出(比如以十進制格式寫一個數(shù)字),ReadHelpers?和?WriteHelpers?頭文件中有一些輔助函數(shù)可用。
讓我們來看一下,當你把一個結果集以?JSON?格式寫到標準輸出(stdout)時會發(fā)生什么。你已經準備好從?IBlockInputStream?獲取結果集,然后創(chuàng)建?WriteBufferFromFileDescriptor(STDOUT_FILENO)?用于寫字節(jié)到標準輸出,創(chuàng)建?JSONRowOutputStream?并用?WriteBuffer?初始化,用于將行以?JSON?格式寫到標準輸出,你還可以在其上創(chuàng)建?BlockOutputStreamFromRowOutputStream,將其表示為?IBlockOutputStream。然后調用?copyData?將數(shù)據從?IBlockInputStream?傳輸?shù)?IBlockOutputStream,一切工作正常。在內部,JSONRowOutputStream?會寫入 JSON 分隔符,并以指向?IColumn?的引用和行數(shù)作為參數(shù)調用?IDataType::serializeTextJSON?函數(shù)。隨后,IDataType::serializeTextJSON?將會調用?WriteHelpers.h?中的一個方法:比如,writeText?用于數(shù)值類型,writeJSONString?用于?DataTypeString?。
表(Tables)
表由?IStorage?接口表示。該接口的不同實現(xiàn)對應不同的表引擎。比如?StorageMergeTree、StorageMemory?等。這些類的實例就是表。
IStorage?中最重要的方法是?read?和?write,除此之外還有?alter、rename?和?drop?等方法。read?方法接受如下參數(shù):需要從表中讀取的列集,需要執(zhí)行的?AST?查詢,以及所需返回的流的數(shù)量。read?方法的返回值是一個或多個?IBlockInputStream?對象,以及在查詢執(zhí)行期間在一個表引擎內完成的關于數(shù)據處理階段的信息。
在大多數(shù)情況下,read?方法僅負責從表中讀取指定的列,而不會進行進一步的數(shù)據處理。進一步的數(shù)據處理均由查詢解釋器完成,不由?IStorage?負責。
但是也有值得注意的例外:
AST 查詢被傳遞給?read?方法,表引擎可以使用它來判斷是否能夠使用索引,從而從表中讀取更少的數(shù)據。
有時候,表引擎能夠將數(shù)據處理到一個特定階段。比如,StorageDistributed?可以向遠程服務器發(fā)送查詢,要求它們將來自不同的遠程服務器能夠合并的數(shù)據處理到某個階段,并返回預處理后的數(shù)據,然后查詢解釋器完成后續(xù)的數(shù)據處理。更多好文章,關注“數(shù)據中臺研習社”公眾號。
表的?read?方法能夠返回多個?IBlockInputStream?對象以允許并行處理數(shù)據。多個塊輸入流能夠從一個表中并行讀取。然后你可以通過不同的轉換對這些流進行裝飾(比如表達式求值或過濾),轉換過程能夠獨立計算,并在其上創(chuàng)建一個?UnionBlockInputStream,以并行讀取多個流。
另外也有?TableFunction。TableFunction?能夠在查詢的?FROM?字句中返回一個臨時的?IStorage?以供使用。
要快速了解如何實現(xiàn)自己的表引擎,可以查看一些簡單的表引擎,比如?StorageMemory?或?StorageTinyLog。
作為?read?方法的結果,IStorage?返回?QueryProcessingStage?- 關于 storage 里哪部分查詢已經被計算的信息。當前我們僅有非常粗粒度的信息。Storage 無法告訴我們?對于這個范圍的數(shù)據,我已經處理完了 WHERE 字句里的這部分表達式?。我們需要在這個地方繼續(xù)努力。
解析器(Parsers)
查詢由一個手寫遞歸下降解析器解析。比如,?ParserSelectQuery?只是針對查詢的不同部分遞歸地調用下層解析器。解析器創(chuàng)建?AST。AST?由節(jié)點表示,節(jié)點是?IAST?的實例。
由于歷史原因,未使用解析器生成器。
解釋器(Interpreters)
解釋器負責從?AST?創(chuàng)建查詢執(zhí)行流水線。既有一些簡單的解釋器,如?InterpreterExistsQuery?和?InterpreterDropQuery,也有更復雜的解釋器,如?InterpreterSelectQuery。查詢執(zhí)行流水線由塊輸入或輸出流組成。比如,SELECT?查詢的解釋結果是從?FROM?字句的結果集中讀取數(shù)據的?IBlockInputStream;INSERT?查詢的結果是寫入需要插入的數(shù)據的?IBlockOutputStream;SELECT INSERT?查詢的解釋結果是?IBlockInputStream,它在第一次讀取時返回一個空結果集,同時將數(shù)據從?SELECT?復制到?INSERT。
InterpreterSelectQuery?使用?ExpressionAnalyzer?和?ExpressionActions?機制來進行查詢分析和轉換。這是大多數(shù)基于規(guī)則的查詢優(yōu)化完成的地方。ExpressionAnalyzer?非?;靵y,應該進行重寫:不同的查詢轉換和優(yōu)化應該被提取出來并劃分成不同的類,從而允許模塊化轉換或查詢。
函數(shù)(Functions)
函數(shù)既有普通函數(shù),也有聚合函數(shù)。對于聚合函數(shù),請看下一節(jié)。
普通函數(shù)不會改變行數(shù) - 它們的執(zhí)行看起來就像是獨立地處理每一行數(shù)據。實際上,函數(shù)不會作用于一個單獨的行上,而是作用在以?Block?為單位的數(shù)據上,以實現(xiàn)向量查詢執(zhí)行。
還有一些雜項函數(shù),比如?塊大小、rowNumberInBlock,以及?跑累積,它們對塊進行處理,并且不遵從行的獨立性。
ClickHouse 具有強類型,因此隱式類型轉換不會發(fā)生。如果函數(shù)不支持某個特定的類型組合,則會拋出異常。但函數(shù)可以通過重載以支持許多不同的類型組合。比如,plus?函數(shù)(用于實現(xiàn)?+?運算符)支持任意數(shù)字類型的組合:UInt8?+?Float32,UInt16?+?Int8?等。同時,一些可變參數(shù)的函數(shù)能夠級接收任意數(shù)目的參數(shù),比如?concat?函數(shù)。
實現(xiàn)函數(shù)可能有些不方便,因為函數(shù)的實現(xiàn)需要包含所有支持該操作的數(shù)據類型和?IColumn?類型。比如,plus?函數(shù)能夠利用 C++ 模板針對不同的數(shù)字類型組合、常量以及非常量的左值和右值進行代碼生成。
這是一個實現(xiàn)動態(tài)代碼生成的好地方,從而能夠避免模板代碼膨脹。同樣,運行時代碼生成也使得實現(xiàn)融合函數(shù)成為可能,比如融合?乘-加?,或者在單層循環(huán)迭代中進行多重比較。
由于向量查詢執(zhí)行,函數(shù)不會?短路?。比如,如果你寫?WHERE f(x) AND g(y),兩邊都會進行計算,即使是對于?f(x)?為 0 的行(除非?f(x)?是零常量表達式)。但是如果?f(x)?的選擇條件很高,并且計算?f(x)?比計算?g(y)?要劃算得多,那么最好進行多遍計算:首先計算?f(x),根據計算結果對列數(shù)據進行過濾,然后計算?g(y),之后只需對較小數(shù)量的數(shù)據進行過濾。
聚合函數(shù)
聚合函數(shù)是狀態(tài)函數(shù)。它們將傳入的值激活到某個狀態(tài),并允許你從該狀態(tài)獲取結果。聚合函數(shù)使用?IAggregateFunction?接口進行管理。狀態(tài)可以非常簡單(AggregateFunctionCount?的狀態(tài)只是一個單一的UInt64?值),也可以非常復雜(AggregateFunctionUniqCombined?的狀態(tài)是由一個線性數(shù)組、一個散列表和一個?HyperLogLog?概率數(shù)據結構組合而成的)。
為了能夠在執(zhí)行一個基數(shù)很大的?GROUP BY?查詢時處理多個聚合狀態(tài),需要在?Arena(一個內存池)或任何合適的內存塊中分配狀態(tài)。狀態(tài)可以有一個非平凡的構造器和析構器:比如,復雜的聚合狀態(tài)能夠自己分配額外的內存。這需要注意狀態(tài)的創(chuàng)建和銷毀并恰當?shù)貍鬟f狀態(tài)的所有權,以跟蹤誰將何時銷毀狀態(tài)。
聚合狀態(tài)可以被序列化和反序列化,以在分布式查詢執(zhí)行期間通過網絡傳遞或者在內存不夠的時候將其寫到硬盤。聚合狀態(tài)甚至可以通過?DataTypeAggregateFunction?存儲到一個表中,以允許數(shù)據的增量聚合。
聚合函數(shù)狀態(tài)的序列化數(shù)據格式目前尚未版本化。如果只是臨時存儲聚合狀態(tài),這樣是可以的。但是我們有?AggregatingMergeTree?表引擎用于增量聚合,并且人們已經在生產中使用它。這就是為什么在未來當我們更改任何聚合函數(shù)的序列化格式時需要增加向后兼容的支持。
服務器(Server)
服務器實現(xiàn)了多個不同的接口:
一個用于任何外部客戶端的 HTTP 接口。
一個用于本機 ClickHouse 客戶端以及在分布式查詢執(zhí)行中跨服務器通信的 TCP 接口。
一個用于傳輸數(shù)據以進行拷貝的接口。
在內部,它只是一個沒有協(xié)程、纖程等的基礎多線程服務器。服務器不是為處理高速率的簡單查詢設計的,而是為處理相對低速率的復雜查詢設計的,每一個復雜查詢能夠對大量的數(shù)據進行處理分析。
服務器使用必要的查詢執(zhí)行需要的環(huán)境初始化?Context?類:可用數(shù)據庫列表、用戶和訪問權限、設置、集群、進程列表和查詢日志等。這些環(huán)境被解釋器使用。
我們維護了服務器 TCP 協(xié)議的完全向后向前兼容性:舊客戶端可以和新服務器通信,新客戶端也可以和舊服務器通信。但是我們并不想永久維護它,我們將在大約一年后刪除對舊版本的支持。
對于所有的外部應用,我們推薦使用 HTTP 接口,因為該接口很簡單,容易使用。TCP 接口與內部數(shù)據結構的聯(lián)系更加緊密:它使用內部格式傳遞數(shù)據塊,并使用自定義幀來壓縮數(shù)據。我們沒有發(fā)布該協(xié)議的 C 庫,因為它需要鏈接大部分的 ClickHouse 代碼庫,這是不切實際的。
分布式查詢執(zhí)行
集群設置中的服務器大多是獨立的。你可以在一個集群中的一個或多個服務器上創(chuàng)建一個?Distributed?表。Distributed?表本身并不存儲數(shù)據,它只為集群的多個節(jié)點上的所有本地表提供一個?視圖(view)?。當從?Distributed?表中進行 SELECT 時,它會重寫該查詢,根據負載平衡設置來選擇遠程節(jié)點,并將查詢發(fā)送給節(jié)點。Distributed?表請求遠程服務器處理查詢,直到可以合并來自不同服務器的中間結果的階段。然后它接收中間結果并進行合并。分布式表會嘗試將盡可能多的工作分配給遠程服務器,并且不會通過網絡發(fā)送太多的中間數(shù)據。
當?IN?或?JOIN?子句中包含子查詢并且每個子查詢都使用分布式表時,事情會變得更加復雜。我們有不同的策略來執(zhí)行這些查詢。
分布式查詢執(zhí)行沒有全局查詢計劃。每個節(jié)點都有針對自己的工作部分的本地查詢計劃。我們僅有簡單的一次性分布式查詢執(zhí)行:將查詢發(fā)送給遠程節(jié)點,然后合并結果。但是對于具有高基數(shù)的?GROUP BY?或具有大量臨時數(shù)據的?JOIN?這樣困難的查詢的來說,這是不可行的:在這種情況下,我們需要在服務器之間?改組?數(shù)據,這需要額外的協(xié)調。ClickHouse 不支持這類查詢執(zhí)行,我們需要在這方面進行努力。
合并樹
MergeTree?是一系列支持按主鍵索引的存儲引擎。主鍵可以是一個任意的列或表達式的元組。MergeTree?表中的數(shù)據存儲于?分塊?中。每一個分塊以主鍵序存儲數(shù)據(數(shù)據按主鍵元組的字典序排序)。表的所有列都存儲在這些?分塊?中分離的?column.bin?文件中。column.bin?文件由壓縮塊組成,每一個塊通常是 64 KB 到 1 MB 大小的未壓縮數(shù)據,具體取決于平均值大小。這些塊由一個接一個連續(xù)放置的列值組成。每一列的列值順序相同(順序由主鍵定義),因此當你按多列進行迭代時,你能夠得到相應列的值。
主鍵本身是?稀疏?的。它并不是索引單一的行,而是索引某個范圍內的數(shù)據。一個單獨的?primary.idx?文件具有每個第 N 行的主鍵值,其中 N 稱為?index_granularity(通常,N = 8192)。同時,對于每一列,都有帶有標記的?column.mrk?文件,該文件記錄的是每個第 N 行在數(shù)據文件中的偏移量。每個標記是一個 pair:文件中的偏移量到壓縮塊的起始,以及解壓縮塊中的偏移量到數(shù)據的起始。通常,壓縮塊根據標記對齊,并且解壓縮塊中的偏移量為 0。primary.idx?的數(shù)據始終駐留在內存,同時?column.mrk?的數(shù)據被緩存。
當我們要從?MergeTree?的一個分塊中讀取部分內容時,我們會查看?primary.idx?數(shù)據并查找可能包含所請求數(shù)據的范圍,然后查看?column.mrk?并計算偏移量從而得知從哪里開始讀取些范圍的數(shù)據。由于稀疏性,可能會讀取額外的數(shù)據。ClickHouse 不適用于高負載的簡單點查詢,因為對于每一個鍵,整個?index_granularity?范圍的行的數(shù)據都需要讀取,并且對于每一列需要解壓縮整個壓縮塊。我們使索引稀疏,是因為每一個單一的服務器需要在索引沒有明顯內存消耗的情況下,維護數(shù)萬億行的數(shù)據。另外,由于主鍵是稀疏的,導致其不是唯一的:無法在 INSERT 時檢查一個鍵在表中是否存在。你可以在一個表中使用同一個鍵創(chuàng)建多個行。
當你向?MergeTree?中插入一堆數(shù)據時,數(shù)據按主鍵排序并形成一個新的分塊。為了保證分塊的數(shù)量相對較少,有后臺線程定期選擇一些分塊并將它們合并成一個有序的分塊,這就是?MergeTree?的名稱來源。當然,合并會導致?寫入放大?。所有的分塊都是不可變的:它們僅會被創(chuàng)建和刪除,不會被修改。當運行?SELECT?查詢時,MergeTree?會保存一個表的快照(分塊集合)。合并之后,還會保留舊的分塊一段時間,以便發(fā)生故障后更容易恢復,因此如果我們發(fā)現(xiàn)某些合并后的分塊可能已損壞,我們可以將其替換為原分塊。
MergeTree?不是 LSM 樹,因為它不包含?memtable?和?log?:插入的數(shù)據直接寫入文件系統(tǒng)。這使得它僅適用于批量插入數(shù)據,而不適用于非常頻繁地一行一行插入 - 大約每秒一次是沒問題的,但是每秒一千次就會有問題。我們這樣做是為了簡單起見,因為我們已經在我們的應用中批量插入數(shù)據。
MergeTree?表只能有一個(主)索引:沒有任何輔助索引。在一個邏輯表下,允許有多個物理表示,比如,可以以多個物理順序存儲數(shù)據,或者同時表示預聚合數(shù)據和原始數(shù)據。
有些?MergeTree?引擎會在后臺合并期間做一些額外工作,比如?CollapsingMergeTree?和?AggregatingMergeTree。這可以視為對更新的特殊支持。請記住這些不是真正的更新,因為用戶通常無法控制后臺合并將會執(zhí)行的時間,并且?MergeTree?中的數(shù)據幾乎總是存儲在多個分塊中,而不是完全合并的形式。
復制(Replication)
ClickHouse 中的復制是基于表實現(xiàn)的。你可以在同一個服務器上有一些可復制的表和不可復制的表。你也可以以不同的方式進行表的復制,比如一個表進行雙因子復制,另一個進行三因子復制。
復制是在?ReplicatedMergeTree?存儲引擎中實現(xiàn)的。ZooKeeper?中的路徑被指定為存儲引擎的參數(shù)。ZooKeeper?中所有具有相同路徑的表互為副本:它們同步數(shù)據并保持一致性。只需創(chuàng)建或刪除表,就可以實現(xiàn)動態(tài)添加或刪除副本。
復制使用異步多主機方案。你可以將數(shù)據插入到與?ZooKeeper?進行會話的任意副本中,并將數(shù)據復制到所有其它副本中。由于 ClickHouse 不支持 UPDATEs,因此復制是無沖突的。由于沒有對插入的仲裁確認,如果一個節(jié)點發(fā)生故障,剛剛插入的數(shù)據可能會丟失。
用于復制的元數(shù)據存儲在 ZooKeeper 中。其中一個復制日志列出了要執(zhí)行的操作。操作包括:獲取分塊、合并分塊和刪除分區(qū)等。每一個副本將復制日志復制到其隊列中,然后執(zhí)行隊列中的操作。比如,在插入時,在復制日志中創(chuàng)建?獲取分塊?這一操作,然后每一個副本都會去下載該分塊。所有副本之間會協(xié)調進行合并以獲得相同字節(jié)的結果。所有的分塊在所有的副本上以相同的方式合并。為實現(xiàn)該目的,其中一個副本被選為領導者,該副本首先進行合并,并把?合并分塊?操作寫到日志中。
復制是物理的:只有壓縮的分塊會在節(jié)點之間傳輸,查詢則不會。為了降低網絡成本(避免網絡放大),大多數(shù)情況下,會在每一個副本上獨立地處理合并。只有在存在顯著的合并延遲的情況下,才會通過網絡發(fā)送大塊的合并分塊。
另外,每一個副本將其狀態(tài)作為分塊和校驗和組成的集合存儲在 ZooKeeper 中。當本地文件系統(tǒng)中的狀態(tài)與 ZooKeeper 中引用的狀態(tài)不同時,該副本會通過從其它副本下載缺失和損壞的分塊來恢復其一致性。當本地文件系統(tǒng)中出現(xiàn)一些意外或損壞的數(shù)據時,ClickHouse 不會將其刪除,而是將其移動到一個單獨的目錄下并忘記它。
ClickHouse 集群由獨立的分片組成,每一個分片由多個副本組成。集群不是彈性的,因此在添加新的分片后,數(shù)據不會自動在分片之間重新平衡。相反,集群負載將變得不均衡。該實現(xiàn)為你提供了更多控制,對于相對較小的集群,例如只有數(shù)十個節(jié)點的集群來說是很好的。但是對于我們在生產中使用的具有數(shù)百個節(jié)點的集群來說,這種方法成為一個重大缺陷。我們應該實現(xiàn)一個表引擎,使得該引擎能夠跨集群擴展數(shù)據,同時具有動態(tài)復制的區(qū)域,這些區(qū)域能夠在集群之間自動拆分和平衡。