騰訊阿里頭條翻牌子 | ClickHouse中MergeTree的存儲結(jié)構(gòu)和查詢加速
點(diǎn)擊上方藍(lán)色字體,選擇“設(shè)為星標(biāo)”
回復(fù)”資源“獲取更多資源

引言
MergeTree存儲
MergeTree思想
MergeTree存儲結(jié)構(gòu)
CREATE TABLE user_action_log (
`time` DateTime DEFAULT CAST('1970-01-01 08:00:00', 'DateTime') COMMENT '日志時間',
`action_id` UInt16 DEFAULT CAST(0, 'UInt16') COMMENT '日志行為類型id',
`action_name` String DEFAULT '' COMMENT '日志行為類型名',
`region_name` String DEFAULT '' COMMENT '區(qū)服名稱',
`uid` UInt64 DEFAULT CAST(0, 'UInt64') COMMENT '用戶id',
`level` UInt32 DEFAULT CAST(0, 'UInt32') COMMENT '當(dāng)前等級',
`trans_no` String DEFAULT '' COMMENT '事務(wù)流水號',
`ext_head` String DEFAULT '' COMMENT '擴(kuò)展日志head',
`avatar_id` UInt32 DEFAULT CAST(0, 'UInt32') COMMENT '角色id',
`scene_id` UInt32 DEFAULT CAST(0, 'UInt32') COMMENT '場景id',
`time_ts` UInt64 DEFAULT CAST(0, 'UInt64') COMMENT '秒單位時間戳',
index avatar_id_minmax (avatar_id) type minmax granularity 3
) ENGINE = MergeTree()
PARTITION BY (toYYYYMMDD(time), toHour(time), region_name)
ORDER BY (action_id, scene_id, time_ts, level, uid)
PRIMARY KEY (action_id, scene_id, time_ts, level);

MergeTree表的存儲結(jié)構(gòu)中,每個數(shù)據(jù)分區(qū)相互獨(dú)立,邏輯上沒有關(guān)聯(lián)。單個數(shù)據(jù)分區(qū)內(nèi)部存在著多個MergeTree Data Part。這些Data Part一旦生成就是Immutable的狀態(tài),Data Part的生成和銷毀主要與寫入和異步Merge有關(guān)。MergeTree表的寫入鏈路是一個極端的batch load過程,Data Part不支持單條的append insert。每次batch insert都會生成一個新的MergeTree Data Part。如果用戶單次insert一條記錄,那就會為那一條記錄生成一個獨(dú)立的Data Part,這必然是無法接受的。一般我們使用MergeTree表引擎的時候,需要在客戶端做聚合進(jìn)行batch寫入或者在MergeTree表的基礎(chǔ)上創(chuàng)建Distributed表來代理MergeTree表的寫入和查詢,Distributed表默認(rèn)會緩存用戶的寫入數(shù)據(jù),超過一定時間或者數(shù)據(jù)量再異步轉(zhuǎn)發(fā)給MergeTree表。MergeTree存儲引擎對數(shù)據(jù)實(shí)時可見要求非常高的場景是不太友好的。

上圖展示了單個MergeTree Data Part里最核心的一部分磁盤文件(只畫了action_id和avatar_id列其關(guān)的存儲文件),從功能上分主要有三個類:
Granule是數(shù)據(jù)按行劃分時用到的邏輯概念。關(guān)于多少行是一個Granule這個問題,在老版本中這是用參數(shù)index_granularity設(shè)定的一個常量,也就是每隔確定行就是一個Granule。在當(dāng)前版本中有另一個參數(shù)index_granularity_bytes會影響Granule的行數(shù),它的意義是讓每個Granule中所有列的sum size盡量不要超過設(shè)定值。老版本中的定長Granule設(shè)定主要的問題是MergeTree中的數(shù)據(jù)是按Granule粒度進(jìn)行索引的,這種粗糙的索引粒度在分析超級大寬表的場景中,從存儲讀取的data size會膨脹得非常厲害,需要用戶非常謹(jǐn)慎得設(shè)定參數(shù)。
Block是列存文件中的壓縮單元。每個列存文件的Block都會包含若干個Granule,具體多少個Granule是由參數(shù)min_compress_block_size控制,每次列的Block中寫完一個Granule的數(shù)據(jù)時,它會檢查當(dāng)前Block Size有沒有達(dá)到設(shè)定值,如果達(dá)到則會把當(dāng)前Block進(jìn)行壓縮然后寫磁盤。
從以上兩點(diǎn)可以看出MergeTree的Block既不是定data size也不是定行數(shù)的,Granule也不是一個定長的邏輯概念。所以我們需要額外信息快速找到某一個Granule。這就是Mark標(biāo)識文件的作用,它記錄了每個Granule的行數(shù),以及它所在的Block在列存壓縮文件中的偏移,同時還有Granule在解壓后的Block中的偏移位置。
MergeTree查詢
索引檢索
/// Whether the condition is feasible in the key range.
/// left_key and right_key must contain all fields in the sort_descr in the appropriate order.
/// data_types - the types of the key columns.
bool mayBeTrueInRange(size_t used_key_size, const Field * left_key, const Field * right_key, const DataTypes & data_types) const;
/// Whether the condition is feasible in the direct product of single column ranges specified by `parallelogram`.
bool mayBeTrueInParallelogram(const std::vector<Range> & parallelogram, const DataTypes & data_types) const;
/// Is the condition valid in a semi-infinite (not limited to the right) key range.
/// left_key must contain all the fields in the sort_descr in the appropriate order.
bool mayBeTrueAfter(size_t used_key_size, const Field * left_key, const DataTypes & data_types) const;
數(shù)據(jù)Sampling
數(shù)據(jù)掃描
Final模式:該模式對CollapsingMergeTree、SummingMergeTree等表引擎提供一個最終Merge后的數(shù)據(jù)視圖。前文已經(jīng)提到過MergeTree基礎(chǔ)上的高級MergeTree表引擎都是對MergeTree Data Part采用了特定的Merge邏輯。它帶來的問題是由于MergeTree Data Part是異步Merge的過程,在沒有最終Merge成一個Data Part的情況下,用戶無法看到最終的數(shù)據(jù)結(jié)果。所以ClickHouse在查詢是提供了一個final模式,它會在各個Data Part的多條BlockInputStream基礎(chǔ)上套上一些高級的Merge Stream,例如DistinctSortedBlockInputStream、SummingSortedBlockInputStream等,這部分邏輯和異步Merge時的邏輯保持一致,這樣用戶就可以提前看到“最終”的數(shù)據(jù)結(jié)果了。
Sorted模式:sort模式可以認(rèn)為是一種order by下推存儲的查詢加速優(yōu)化手段。因?yàn)槊總€MergeTree Data Part內(nèi)部的數(shù)據(jù)是有序的,所以當(dāng)用戶查詢中包括排序鍵order by條件時只需要在各個Data Part的BlockInputStream上套一個做數(shù)據(jù)有序歸并的InputStream就可以實(shí)現(xiàn)全局有序的能力。
Normal模式:這是基礎(chǔ)MergeTree表最常用的數(shù)據(jù)掃描模式,多個Data Part之間進(jìn)行并行數(shù)據(jù)掃描,對于單查詢可以達(dá)到非常高吞吐的數(shù)據(jù)讀取。
并行掃描:傳統(tǒng)的計(jì)算引擎在數(shù)據(jù)掃描部分的并發(fā)度大多和存儲文件數(shù)綁定在一起,所以MergeTree Data Part并行掃描是一個基礎(chǔ)能力。但是MergeTree的存儲結(jié)構(gòu)要求數(shù)據(jù)不斷mege,最終合并成一個Data Part,這樣對索引和數(shù)據(jù)壓縮才是最高效的。所以ClickHouse在MergeTree Data Part并行的基礎(chǔ)上還增加了Mark Range并行。用戶可以任意設(shè)定數(shù)據(jù)掃描過程中的并行度,每個掃描線程分配到的是Mark Range In Data Part粒度的任務(wù),同時多個掃描線程之間還共享了Mark Range Task Pool,這樣可以避免在存儲掃描中的長尾問題。
數(shù)據(jù)Cache:MergeTree的查詢鏈路中涉及到的數(shù)據(jù)有不同級別的緩存設(shè)計(jì)。主鍵索引和分區(qū)鍵索引在load Data Part的過程中被加載到內(nèi)存,Mark文件和列存文件有對應(yīng)的MarkCache和UncompressedCache,MarkCache直接緩存了Mark文件中的binary內(nèi)容,而UncompressedCache中緩存的是解壓后的Block數(shù)據(jù)。
SIMD反序列化:部分列類型的反序列化過程中采用了手寫的sse指令加速,在數(shù)據(jù)命中UncompressedCache的情況下會有一些效果。
PreWhere過濾:ClickHouse的語法支持了額外的PreWhere過濾條件,它會先于Where條件進(jìn)行判斷。當(dāng)用戶在sql的filter條件中加上PreWhere過濾條件時,存儲掃描會分兩階段進(jìn)行,先讀取PreWhere條件中依賴的列值,然后計(jì)算每一行是否符合條件。相當(dāng)于在Mark Range的基礎(chǔ)上進(jìn)一步縮小掃描范圍,PreWhere列掃描計(jì)算過后,ClickHouse會調(diào)整每個Mark對應(yīng)的Granule中具體要掃描的行數(shù),相當(dāng)于可以丟棄Granule頭尾的一部分行。
結(jié)語

