详解 ClickHouse 中的 MergeTree 引擎工作原理-1
技术分享
Mar 3, 2024
type
Post
status
Published
date
Mar 3, 2024
slug
clickhouse-mergetree-1
summary
ClickHouse 是让我印象深刻的数据库产品,海量数据查询和分析操作是真的快 😄,而 MergeTree 是它最常用、最健壮的表引擎,该引擎提供了丰富的功能和极高的效率,是我们使用 ClickHouse 必须要了解的,本文将向你全面介绍 MergeTree 引擎的工作原理
tags
数据库
Architecture
category
技术分享
icon
password
Property
Mar 18, 2024 01:12 AM

ClickHouse 的基础概念

ClickHouse 被官方网站定义为一款”高性能、面向列的 SQL 数据库管理系统,专为在线分析处理(OLAP)设计“的产品,鲜明地指出了它的三大特点:高性能、列式存储、OLAP,我们从这个定义开始,逐步了解 ClickHouse 的基础技术设计理念

OLAP

现代工程界普遍认为,数据库系统可以在广义上分为联机事务处理(Online Transaction Process,OLTP)和联机分析处理(Online Analyze Process,OLAP)两种,它们面向不同领域,满足不同场景下的数据处理需求。
传统的关系型数据库是 OLTP 的典型应用,面向的主要数据操作通常是随机读写,主要采用满足第三范式(3NF)的实体关系模型存储数据,在事务处理中解决数据的冗余和一致性问题。而 OLAP 系统面向的主要数据操作通常是批量读写和聚合类分析,它不关注数据一致性和数据模型的规范化,主要关注数据的整合以及对海量和复杂数据的查询和处理的能力。

列式存储

按照数据存储的方式,数据库系统又可以分为面向列和面向行两种,在面向行的 DBMS 中,数据以行的形式存储,与某一行相关的所有值物理上都存储在一起,而在面向列的 DBMS 中,数据是以列的形式存储的,相同列的值存储在一起。
面向列的数据库更适合于 OLAP 场景:它们在处理大多数查询时比面向行的数据库至少快 100 倍。具体原因此处不再详细描述,下面两幅对比动画可以反映出其中的一部分差异
行式存储数据库
行式存储数据库
列式存储数据库
列式存储数据库

ClickHouse 的表引擎

在 ClickHouse 中,每个表被创建的时候必须指定一个表引擎,表引擎决定了数据是怎么存储和存储在什么地方,进而影响了系统怎么查询数据、是否支持并发访问、能否和怎么使用索引以及数据复制参数等。表引擎是 ClickHouse 系统中重要的组成部分,它的高性能的特点可以认为就是建立在其设计精妙的表引擎之上的。
不同的引擎根据不同的使用场景和存储特点,划分到对应的引擎族,常用的表引擎族有下面几个
  • MergeTree
    • 这个系列是用于高负载任务的最通用和功能最丰富的表引擎,这些引擎的共同特性是快速插入数据和后续进行的后台数据合并处理,支持数据复制(通过引擎的 Replicated* 版本)、分区、辅助 data-skipping 索引,以及其他引擎不支持的功能。MergeTree 引擎是这个引擎族的原型,族中的其他引擎大多数都是基于此引擎的扩展和优化,以满足更多场景和需求。
  • Log
    • 这个系列是轻量级引擎,具有最少的功能,当我们需要快速插入许多小表格(最多约100万行),并在以后整体读取它们时,该引擎是最高效的
  • Integration Engines 和 Special Engines
    • Integration 类的引擎是与其他数据存储和处理系统进行通信集成的引擎,Special 类的引擎是用于专门目的的表引擎,这两类引擎在特定场景下十分有效

MergeTree 表引擎的文件组织

MergeTree 引擎是 ClickHouse 中最强大、最灵活的表引擎之一,它被设计用于处理大规模数据集的高性能读写操作,它的列式存储、数据分区、高效的数据压缩和排序机制,使其成为高性能数据分析的理想选择。让我们先探索一下 MergeTree 表引擎是如何组织数据的物理存储文件的,通过这些存储文件,我们就能够在物理层面上看到数据的组织形式,然后由物理文件出发逆向推及逻辑概念的映射关系。我认为这样的学习方式是更加高效,不至于一开始就迷失于各种逻辑概念中,没有实体去做概念上的对应,学到的知识过不了几天就全部被遗忘了。
MergeTree 引擎的物理文件组织形式如图所示(24.3.1.890 版本)
MergeTree 引擎存储文件组织结构示意图
MergeTree 引擎存储文件组织结构示意图

第一层:ClickHouse 存放数据根目录

默认情况下是 /var/lib/clickhouse/data ,我们打开 data 文件夹进入下一层

第二层:数据库目录

此目录下包含了系统中的几乎所有数据库文件夹,如果没有指定数据库,那么我们创建的表默认就在 default 数据库下,打开 default 文件夹进入下一层

第三层:表目录

此目录下包含了某个数据库中所有表的数据,需要注意的是这里的文件夹是一个软连接,并不是真实存储地址,真实地址一般是在 /var/lib/clickhouse/store/xx/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx ,打开文件夹 hits 进入下一层,进入真实存储文件夹中
notion image

第四层:Part 目录

此目录存储着某张表的 Part 文件夹(也就是示意图中的 all_1_23_5all_24_24_5)、detached 文件夹和 format_version.txt,这一层才真正触及 MergeTree 引擎对表数据的处理过程,也开始对应了一些逻辑概念

Part

notion image
在 ClickHouse 中,每次 INSERT 操作的数据都会生成一个逻辑上的 Part,不管此次操作包含了多少行的数据量,Part 是数据物理存储的基本单元,它们按照表的分区键(Partitioning Key)来切分和管理,每个 Part 只包含某个分区键值范围内的数据,如果没有设置分区键,则一个逻辑 Part 对应一个物理文件夹,如果设置了分区键则会按照分区键拆分成多个物理文件夹。由于表数据被切分成多个 Part 文件夹,ClickHouse 能够更加高效地管理表数据,包括数据的压缩、清理过期和数据修复等操作。
同时,ClickHouse 会在后台对 Part 文件夹不定期的执行检查和合并操作,这也是“MergeTree” 中 Merge 的意义,而且整个过程跟 LSM-Tree 的压缩合并相似,可以类比学习。合并操作可以减少存储的碎片化,提高存储效率,间接提供数据访问的效率,ClickHouse 采用复杂的启发式算法来决定合并哪些 Part 以及是否合并,跟 Part 的大小、Part 创建时间、现有空余存储空间、空闲的 Merge 线程数等相关,源码位于 MergeTreeDataMergerMutator::selectPartsToMerge
MergeTree 的合并过程是分别将两个 Part 的数据读入内存,重新排序生成全新的物理分区再写入磁盘,旧的物理分区会在未来某个时间被 MergeTree 物理删除,这个过程体现了MergeTree 的空间放大特性,MergeTree的分区删除,本质上是将该逻辑分区所涉及的物理分区的文件夹直接删除,合并过的 Part 仍然可以合并,直到大小达到 max_bytes_to_merge_at_max_space_in_pool 设置的值(默认为 150G)。
我们可以在日志中观察到整个合并操作的过程
notion image
Part 文件夹的命名规则为“分区ID_最小数据块编号_最大数据块编号_层级”,其中分区 ID 为 ClickHouse 根据分区健生成的一个编号,如果没有分区键,则为 “all”,数据块编号从 1 开始自增,新创建的 Part 文件夹最大和最小数据块编号相同,当发生合并时会将其最大数据块修改为合并之后的数据块编号,同时每次合并都会将层级增加 1

Partition

notion image
分区(Partition)是一个逻辑上的概念,可以在创建表的时候通过 PARTITION BY expr 来设置,不同的分区存储在不同的 Part 中,同一个分区可能有多个 Part,正如上文介绍 Part 的部分所述。在访问数据时,分区对包含分区键的查询性能有所提升,因为 ClickHouse 会在选择分区中的 Part 和 Granule 之前对该分区进行过滤,以尽可能使用最小的分区子集和 Part 子集,但是并不是分区越多越好,过多的分区会导致查询时打开的文件描述符过多,反而拖慢系统查询速度,所以我们应该尽可能选择低基数(Low Cardinality)的分区键,也就是具有较少不同值的分区键,减少分区数量。
表的分区的信息可以通过下面的 SQL 查询
SELECT partition, name, active FROM system.parts WHERE `table` = 'hits' -- table name

第四层的文件说明

文件名
说明
all_1_23_5
没有分区键的 Part 文件夹,包含了数据块 1 到 23,已经发生了 5 次合并操作
detached
通过 DETACH 语句卸载后的 Part,会被移动到此文件夹内,参考文档
format_version.txt
format_version.txt文件的主要目的是指定这个 MergeTree 表使用的数据结构格式版本,这包括有关数据如何存储、索引和在磁盘上管理的详细信息。因为随着时间的推移,ClickHouse 不断进化,包括对数据存储和处理方式的改进和变化,这些变化有时会涉及到MergeTree表的磁盘格式的修改,format_version.txt 文件帮助 ClickHouse 确定文件里的数据格式是否与当前运行的 ClickHouse 版本兼容。

第五层:Part 数据

该层存储着某一个 Part 的索引文件、数据文件以及这个 Part 的元数据文件。primary.cidx 文件存储的是当前 Part 的主键索引信息,数据文件存储的时候分为两种格式,一种是Wide 格式,一种是 Compact 格式,元数据文件存储着关于这个 Part 的一些基础信息,比如列的信息、数据量等。Part 文件的详细信息包括真实存储路径等,可以通过下面的 SQL 查询
SELECT part_type, path, formatReadableQuantity(rows) AS rows, formatReadableSize(data_uncompressed_bytes) AS data_uncompressed_bytes, formatReadableSize(data_compressed_bytes) AS data_compressed_bytes, formatReadableSize(primary_key_bytes_in_memory) AS primary_key_bytes_in_memory, marks, formatReadableSize(bytes_on_disk) AS bytes_on_disk FROM system.parts WHERE (table = 'hits') AND (active = 1) FORMAT Vertical;

数据文件

如上文所述,数据文件存储分为宽格式(Wide format)或紧凑格式(Compact format),在宽格式中,每一列都存储在文件系统中单独的数据文件里,在紧凑格式中,所有列都存储在相同的数据文件中,紧凑格式可用于提高小批量且频繁插入操作的性能,而宽格式使用更加普遍和常见,该格式由表引擎的参数 min_bytes_for_wide_part min_rows_for_wide_part 的值来控制,如果数据部分中的字节数或行数小于相应设置的值,该部分以紧凑格式存储,否则以宽格式存储,如果这些设置均未设置,则数据部分以宽格式存储。
数据文件分为两部分,一部分是 *.bin 文件,这些文件包含了 Part 中所有值的原始数据,一部分是 *.mrk/mrk2/mrk3 文件(分别为不同的标记文件版本),这些是标记文件(Mark files),用于存储索引信息,使得ClickHouse 可以快速定位到 .bin 文件中的特定数据段,而不需要扫描整个文件。如果 compress_marks 设置为 1 则会开启标记文件的压缩,那么标记文件会被压缩并存储为格式 *.cmrk/cmrk2/cmrk3 的文件。

索引文件

ClickHouse 的主键索引存储在文件 primary.idx 中,如果参数 compress_primary_key 设置为 1,则会开启对此文件的压缩,存储在 primary.cidx 中。此文件存储着该 Part 的主键索引,当 Part 合并时,合并部分的主键索引也会合并。
ClickHouse 的主键索引跟传统关系型数据库系统并不相同,因为二者设计的出发点不同,它的主键索引是为了处理海量数据量的插入和查询而设计和优化的,因此记录的不是每一行的索引,而是当前Part 中一系列行(称为 Granule)的索引,也就是记录的是每个 Granule 中第一行的主键值(通常是 Granule 中的主键的最小值),也就是所谓的稀疏索引,这种方式能够减小索引文件所占空间大小,让它常驻内存以加快查询效率,同时索引项按照主键的字典序列排序,因此可以通过二分查找法快速定位到所要读取数据所在的 Granule,进而间接减少了读取磁盘文件的 IO,提高查询性能。
ClickHouse 中除了主键索引页可以设置其他索引以加快数据处理速度,比如 Data Skipping Indexes,详情此处略去。

Granule(颗粒)

Granule 是 ClickHouse 中非常重要的一个概念,从上文得知,ClickHouse 中表的数据会被分成多个列,一个 Part 中的每个列在逻辑上会被划分为多个 Granule,每个 Granule 包含了这个列一定数量的行,可以通过参数 index_granularityindex_granularity_bytes 设置。
Granule 是 ClickHouse 进行数据处理的最小不可分割的集合,每次处理数据都会读取至少一个 Granule 到内存中,在写入数据的时候,多个 Granule 会被写入一个 Block 然后压缩并物理存储到文件中。

第五层的文件说明

文件名
说明
*.idx/cidx
索引文件
*.mrk/cmrk
标记文件
*.bin
压缩后数据文件
*.txt
元数据文件
serialization.json
序列化成 Json 的列的元数据,内容为 {"columns": [{ "kind": "Default", "name": "Url", "num_defaults": 0, "num_rows": 10000 }, …], "version": 0}

总结

第一部分我们主要先从物理文件入手,了解重要的基本概念,为下一部分中详细讲解 MergeTree 引擎工作原理时各部分如何配合做好准备。
  • 数据库
  • Architecture
  • 详解 ClickHouse 中的 MergeTree 引擎工作原理-2
    解析分布式事务解决方案 - Saga 模式