前言
笔者在之前的文章中已经提到过,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,拭目以待吧。
民那晚安晚安。