ClickHouse MergeTree變得更像LSM Tree了?——Polymorphic Parts特性淺析

前言

筆者在之前的文章中已經提到過,MergeTree引擎族是ClickHouse強大功能的基礎。MergeTree這個名詞是在我們耳熟能詳的LSM Tree之上做減法而來——去掉了MemTable和Log。也就是說,向MergeTree引擎族的表插入數據時,數據會不經過緩衝而直接寫到磁盤。官方文檔中有如下的描述:

MergeTree is not an LSM tree because it doesn’t contain "memtable" and "log": inserted data is written directly to the filesystem. This makes it suitable only to INSERT data in batches, not by individual row and not very frequently – about once per second is ok, but a thousand times a second is not. We did it this way for simplicity’s sake, and because we are already inserting data in batches in our applications.

但是在最近的ClickHouse新版本中,上述情況發生了巨大的改變。社區通過#8290#10697兩個PR實現了名爲Polymorphic Parts的特性,使得MergeTree引擎能夠更好地處理頻繁的小批量寫入,但同時也標誌着MergeTree的內核開始向真正的LSM Tree靠攏。本文就來介紹一下這個似乎並不引人注目的重要特性,採用的ClickHouse版本爲20.6.4。

Wide/Compact Part Storage

先來創建一張測試表,並寫入兩批次數據。

CREATE TABLE test.test_event_log (
  event_time DateTime,
  user_id UInt64,
  event_type String,
  site_id UInt64
) ENGINE = MergeTree()
PARTITION BY toYYYYMMDD(event_time)
ORDER BY (user_id,site_id)
SETTINGS index_granularity = 8192;

INSERT INTO test.test_event_log VALUES
('2020-09-14 12:00:00',12345678,'appStart',16789),
('2020-09-14 12:00:01',12345679,'appStart',26789);
INSERT INTO test.test_event_log VALUES
('2020-09-14 13:00:00',22345678,'openGoodsDetail',16789),
('2020-09-14 13:00:01',22345679,'buyNow',26789);

利用tree命令觀察該表的數據目錄,可以發現形成了兩個part目錄,每個part目錄中都存在每一列的數據文件(bin)和索引標記文件(mrk2),老生常談了。

├── 20200914_1_1_0
│   ├── checksums.txt
│   ├── columns.txt
│   ├── count.txt
│   ├── event_time.bin
│   ├── event_time.mrk2
│   ├── event_type.bin
│   ├── event_type.mrk2
│   ├── minmax_event_time.idx
│   ├── partition.dat
│   ├── primary.idx
│   ├── site_id.bin
│   ├── site_id.mrk2
│   ├── user_id.bin
│   └── user_id.mrk2
├── 20200914_2_2_0
│   ├── ......

當寫入特別頻繁時,短時間內生成的part目錄過多,後臺的merger線程合併不過來,就會出現Too many parts的異常,所以官方纔會建議不要執行超過一秒鐘一次的寫入操作。

下面修改表參數min_rows_for_wide_part,當然也可以在建表時的SETTINGS中指定。

ALTER TABLE test.test_event_log MODIFY SETTING min_rows_for_wide_part = 5;

然後再寫入一批次2條數據(SQL就略去了),觀察數據目錄。

├── 20200914_3_3_0
│   ├── checksums.txt
│   ├── columns.txt
│   ├── count.txt
│   ├── data.bin
│   ├── data.mrk3
│   ├── minmax_event_time.idx
│   ├── partition.dat
│   └── primary.idx

可以發現,新生成的part目錄中不再有每一列的bin和mrk2文件了,而是作爲整體存儲在一個文件中,即data.bin/mrk3。

重複實驗可知,只有當寫入批次中的數據行數達到或超過min_rows_for_wide_part規定的閾值時,part目錄中的存儲結構纔會像之前一樣“正常”,否則所有數據就會存儲在data.bin/mrk3中。ClickHouse將每列數據分開存儲的形式稱爲“Wide”(寬的),而將整體存儲的形式稱爲“Compact”(壓縮的),這也正是Polymorphic(多型的)一詞的含義。

在system.parts系統表中,也增加了part_type列來描述part的存儲形式。

SELECT partition,name,part_type,active FROM system.parts
WHERE table = 'test_event_log';

┌─partition─┬─name───────────┬─part_type─┬─active─┐
│ 20200914  │ 20200914_1_1_0 │ Wide      │      0 │
│ 20200914  │ 20200914_1_4_1 │ Wide      │      1 │
│ 20200914  │ 20200914_2_2_0 │ Wide      │      0 │
│ 20200914  │ 20200914_3_3_0 │ Compact   │      0 │
│ 20200914  │ 20200914_4_4_0 │ Compact   │      0 │
└───────────┴────────────────┴───────────┴────────┘

上面是已經發生過merge的parts信息,可以發現Wide part和Compact part是能夠合併在一起的,且合併的結果part的存儲形式仍然遵循min_rows_for_wide_part的閾值。

除了min_rows_for_wide_part參數之外,還有另外一個參數min_bytes_for_wide_part與它共同作用。顧名思義,它是part數據以Wide形式存儲的大小閾值。當兩個條件滿足其一時,part數據就會以Wide形式存儲。當然這兩個參數默認都爲0,表示禁用Compact存儲。

min_bytes_for_wide_part參數已經應用在了會被頻繁寫入的系統日誌表中,例如查詢日誌表system.query_log:

SHOW CREATE TABLE system.query_log\G

Row 1:
──────
statement: CREATE TABLE system.query_log
(
    `type` Enum8('QueryStart' = 1, 'QueryFinish' = 2, 'ExceptionBeforeStart' = 3, 'ExceptionWhileProcessing' = 4),
    `event_date` Date,
    `event_time` DateTime,
    -- 略去……
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_date)
ORDER BY (event_date, event_time)
SETTINGS min_bytes_for_wide_part = '10M', index_granularity = 8192  -- 10MB的Wide part閾值

由於Compact存儲形式大大減少了文件的數量,在生成大量小part時可以有效降低磁盤的iops,從而降低merge的壓力。

In-Memory Part & Write-Ahead Log

到這裏似乎還不能明顯地看出MergeTree向LSM Tree靠攏的跡象,頂多是像LSM Tree一樣更適合小批量寫入而已。但是ClickHouse在實現Polymorphic Parts的同時,還把原版MergeTree中沒有的預寫日誌(WAL)補了回來,而WAL的初衷正是爲了防止內存中的MemTable丟失的,說明MergeTree引擎也引入了MemTable。下面進行介紹。

仍然用例子來說話,修改表參數min_rows_for_compact_part

ALTER TABLE test.test_event_log MODIFY SETTING min_rows_for_compact_part = 3;

插入一批次2條數據,可以看到並沒有生成新的part目錄,但是在表目錄下生成了一個全局的wal.bin文件,即預寫日誌文件,說明剛纔寫入的數據存在了MemTable中。注意ClickHouse代碼內並沒有MemTable的概念,而是將其稱爲In-Memory parts。

├── 20200914_1_4_1
│   ├── ...
├── 20200915_5_5_0
├── ...
├── 20200915_7_7_0
│   ├── ...
├── detached
├── format_version.txt
└── wal.bin

用clickhouse-compressor工具看不到wal.bin具體的內容,只能作罷。

反覆插入一兩行的小批次數據,可以發現始終不會形成新的part目錄,但wal.bin的大小在增長,說明這些數據都留在了內存中。如果此時執行OPTIMIZE語句觸發merge(自動觸發同理),就會發現生成了形如20200915_8_12_1的part,說明內存中的數據在merge的同時被flush到了磁盤——也就是說在啓用了WAL的情況下,ClickHouse的flush是和merge一起進行的,而不是像一般的LSM Tree引擎一樣是分別處理的。

├── 20200914_1_4_1
│   ├── ...
├── 20200915_8_12_1
│   ├── ...
├── detached
├── format_version.txt
└── wal.bin

min_rows_for_compact_part就是In-Memory part與Compact part之間的行數閾值,一次寫入的數據行數大於此值,就會按照傳統方式直接向磁盤flush形成Compact part(或者Wide part),不保存在內存中,也不會寫WAL。反之,則會將數據保留成In-Memory part,並同時寫入WAL,在下一次發生merge時再進行flush。同理,也存在min_bytes_for_compact_part參數,即In-Memory part與Compact part之間的大小閾值。這兩個參數默認也都爲0,表示禁用In-Memory part和WAL。

當然,WAL的大小也不是無限增長的,write_ahead_log_max_bytes參數用於限制wal.bin的大小,默認值爲1G。上面的這三個參數目前是試驗性的,在生產環境中仍然要謹慎使用。

In-Memory part和WAL的引入使得MergeTree的寫入有了更強的緩衝,也更加趨近於LSM Tree-based引擎的機制。這也意味着在讀取分區數據時,必須將In-Memory part和Wide/Compact part的數據進行合併,可能會犧牲讀取性能,需要我們在之後的實踐中評估其影響。

The End

通過上面的介紹,可以得知MergeTree的Polymorphic Parts實際上就是以寫入優化爲最終目的,借鑑LSM Tree的思想,將part的存儲按照In-Memory→Compact→Wide的形式組織起來,彌補小批量寫入性能不足的短板。不過照這樣發展下去,ClickHouse有沒有可能像Greenplum一樣由OLAP引擎變成HTAP引擎呢?社區好像還沒有這方面的roadmap,拭目以待吧。

民那晚安晚安。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章