简介
Clickhouse中有众多表引擎,不同的表引擎在底层数据存储上千差万别,在功能和性能上各有侧重。但实际生产中,使用最广泛的表引擎就是MergeTree系列。
本文主要以 MergeTree 引擎为例讲一下 ClickHouse 数据文件在磁盘上的的组织结构及内容。
数据目录
clickhouse
└── test_db // database
├── test_table_a // table
│ ├── 20210224_0_1_1_2 // data part
│ ├── 20210224_3_3_0
│ ├── 20210225_0_1_2_3
│ └── 20210225_4_4_0
└── test_table_b
├── 20210224_0_1_1_2
├── 20210224_3_3_0
├── 20210225_0_1_2_3
└── 20210225_4_4_0
从外部看,data在文件系统中的目录存储结构如上图所示。其中,库( database )、表(table)都对应一个文件目录。每张表会包含若干个分区(Partition) (如果不指定分区配置,则默认为一个 all 分区),每个分区又由若干个 part 组成,其中每个 Part 对应一个文件目录。接下来我们逐级介绍这些概念。
Partition
分区(Partition)是在 建表 时通过 PARTITION BY expr
子句指定的逻辑数据集。同一分区内具有相同的分区键值。 分区键可以是表中列的任意表达式。例如,指定按月分区,表达式为 toYYYYMM(date_column)
:
CREATE TABLE visits
(
VisitDate Date,
Hour UInt8,
ClientID UUID
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(VisitDate)
ORDER BY Hour;
可以通过 system.parts 表查看表片段和分区信息。例如,假设我们有一个 visits
表,按月分区,可以对 system.parts
表执行 SELECT
:
SELECT
partition,
name,
active
FROM system.parts
WHERE table = 'visits'
其结果如下:
-
partition
列存储分区的名称。此示例中有两个分区:201901
和201902
。 -
name
列为分区中数据 part 的名称。part 的命名规则将放在下一小节中介绍。 -
active
列为片段状态。1
代表激活状态;0
代表非激活状态。非激活片段是那些在合并到较大片段之后剩余的源数据片段。损坏的数据片段也表示为非活动状态。非激活片段会在合并后的10分钟左右被删除。
每个分区的数据都是分开存储的,因此合理的分区可以减少每次查询需要操作的数据。反之,如果分区数过多可能会导致数据文件数量过多,导致查询效率不佳。
Part
每个分区由若干个 part 组成,其命名规则如下:
-
PartitionID
201905
即分区键值。 -
第一个
1
和第二个1
分别代表这个数据 part 中包含数据块 block 的最小编号和最大编号。 -
最后一个
0
是合并级别 level。每经过一次合并,会更新生成一个 level + 1 后的 part 目录。
值得注意的是,ClickHouse每次写入都会生成一个data part,如果每次写入一条或者少量的数据,那会造成ClickHouse内部有大量的data part(会给merge和查询造成很大的负担)。为了防止出现大量的data part,推荐采用 batch insert 的方式,每次写入一批数据。
每个 Part 目录下主要包含数据文件(. bin )、索引文件(.idx)、标记文件(.mrk),此外还有校验和(checksum)、列属性(columns)等文件。
接下来,我们会使用官方文档中的一个例子,来介绍 bin
文件、idx
文件、mrk
文件以及 granule
的概念。
假设我们创建一个包含联合主键UserID和URL列的表:
CREATE TABLE hits_UserID_URL
(
`UserID` UInt32,
`URL` String,
`EventTime` DateTime
)
ENGINE = MergeTree
PRIMARY KEY (UserID, URL)
ORDER BY (UserID, URL, EventTime)
SETTINGS index_granularity = 8192, index_granularity_bytes = 0;
上面创建的表有:
- 联合主键 (UserID, URL)
- 联合排序键 (UserID, URL, EventTime)。
Bin
插入的行按照主键列(以及排序键的附加列)的字典序(从小到大)存储在磁盘上。 在本例中,会首先按照 UserID 排序,
然后是URL,最后是EventTime。
由于 Clickhouse 是列存数据库,因此每一列都有一个单独的数据文件(*. bin ) ,该列的所有值都以压缩格式存储。
Granule
颗粒(granule)是在每列上划分得到的逻辑数据块,也是 clickhouse 最小的读取单位。 即 ClickHouse 每次不是读取单独的行,而是始终读取(以流方式并并行地)整个行组(granule)。
granule的大小由配置项 index_granularity
确定,默认8192。例如,下图中前 8192 行属于 granule0,接下来的 8192 行属于 granule1,依次类推。
同时,granule 也会作为划分主键稀疏索引的单位。在查询时,会通过主键定位到具体的 granule,然后一次性读取所有符合条件的 granule。
Idx
在上文中也提到,索引是基于上面提到的颗粒(granule)创建的。对于每个 granule,会保存其首行作为该 granule 的索引条目,如下图所示。
之所以可以使用这种稀疏索引,是因为ClickHouse会按照主键列的顺序将一组行存储在磁盘上。之后在查询的时候,就可以通过二分查找的方式迅速定位可能匹配的行组。例如,我们需要查询 UserID 749927693
点击次数最多的10个url,则可以通过主键索引中的 UserId 列做二分查找,快速筛选符合条件的 granule。
在ClickHouse中,每个数据部分(data part)都有自己的主索引。当他们被合并时,合并部分的主索引也被合并。
Mrk
通过主键的稀疏索引,我们可以快速定位到符合查询条件的 granule,但是由于 granule 是个逻辑数据块,我们并不直接知道它在数据文件(.bin)中的存储位置。因此,我们还需要一个文件用来定位 granule,这就是标记(.mrk)文件。
在 bin 文件中,为了减少数据文件大小,数据需要进行压缩存储。如果直接将整个文件压缩,则查询时必须读取整个文件进行解压,显然如果需要查询的数据集比较小,这样做的开销就会显得特别大。因此,数据是以块(Block) 为单位进行压缩, 一个压缩数据块可以包含若干个 granule 的数据,如下图所示。
压缩数据块大小范围由配置max_compress_block_size
和 min_compress_block_size
共同决定。每个压缩块中的header部分会存下这个压缩块的压缩前大小和压缩后大小。
于是,为了从上图所示的数据文件中定位一个 granule,则需要两个值,分别是 granule 所属的数据块 block 的位置以及 block 中 granule 的位置。因此,mrk文件的结构如下所示,每行以偏移量的形式存储两个位置:
- 第一个偏移量(下图中的
block_offset
)是包含 granule 的压缩 block 在数据文件中的偏移量。 - 第二个偏移量(下图中的
granule_offset
)提供了 granule 在解压后的数据块中的位置。
下图说明了 ClickHouse 如何在UserID.bin数据文件中定位176颗粒。
-
首先,ClickHouse 通过主索引定位到了第 176 个 granule 中可能包含查询所需的匹配行。
-
然后,读取对应的 mark 文件,通过第一个偏移量定位压缩数据块的位置,并将其解压进内存。
-
再通过第二个偏移量定位 granule176 在解压数据块中的位置,将其读进 Clickhouse。
总结
至此,我们自上而下逐层深入的介绍了使用 MergeTree 作为表引擎时 ClickHouse 数据文件的组织及存储结构。
参考
ClickHouse主键索引最佳实践
Clickhouse数据存储结构
Clickhouse MergeTree
自定义分区键