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,拭目以待吧。

民那晚安晚安。

你可能感兴趣的:(ClickHouse MergeTree变得更像LSM Tree了?——Polymorphic Parts特性浅析)