ClickHouse是由俄罗斯Yandex公司开发的、面向列的数据库管理系统(DBMS),主要面向OLAP场景,用于在线分析处理查询,可以使用SQL查询实时生成数据分析结果。列式存储的好处就是当我们对列进行聚合等操作时,效率会大大优于行式存储,而且由于每一列的类型都是相同的,所以对于数据存储更容易进行压缩,而且可以对不同类型的列选择更合适的压缩算法,节约资源。
clickhouse的设计也处处体现了俄罗斯的暴力美学,它不仅仅是一个数据库,还是一个数据库管理系统,后面我们在介绍基于SQL的用户管理、权限管理、资源管理,以及clickhouse本身在数据压缩、并行化计算等方面的特色,就会明白为什么会说clickhouse是一个数据库管理系统了。
clickhouse虽然在很多方面表现出了优异的性能,尤其是是在大数据量情况下的高效查询效率(ck和其他数据库的查询性能比较,可参考官方测试,但也并不意味着就适合所有数据库使用场景。ck的使用场景如下:
ClickHouse支持不同的表引擎,主要有MergeTree家族表引擎、Log家族表引擎、集成表引擎,以及一些特殊的表引擎。表引擎的主要作用是:
MergeTree表引擎是MergeTree家族表引擎最具代表性的表引擎,也是使用最为广泛的表引擎。MergeTree表引擎可以被认为是单节点ClickHouse实例的默认表引擎,因为它适用于各种各样的用例。建表语句如下:
CREATE TABLE [IF NOT EXISTS] [db.]table_name [ON CLUSTER cluster]
(
name1 [type1] [DEFAULT|MATERIALIZED|ALIAS expr1] [TTL expr1],
name2 [type2] [DEFAULT|MATERIALIZED|ALIAS expr2] [TTL expr2],
...
INDEX index_name1 expr1 TYPE type1(...) GRANULARITY value1,
INDEX index_name2 expr2 TYPE type2(...) GRANULARITY value2,
...
PROJECTION projection_name_1 (SELECT <COLUMN LIST EXPR> [GROUP BY] [ORDER BY]),
PROJECTION projection_name_2 (SELECT <COLUMN LIST EXPR> [GROUP BY] [ORDER BY])
) ENGINE = MergeTree()
ORDER BY expr
[PARTITION BY expr]
[PRIMARY KEY expr]
[SAMPLE BY expr]
[TTL expr
[DELETE|TO DISK 'xxx'|TO VOLUME 'xxx' [, ...] ]
[WHERE conditions]
[GROUP BY key_expr [SET v1 = aggr_func(v1) [, v2 = aggr_func(v2) ...]] ] ]
[SETTINGS name=value, ...]
CREATE TABLE example_table
(
d DateTime,
a Int TTL d + INTERVAL 1 MONTH,
b Int TTL d + INTERVAL 1 MONTH,
c String
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(d)
ORDER BY d;
相对于列级TTL,一般更常用表级TTL,可用来存储对历史数据不再关注的数据,而且表TTL可以把过期数据移动到磁盘或者卷(关于卷和磁盘的区别参见10),例如在训练模型进行在线迁移学习的时候,会一直使用新数据优化模型,不再关注历史数据等。创建表级TTL表:
CREATE TABLE example_table
(
d DateTime,
a Int
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(d)
ORDER BY d
TTL d + INTERVAL 1 MONTH [DELETE],
d + INTERVAL 1 WEEK TO VOLUME 'aaa',
d + INTERVAL 2 WEEK TO DISK 'bbb';
ALTER TABLE hits MOVE PART '20190301_14343_16206_438' TO VOLUME 'slow'
ALTER TABLE hits MOVE PARTITION '2019-09-01' TO DISK 'fast_ssd'
如果想通过配置实现数据的自动转移,可参考官方文档在配置文件中设置。
关于ck服务器的存储策略和资源信息可通过 system.storage_policies 和 system.disks 表查看。
前面我们已经介绍了分区、索引粒度、order by等概念,接下来我们具体介绍clickhouse是怎么存储数据的。首先看一下clickhouse的目录结构:
和数据存储相关的目录主要是 data 和 metadata 目录,其中 metadata 目录是保存元数据的目录,结构如下:
可以发现metadata目录保存的数据就是建库和建表语句信息,default.sql 是create db sql,default目录下是default库中所有表的建表sql文件。
回到clickhouse根目录下的data目录,data目录下也是按照库名分为不同的子目录,例如default目录对应的就是default库,default目录下又按照表分为了不同的子目录。
通过上面两幅图可以发现,clickhouse的实际存储目录是 store 目录,只是store目录下都是一些不直观的id文件,不便于观察,因此做了软连接映射到data和metadata目录。
上面的存储结构是clickhouse的存储目录结构,并不是mergetree独有的,下面我们来介绍mergetree引擎表的存储结构。
clickhouse对分区的处理和hive一样,也是通过目录来隔离的,但是目录名称略有不同,命名格式为 {0}{1}{2}_{3} ,其中 {0}是分区名(分区id),{1}是分区目录内最小的数据块编号(minblocknum),{2}是分区目录内最大的数据块编号(maxblocknum),{3}是目前合并的层级(level)。对于没有分区的表,数据都存在一个分区id为 all 的目录内,例如目录名为: all_1_1_0,对于有分区的表,如果分区是日期或者整型类型,则分区id就是分区字段值,如果是其他类型,则取hash值作为分区id,例如分区目录名为:20220301_1_1_0。为什么会存在数据块编号和合并层级呢?因为 mergetree 在写入数据的时候并不是直接写入原始分区内的,而是先写入临时分区,然后在空闲的时候合并分区(或者手动执行optimize语句触发合并),合并后的分区minblocknum取同一分区内的minblocknum最小值、maxblocknum取同一分区内的maxblocknum最大值、level取同一分区内的level最大值加1(level也隐含表示合并次数),这也是mergetree 名称的由来。例如对于合并前分区目录如下:
20220301_1_1_0
20220301_2_2_0
20220301_3_3_0
合并后就是:20220301_1_3_1。
再解释一下分区block的概念,blocknum从1开始,每当创建一个新的分区目录时,新分区的minblocknum和maxblocknum都是一样的,并且是原有maxblocknum最大值加1。
※ 注意:这里需要注意区分block和上文提到且下文要详细介绍的自适应索引粒度的压缩块part不是一个概念。
接下来我们看一下每个分区内部的文件:
# 基础文件
checksums.txt
columns.txt
count.txt
partition.dat
primary.idx
default_compression_codec.txt
# 窄格式(compact)存储数据文件
data.bin
data.mrk 或者 data.mrk3
minmax_{分区列名}.idx
# 宽格式(wide)存储数据文件
{column1_name}.bin
{column1_name}.mrk 或者 {column1_name}.mrk2
minmax_{column1_name}.idx
...
# 二级索引文件
※ 附录:MergeTree引擎本质是在LSM Tree基础上做了简化,去掉了 MemTable 和 Log,也就是写入数据时不经过缓存直接写入磁盘,所以不建议频繁的小批量写入。在新版clickhouse中除了引入min_rows_for_wide_part外,还引入了WAL(预写日志,防止MemTable丢失),具体表现为 min_rows_for_compact_part 参数。
当写入数据小于 min_rows_for_compact_part 时,不会生成分区目录,会有一个独立于所有分区外的 wal.bin 文件,如果执行OPTIMIZE语句,会生成新的分区目录文件。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的大小。
WAL虽提高了写性能,但是无疑也牺牲了读性能,所以在使用时需要权衡,对于例如只在凌晨进行频繁计算写操作,其他时间进行读操作的场景就比较适合。
※ 注意:Hive分区目录内的数据会被划分为一个个按照序号命名的小文件,本质上数据是存储在Hdfs上的,而Hdfs的存储是按照文件块(一般默认128M)管理的,所以HIve存储能力在分布式节点上的横向扩展是通过Hdfs实现的。MergeTree表的分区目录是针对本地数据(一个节点)而言的,其在多节点上的横向扩展存储是通过分片来实现的,和Hive不同,后面在介绍分布式表的文章中,我们会详细说明。
在第3节我们介绍索引粒度的时候提到了两个参数:index_granularity (按行,默认8192)、index_granularity_bytes(按字节,默认10Mb),在第4节介绍标记文件时也提到了两种标记格式:mrk和mrk2/mrk3,为什么会有两种形式呢?这就和MergeTree自适应索引粒度有关。
在clickhouse的早期版本中,采用的是固定值索引,默认 index_granularity = 8192,也就是每隔8192行数据 primary.idx 就保存一个稀疏索引标记。
如果建表的时候设置index_granularity_bytes=0, 关闭自适应索引,那么稀疏索引粒度和一个压缩块(part)还是8192行,生成的标记文件是mrk文件。对于类型为UInt8类型的字段,mrk文件的内容格式如下:
0 0
0 8192
0 16384
0 24576
0 32768
0 40960
0 49152
0 57344
19423 0
19423 8192
19423 16384
19423 24576
19423 32768
19423 40960
19423 49152
19423 57344
45658 0
45658 8192
45658 16384
45658 24576
mrk文件共有两列,第一列表示该行所在的数据块(part)在对应bin文件中的起始偏移量,第二列表示该行在数据块解压后在part内部的偏移量,单位均为字节,因为U8Int刚好一个字节,所以可以看到第二列都是8192的倍数,为什么每个part内是8个稀疏索引偏移量呢?因为bin文件的压缩规则是每个part压缩前大小是64K~1M (参数min_compress_block_size和max_compress_block_size),8 * 8192 / 1024=64K,刚好切割为一个part压缩存储。如果一个索引粒度对应的数据超过1M,则会被切分为多个part压缩存储。一个压缩块有两部分数据组成:头文件和需要压缩的数据,头文件大小为9字节,包括:压缩算法(1字节UInt8)、压缩后大小(4字节UInt32)、压缩前大小(4字节UInt32)。
※ 附录:压缩算法编号:
LZ4:0x82
ZSTD: 0x90
Multiple: 0x91
Delta: 0x92
这种索引标记对于像整型、短字符串等数据是比较友好的,但是如果存储的数据比较大,就会造成一个固定索引粒度内数据太大,影响写入新数据的效率。为此,clickhouse引入了自适应索引粒度功能,主要表现就是一个索引粒度(间隔)的行数不再是固定的,并且自适应索引粒度默认是开启的(index_granularity_bytes参数)。
index_granularity_bytes表示每隔指定的文件大小生成索引和标记,与index_granularity共同作用,即只要满足两个条件之一即生成,索引间隔会出现小于8192的情况。一旦出现了自适应的数据,mrk文件就会改为mrk2/mrk3。下面是一个mrk2文件的内容:
[root@ck-test001 201403_1_32_3]# od -An -l -j 0 -N 2048 --width=24 Age.mrk2
0 0 1120
0 1120 1120
0 2240 1120
0 3360 1120
0 4480 1120
0 5600 1120
0 6720 1120
0 7840 352
0 8192 1111
0 9303 1111
0 10414 1111
0 11525 1111
0 12636 1111
0 13747 1111
0 14858 1111
0 15969 415
0 16384 1096
...... ...... ......
17694 0 1102
17694 1102 1102
17694 2204 1102
17694 3306 1102
17694 4408 1102
17694 5510 1102
17694 6612 956
17694 7568 1104
# ......
可以发现数据有三列,前两列和mrk文件内容一致,第三列是相邻两个标记位置之间相隔的行数。在索引粒度大于index_granularity_bytes 或者索引位置是index_granularity整数倍时都会记入标记文件。