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

简介

Clickhouse中有众多表引擎,不同的表引擎在底层数据存储上千差万别,在功能和性能上各有侧重。但实际生产中,使用最广泛的表引擎就是MergeTree系列。

本文主要以 MergeTree 引擎为例讲一下 ClickHouse 数据文件在磁盘上的的组织结构及内容

数据目录

arduino 复制代码
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)

sql 复制代码
CREATE TABLE visits
(
    VisitDate Date,
    Hour UInt8,
    ClientID UUID
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(VisitDate)
ORDER BY Hour;

可以通过 system.parts 表查看表片段和分区信息。例如,假设我们有一个 visits 表,按月分区,可以对 system.parts 表执行 SELECT

sql 复制代码
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列的表:

ini 复制代码
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;

上面创建的表有:

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

自定义分区键

相关推荐
Jayyih5 小时前
嵌入式系统学习Day35(sqlite3数据库)
数据库·学习·sqlite
得意霄尽欢7 小时前
Redis之底层数据结构
数据结构·数据库·redis
hsjkdhs7 小时前
MySQL 数据类型与运算符详解
数据库·mysql
爱吃烤鸡翅的酸菜鱼9 小时前
【Redis】常用数据结构之Hash篇:从常用命令到使用场景详解
数据结构·数据库·redis·后端·缓存·哈希算法
李少兄9 小时前
IntelliJ IDEA 启动项目时配置端口指南
数据库·sql·intellij-idea
NineData9 小时前
NineData云原生智能数据管理平台新功能发布|2025年8月版
数据库·mongodb·云原生·数据库管理工具·ninedata·数据库迁移·数据复制
白云如幻9 小时前
【Java】QBC检索和本地SQL检索
java·数据库·sql
勘察加熊人10 小时前
python将pdf转txt,并切割ai
数据库·python·pdf
不良人天码星10 小时前
Redis单线程模型为什么快?
数据库·redis·缓存
RestCloud11 小时前
ETL 不只是数据搬运工:如何实现智能转换与清洗?
数据库·api