📅 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:一种快速的无损压缩算法,适合对数据进行快速压缩和解压缩。适用于对速度要求较高的场景,但压缩率相对较低。
  • LZ4HCLZ4的高压缩率版本,具有更高的压缩率,但压缩和解压缩速度相对较慢。
  • ZSTD:一种高性能的无损压缩算法,与 LZ4相比,具有更高的压缩率和更低的解压缩时间。适用于对存储空间要求较高的场景。
  • ZSTDFastZSTD的快速解压缩版本,适用于对解压缩速度有较高要求的场景。
  • NONE:不使用压缩,适用于对读写速度要求极高,但对存储空间要求不高的场景。
  • Delta:增量压缩算法,适合对有序数据进行增量压缩和解压缩,如连续递增或递减的数值类型数据。
  • DoubleDelta:适用于缓慢变化的序列,如时间序列数据,对于递增序列效果很好。
  • Gorilla:适用于缓慢变化的数值类型数据,通过计算当前值与前一个值的异或来实现压缩。
  • T64:对数据进行64位整数压缩的算法,适合对64位整数类型的数据进行高效压缩和解压缩。

基本原理

LZ4基于 LZ77压缩算法,通过在输入流中查找重复的字节序列来实现压缩。它使用一个哈希表来记录最近出现的字节序列,当发现重复时,就用较小的符号来表示这些字节序列,从而减少数据量。

压缩过程

  1. 初始化哈希表:哈希表用于记录输入数据中出现的字节序列及其位置。
  2. 扫描输入数据:逐个字节扫描输入数据,当遇到一个新的字节序列时,检查它是否已经在哈希表中出现过。
  3. 查找重复序列:如果找到重复的字节序列,就用一个较短的符号(如偏移量和长度)来代替这些重复序列,从而实现压缩。
  4. 生成压缩数据:将符号和偏移量等信息打包成压缩数据块。

2️⃣ 压缩数据块介绍

一个压缩数据块由头信息和压缩数据两部分组成。头信息固定使用9位字节表示,具体由1个UInt8(1字节)整型和2个UInt32(4字节)整型组成,分别代表使用的压缩算法类型、压缩后的数据大小和压缩前的数据大小
image-20250318220223795

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,则整个写入过程遵循以下规则

  1. 单个批次数据 size<64KB :如果单个批次数据小于 64KB,则继续获取下一批数据,直至累积到 size>=64KB时,生成下一个压缩数据块
  2. 单个批次数据 64KB<=size<=1MB :如果单个批次数据大小恰好在64KB与1MB之间,则直接生成下一个压缩数据块
  3. 单个批次数据 size>1MB :如果单个批次数据直接超过1MB,则首先按照1MB大小截断并生成下一个压缩数据块。剩余数据继续依照上述规则执行。此时,会出现一个批次数据生成多个压缩数据块的情况

如果索引为 8192 条,但是他的数据不满足 64k,会读取下一个索引的一部分值,然后进行压缩吗,在假如索引的值大于了 1MB,他会切割到 1MB 数据然后切割剩余的数据假如下一个索引之后被压缩吗?

在 MergeTree 的数据写入过程中,数据是按照索引粒度(默认 8192 行)进行处理和压缩的。以下是针对你提到的几种情况的详细说明:

  1. 索引粒度为 8192 行,但数据大小不满足 64k

处理方式:如果一个索引粒度(8192 行)的数据大小小于 64k,MergeTree 会继续读取下一个索引粒度的数据,直到数据大小超过 64k,然后生成一个压缩块。
示例:假设每个索引粒度的数据大小为 30k,那么 MergeTree 会继续读取下一个索引粒度,直到累计的数据大小超过 64k(例如,两个索引粒度的数据大小为 60k,仍然会继续读取,直到超过 64k)。

  1. 索引粒度的数据大小大于 1MB

处理方式:如果一个索引粒度的数据大小超过 1MB,MergeTree 会将数据切割成多个 1MB 的块,然后对每个块进行压缩。
示例:假设一个索引粒度的数据大小为 1.5MB,MergeTree 会将其切割成两个块:第一个块大小为 1MB,第二个块大小为 0.5MB。然后对每个块分别进行压缩。

  1. 下一个索引粒度的数据是否会被压缩

处理方式:如果当前索引粒度的数据大小超过 1MB,切割后的剩余数据(例如 0.5MB)会继续按照上述规则处理。如果剩余数据加上下一个索引粒度的数据大小仍然小于 64k,MergeTree 会继续读取下一个索引粒度的数据,直到数据大小超过 64k,然后生成一个压缩块。
示例:假设当前索引粒度的数据大小为 1.5MB,切割后剩余 0.5MB。如果下一个索引粒度的数据大小为 20k,那么 MergeTree 会将 0.5MB 和 20k 合并,继续读取下一个索引粒度的数据,直到累计的数据大小超过 64k。

image-20250318221306834

在.bin文件中引入压缩数据块的目的至少有以下两个:

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

🍑 数据标记

如果把

MergeTree比作一本书 primary.idx一级索引好比这本书的一级章节目录 .bin文件中的数据好比这本书中的文字,那么数据标记(.mrk)会为一级章节目录和具体的文字之间建立关联。对于数据标记而言,它记录了两点重要信息:其一 是一级章节对应的页码信息;其二 是一段文字在某一页中的起始位置信息

1️⃣ 数据标记生成规则

数据标记作为衔接一级索引和数据的桥梁,其像极了做过标记小抄的书签,而且书本中每个一级章节都拥有各自的书签,而且数据标记和索引区间是对齐的,均按照

index_granularity的粒度间隔

为了能够与数据衔接,数据标记文件也与

.bin文件一一对应。即每 一个列字段 [Column].bin文件都有一个与之对应的 [Column].mrk数 据标记文件,用于记录数据在 .bin文件中的偏移量信息

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

注意如下偏移量应该是有错的 应该是12008而不是12016

image-20250318222309135
每一行标记数据都表示了一个片段的数据(默认8192行)在

.bin压缩文件中的读取位置信息。标记数据与一级索引数 据不同,它并不能常驻内存,而是使用 LRU(最近最少使用)缓存策略加快其取用速度

2️⃣ 数据标记的方式

MergeTree在读取数据时,必须通过标记数据的位置信息才能够找到所需要的数据。整个查找过程大致可以分为读取压缩数据块和读取数据两个步骤

注意如下偏移量应该是有错的 应该是12008而不是12016

image-20250318223309131

MergeTree定位压缩数据块并读取数据:

  1. 读取压缩数据块: 在查询某一列数据时 MergeTree无须一次性加载整个.bin文件,而是可以根据需要,只加载特定的压缩数据块。而这项特性需要借助标记文件中所保存的压缩文件中的偏移量

    在上图所示的标记数据中,上下相邻的两个压缩文件中的起始偏移量,构成了与获取当前标记对应的压缩数据块的偏移量区间。由当前标记数据开始,向下寻找,直到找到不同的压缩文件偏移量为止。此时得到的一组偏移量区间即是压缩数据块在.bin文件中的偏移量。例如在上图图所示中,读取右侧.bin文件中[0,12016]字节数据,就能获取第0个压缩数据块

.mrk文件中,第0个压缩数据块的截止偏移量是1208。而在.bin数据文件中,第0个压缩数据块的压缩大小是12000。为什么两个数值不同呢?其实原因很简单,12000只是数据压缩后的字节数,并没有包含头信息部分。而一个完整的压缩数据块是由头信息加上压缩数据组成的,它的头信息固定由9个字节组成,压缩后大小为8个字节

  1. 读取数据: 在读取解压后的数据时 MergeTree并不需要一次性扫描整段解压数据,它可以根据需要,以 index_granularity的 粒度加载特定的一小段。为了实现这项特性,需要借助标记文件中保存的解压数据块中的偏移量

    同样的,在上

    图所示的标记数据中,上下相邻两个解压缩数据 块中的起始偏移量,构成了与获取当前标记对应的数据的偏移量区 间。通过这个区间,能够在它的压缩块被解压之后,依照偏移量按需 读取数据。例如在图6-19所示中,通过[0,8192]能够读取压缩数据 块0中的第一个数据片段