📅 2025/03/18
读《clickhouse 原理解析与实践》笔记
🏆 MergeTree数据存储和数据标记
🍌 数据存储
在 MergeTree
中数据是按列存储的
1️⃣ 每列独立存储
在
MergeTree
中,数据按列存储。而具体到每个列字段,数据也是独立存储的,每个列字段都拥有一个与之对应的 .bin
数据文件。也正是这些 .bin
文件,最终承载着数据的物理存储,数据文件以分区目录的形式被组织存放,所以在 .bin
文件中只会保存当前分区片段内的这一部分数据;
列式存储有两点优点: 一是可以更好地进行数据压缩(相同类型的数据放在一起,对压缩更加友好),二是能够最小化数据扫描的范围
MergeTree
也并不是一股脑地将数据直接写入 .bin
文件,而是经过了一番精心设计:首先,数据是经过压缩的,目前支持 LZ4、LZ4HC、ZSTDFast、ZSTD、Multiple、NONE,和Delta几
种算法,默认使用 LZ4
算法;其次,数据会事先依照 ORDER BY
的声明排序;最后,数据是以压缩数据块的形式被组织并写入.bin文件中的。
这几种压缩算法优点如下:
- LZ4:一种快速的无损压缩算法,适合对数据进行快速压缩和解压缩。适用于对速度要求较高的场景,但压缩率相对较低。
- LZ4HC:
LZ4
的高压缩率版本,具有更高的压缩率,但压缩和解压缩速度相对较慢。 - ZSTD:一种高性能的无损压缩算法,与
LZ4
相比,具有更高的压缩率和更低的解压缩时间。适用于对存储空间要求较高的场景。 - ZSTDFast:
ZSTD
的快速解压缩版本,适用于对解压缩速度有较高要求的场景。 - NONE:不使用压缩,适用于对读写速度要求极高,但对存储空间要求不高的场景。
- Delta:增量压缩算法,适合对有序数据进行增量压缩和解压缩,如连续递增或递减的数值类型数据。
- DoubleDelta:适用于缓慢变化的序列,如时间序列数据,对于递增序列效果很好。
- Gorilla:适用于缓慢变化的数值类型数据,通过计算当前值与前一个值的异或来实现压缩。
- T64:对数据进行64位整数压缩的算法,适合对64位整数类型的数据进行高效压缩和解压缩。
基本原理
LZ4
基于LZ77
压缩算法,通过在输入流中查找重复的字节序列来实现压缩。它使用一个哈希表来记录最近出现的字节序列,当发现重复时,就用较小的符号来表示这些字节序列,从而减少数据量。压缩过程
- 初始化哈希表:哈希表用于记录输入数据中出现的字节序列及其位置。
- 扫描输入数据:逐个字节扫描输入数据,当遇到一个新的字节序列时,检查它是否已经在哈希表中出现过。
- 查找重复序列:如果找到重复的字节序列,就用一个较短的符号(如偏移量和长度)来代替这些重复序列,从而实现压缩。
- 生成压缩数据:将符号和偏移量等信息打包成压缩数据块。
2️⃣ 压缩数据块介绍
一个压缩数据块由头信息和压缩数据两部分组成。头信息固定使用9位字节表示,具体由1个UInt8(1字节)整型和2个UInt32(4字节)整型组成,分别代表使用的压缩算法类型、压缩后的数据大小和压缩前的数据大小

clickhouse-compressor
工具,能够查询某个 .bin
文件中压缩数据的统计信息,其中每一行数据代表着一个压缩数据块的头信息,其分别表示该压缩块的算法、压缩后数据大小、未压缩数据大小
# clickhouse-compressor --stat < data.bin
CODEC(LZ4) 12 22
CODEC(LZ4) 14 24
CODEC(LZ4) 2 12
每个压缩数据块的体积,按照其压缩前的数据字节大小,都被严格控制在
64KB~1MB
,其上下限分别由 min_compress_block_size
(默认65536)与 max_compress_block_size
(默认1048576)参数指定。而一个压缩数据块最终的大小,则和一个间隔 (index_granularity
)内数据的实际大小相关
MergeTree
在数据具体的写入过程中,会依照索引粒度(默认情况下,每次取 8192
行),按批次获取数据并进行处理。如果把一批数 据的未压缩大小设为 size
,则整个写入过程遵循以下规则
- 单个批次数据
size<64KB
:如果单个批次数据小于64KB
,则继续获取下一批数据,直至累积到size>=64KB
时,生成下一个压缩数据块 - 单个批次数据
64KB<=size<=1MB
:如果单个批次数据大小恰好在64KB与1MB之间,则直接生成下一个压缩数据块 - 单个批次数据
size>1MB
:如果单个批次数据直接超过1MB,则首先按照1MB大小截断并生成下一个压缩数据块。剩余数据继续依照上述规则执行。此时,会出现一个批次数据生成多个压缩数据块的情况
如果索引为 8192 条,但是他的数据不满足 64k,会读取下一个索引的一部分值,然后进行压缩吗,在假如索引的值大于了 1MB,他会切割到 1MB 数据然后切割剩余的数据假如下一个索引之后被压缩吗?
在 MergeTree 的数据写入过程中,数据是按照索引粒度(默认 8192 行)进行处理和压缩的。以下是针对你提到的几种情况的详细说明:
- 索引粒度为 8192 行,但数据大小不满足 64k
处理方式:如果一个索引粒度(8192 行)的数据大小小于 64k,MergeTree 会继续读取下一个索引粒度的数据,直到数据大小超过 64k,然后生成一个压缩块。
示例:假设每个索引粒度的数据大小为 30k,那么 MergeTree 会继续读取下一个索引粒度,直到累计的数据大小超过 64k(例如,两个索引粒度的数据大小为 60k,仍然会继续读取,直到超过 64k)。
- 索引粒度的数据大小大于 1MB
处理方式:如果一个索引粒度的数据大小超过 1MB,MergeTree 会将数据切割成多个 1MB 的块,然后对每个块进行压缩。
示例:假设一个索引粒度的数据大小为 1.5MB,MergeTree 会将其切割成两个块:第一个块大小为 1MB,第二个块大小为 0.5MB。然后对每个块分别进行压缩。
- 下一个索引粒度的数据是否会被压缩
处理方式:如果当前索引粒度的数据大小超过 1MB,切割后的剩余数据(例如 0.5MB)会继续按照上述规则处理。如果剩余数据加上下一个索引粒度的数据大小仍然小于 64k,MergeTree 会继续读取下一个索引粒度的数据,直到数据大小超过 64k,然后生成一个压缩块。
示例:假设当前索引粒度的数据大小为 1.5MB,切割后剩余 0.5MB。如果下一个索引粒度的数据大小为 20k,那么 MergeTree 会将 0.5MB 和 20k 合并,继续读取下一个索引粒度的数据,直到累计的数据大小超过 64k。

在.bin文件中引入压缩数据块的目的至少有以下两个:
- 其一,虽然数据被压缩后能够有效减少数据大小,降低存储空间并加速数据传输效率,但数据的压缩和解压动作,其本身也会带来额外的性能损耗。所以需要控制被压缩数据的大小,以求在性能损耗和压缩率之间寻求一种平衡。
- 其二,在具体读取某一列数据时(.bin文件),首先需要将压缩数据加载到内存并解压,这样才能进行后续的数据处理。通过压缩数据块,可以在不读取整个.bin文件的情况下将读取粒度降低到压缩数据块级别,从而进一步缩小数据读取的范围

🍑 数据标记
如果把
MergeTree
比作一本书 primary.idx
一级索引好比这本书的一级章节目录 .bin
文件中的数据好比这本书中的文字,那么数据标记(.mrk)会为一级章节目录和具体的文字之间建立关联。对于数据标记而言,它记录了两点重要信息:其一 是一级章节对应的页码信息;其二 是一段文字在某一页中的起始位置信息
1️⃣ 数据标记生成规则
数据标记作为衔接一级索引和数据的桥梁,其像极了做过标记小抄的书签,而且书本中每个一级章节都拥有各自的书签,而且数据标记和索引区间是对齐的,均按照
index_granularity
的粒度间隔
为了能够与数据衔接,数据标记文件也与
.bin
文件一一对应。即每 一个列字段 [Column].bin
文件都有一个与之对应的 [Column].mrk
数 据标记文件,用于记录数据在 .bin
文件中的偏移量信息

一行标记数据使用一个元组表示,元组内包含两个整型数值的偏移量信息。它们分别表示在此段数据区间内,在对应的.bin压缩文件中,压缩数据块的起始偏移量(也就输压缩了数据的大小);以及将该数据压缩块解压后,其未压缩数据的起始偏移量
注意如下偏移量应该是有错的 应该是12008而不是12016

每一行标记数据都表示了一个片段的数据(默认8192行)在
.bin
压缩文件中的读取位置信息。标记数据与一级索引数 据不同,它并不能常驻内存,而是使用 LRU
(最近最少使用)缓存策略加快其取用速度
2️⃣ 数据标记的方式
MergeTree
在读取数据时,必须通过标记数据的位置信息才能够找到所需要的数据。整个查找过程大致可以分为读取压缩数据块和读取数据两个步骤
注意如下偏移量应该是有错的 应该是12008而不是12016
MergeTree定位压缩数据块并读取数据:
-
读取压缩数据块: 在查询某一列数据时
MergeTree
无须一次性加载整个.bin文件,而是可以根据需要,只加载特定的压缩数据块。而这项特性需要借助标记文件中所保存的压缩文件中的偏移量在上图所示的标记数据中,上下相邻的两个压缩文件中的起始偏移量,构成了与获取当前标记对应的压缩数据块的偏移量区间。由当前标记数据开始,向下寻找,直到找到不同的压缩文件偏移量为止。此时得到的一组偏移量区间即是压缩数据块在.bin文件中的偏移量。例如在上图图所示中,读取右侧.bin文件中[0,12016]字节数据,就能获取第0个压缩数据块
在
.mrk
文件中,第0个压缩数据块的截止偏移量是1208。而在.bin数据文件中,第0个压缩数据块的压缩大小是12000。为什么两个数值不同呢?其实原因很简单,12000只是数据压缩后的字节数,并没有包含头信息部分。而一个完整的压缩数据块是由头信息加上压缩数据组成的,它的头信息固定由9个字节组成,压缩后大小为8个字节
-
读取数据: 在读取解压后的数据时
MergeTree
并不需要一次性扫描整段解压数据,它可以根据需要,以index_granularity
的 粒度加载特定的一小段。为了实现这项特性,需要借助标记文件中保存的解压数据块中的偏移量同样的,在上
图所示的标记数据中,上下相邻两个解压缩数据 块中的起始偏移量,构成了与获取当前标记对应的数据的偏移量区 间。通过这个区间,能够在它的压缩块被解压之后,依照偏移量按需 读取数据。例如在图6-19所示中,通过[0,8192]能够读取压缩数据 块0中的第一个数据片段