ClickHouse 存储引擎解析:磁盘上的数据组织

2023年 9月 12日 56.6k 0

简介

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 列存储分区的名称。此示例中有两个分区:201901201902

  • 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_sizemin_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

自定义分区键

相关文章

JavaScript2024新功能:Object.groupBy、正则表达式v标志
PHP trim 函数对多字节字符的使用和限制
新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
为React 19做准备:WordPress 6.6用户指南
如何删除WordPress中的所有评论

发布评论