Clickhouse的数据存储原理、二进制文件内容分析与索引详解

Clickhouse以其强大的性能著称,已经被越来越多的使用在OLAP分析查询等场景中。Clickhouse是一个列式存储的数据库,而列式存储数据库的一个优势就是查询速度快,只需要检索需要查询的列即可,但是插入时的性能会相对较慢。面向列的数据库更适合OLAP场景:它们处理大多数查询的速度至少快100倍。

使用一个官方的动态图来做对比二者的性能(客观性大家自己判断):

官方配图

列式存储:

官方配图

本次主要对Clickhouse中的数据存储原理进行剖析,也是加深自己对这方面的部分理解。

数据存储

ClickHouse 中有众多的不同特性的表引擎可以应对不同的需要,其中 MergeTree 引擎作为 ClickHouse 的核心,凭借其强大的性能与丰富的特性得到了广泛的使用,并成为其他特性引擎的基础。后续将基于 MergeTree 引擎进行讨论。

由于 ClickHouse 的更新迭代较快,相邻版本之间的数据存储结构以及特性会有变化,但基本原理相通,本文基于 22.6.5 版本做演示和说明。

创建数据表

CREATE TABLE test.users2
(`name` String,`sex` String,`age` Int8,`birthday` Date
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(birthday)
ORDER BY age
SETTINGS min_rows_for_wide_part = 0, min_bytes_for_wide_part = 0,index_granularity = 2

注意:这里在建表时一定要加最后的SETTINGS内容。因此ClickHouse新版本有compact功能,可以提高插入量少插入频率频繁时的性能。但是底层就不会每一列都生成一个.bin文件了。只会生成一个统一的data.bin。所以我们先关闭了compact功能(min_rows_for_wide_part = 0, min_bytes_for_wide_part = 0),方便讲解。

接下来插入一条数据:

insert into test.users values ('zhangsan', 'female', 20, '2023-03-07');

进入到/var/lib/clickhouse/data/test/users路径下,观察生成的文件:

可以看到生成了一个202303_1_1_0的分区目录。

分区目录命名规则

在介绍这些文件之前先介绍一下202303_1_1_0这个目录的命名规则

PartitionId_MinBlockNum_MaxBlockNum_Level
  • PartitionId,分区Id。其值是由创建表时所指定的分区键决定的,由于我们创建表时使用的分区键为toYYYYMM(birthday),而插入的birthday为2023-03-08,所以其值为202303。
  • MinBlockNum、MaxBlockNum,最小最大数据块编号。其值在单张表内从1开始累加,每当新创建一个分区目录其值就会加1,且新创建的分区目录MinBlockNum和MaxBlockNum相等,只有当分区目录发生合并时其值才会不等。由于这是该表第一次插入数据,所以MinBlockNum和MaxBlockNum都为1。
  • Level,分区被合并的次数。level和MinBlockNum以及MaxBlockNum不同,它不是单张表内累加的,而是单张表中的单个分区内累加的。每当新创建一个分区目录其值均为0,只有当分区目录发生合并时其值才会大于0。

这里为了更好的理解这些值,我们再尝试插入两条相同数据后发现,每次插入都生成了一个新的分区目录。同时,MinBlockNum和MaxBlockNum在自增。

由于MergeTree引擎对于分区的合并是不定时的,因此这里为了更快的尝试对分区进行合并后的结果,我们使用optimize命令强制合并分区

optimize table users final;

命令执行后,再去到上面的目录中观察,可以发现,多了一个202303_1_3_1的分区目录,也就是合并后的目录。这里的第一个1和3,表示MinBlockNum,MaxBlockNum,因为合并的3个分区中,MinBlockNum=1,MaxBlockNum=3。最后一个表示Level,也就是分区被合并的次数,由于只合并了一次,所以此时level=1。

通过实验与上面各参数说明中进行了一一对应。同时,过了一段时间后,其他的几个分区会被删除,只保留合并后的分区

文件结构

进入到上述的文件路径中,利用tree命令查看目录下的文件结构:

[root@linux121 202303_1_3_1]# tree
.
├── age.bin
├── age.mrk2
├── birthday.bin
├── birthday.mrk2
├── checksums.txt
├── columns.txt
├── count.txt
├── default_compression_codec.txt
├── minmax_birthday.idx
├── name.bin
├── name.mrk2
├── partition.dat
├── primary.idx
├── sex.bin
└── sex.mrk20 directories, 15 files

说明:

  • checksums.txt: 校验文件,二进制存储了各文件的大小、哈希等,用于快速校验存储的正确性。
  • columns.txt: 列名以及数据类型,本例该文件的内容为:
0 directories, 15 files
[root@linux121 202303_1_3_1]# cat columns.txt
columns format version: 1
4 columns:
`name` String
`sex` String
`age` Int8
`birthday` Date
  • count.txt: 记录数据的总行数,本例中文件内容为 2(只写入了两行数据)。

  • primary.idx: 主键索引文件,用于存放稀疏索引的数据。通过查询条件与稀疏索引能够快速的过滤无用的数据,减少需要加载的数据量。

  • {column}.bin: 实际列数据的存储文件,以列名+bin为文件名,默认设置采用 lz4 压缩格式。每一列都会有单独的文件,此种方式为wide part模式。另外一种模式是compact part模式,这种模式下所有的列的数据放在一个文件data.bin里面。(新版本需要指定 SETTINGS min_rows_for_wide_part = 0, min_bytes_for_wide_part = 0 参数来强制跳过 Compact format)。

  • {column}.mrk2: 列数据的标记信息,记录了数据块在 bin 文件中的偏移量。标记文件首先与列数据的存储文件对齐,记录了某个压缩块在 bin 文件中的相对位置;其次与索引文件对齐,记录了稀疏索引对应数据在列存储文件中的位置。clickhouse 将首先通过索引文件定位到标记信息,再根据标记信息直接从.bin 数据文件中读取数据。

  • Partition.dat: 保存一个值,就是partition的编号,从0开始。

    [root@linux121 202303_1_3_1]# od -An -l partition.dat202303
    
  • minmax_[Column].idx: minmax索引。用于记录当前分区下分区字段的最小最大值

二进制文件格式详解

在介绍了上述目录下的每个文件的功能和作用后,我们一起来看看上面各项文件中,具体存储了什么信息,以及如何去分析这些二进制文件。

在讲解之前,为了更好的看清文件内容,我们一共在users表中插入5条数据:

insert into test.users values ('zhangsan', 'female', 20, '2023-03-07');
insert into test.users values ('lisi', 'female', 22, '2023-03-06');
insert into test.users values ('lilei', 'female', 25, '2023-03-06');
insert into test.users values ('xiaoming', 'female', 27, '2023-03-09');
insert into test.users values ('yaya', 'female', 30, '2023-03-07');SELECT *
FROM users
ORDER BY age DESCQuery id: ee21ad84-be21-47cf-92bd-55059d59fa6e┌─name─┬─sex────┬─age─┬───birthday─┐
│ yaya │ female │  30 │ 2023-03-07 │
└──────┴────────┴─────┴────────────┘
┌─name─────┬─sex────┬─age─┬───birthday─┐
│ xiaoming │ female │  27 │ 2023-03-09 │
│ lilei    │ female │  25 │ 2023-03-06 │
│ lisi     │ female │  22 │ 2023-03-06 │
│ zhangsan │ female │  20 │ 2023-03-07 │
└──────────┴────────┴─────┴────────────┘5 rows in set. Elapsed: 0.002 sec.
  • primary.idx

    MergeTree表会根据排序键生成primary.idx表,由users的建表语句可知,我们设置排序的键为age,同时index_granularity = 2。因此对应primary.idx中生成的记录应该为20、25、30三条记录。接下来,我们进入到primary.idx文件中,看下记录的内容是不是我们逻辑上分析的结果:

    hexdump -C primary.idx
    00000000  14 19 1e 1e                                       |....|
    00000004
    

    hexdump可以用来查看二进制文件的十六进制编码。由于定义的age Int8占用1个字节,因此上面的每一个16进制值应该对应于一个Age值。 对上面的十六进制结果做一下转换:

    • 14(十六进制)->20(十进制)
    • 19(十六进制)->25(十进制)
    • 1e(十六进制)->30(十进制)

    可以发现,转换成10进制后跟我们一开始的逻辑分析结果一致。证明了我们的猜想,更进一步核实了Clickhouse中primary.idx稀疏索引的原理。

  • {column}.mrk2

    一个{column}.bin文件有1至多个数据压缩块组成,mark2数据标记文件格式比较固定,primary.idx文件中的每个索引在此文件中都有一个对应的Mark,有三列:

    1. Offset in compressed file,8 Bytes,代表该标记指向的压缩数据块在bin文件中的偏移量。
    2. Offset in decompressed block,8 Bytes,代表该标记指向的数据在解压数据块中的偏移量。
    3. Rows count,8 Bytes,行数,通常情况下其等于index_granularity。

因此,每一行mrk2文件共占用24 Bytes。所以通过primary.idx中的索引寻找mrk2文件中对应的Mark非常简单,如果要寻找第n(从0开始)个index,则对应的Mark在mrk2文件中的偏移为n*24,从这个偏移处开始读取24 Bytes即可得到相应的Mark。

age.mrk2

$ hexdump -C age.mrk2
00000000  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
00000010  02 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
00000020  02 00 00 00 00 00 00 00  02 00 00 00 00 00 00 00  |................|
00000030  00 00 00 00 00 00 00 00  04 00 00 00 00 00 00 00  |................|
00000040  01 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
00000050  05 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
00000060$ od -An -l age.mrk20                    02                    02                    20                    41                    05                    0

分析下上面的文件内容。由于24 Bytes表示一行数据,因此从上面以每24位做切分,可以得到如下所示的表:

下标 压缩文件中的偏移量 解压文件中的偏移量 行数
0 0 0 2
1 0 2 2
2 0 4 1
3 0 0 0

从表中可以看出,解压文件中的偏移量对应的原表中的age值这与我们在primary.idx中分析出来的3个索引值一一对应。

  • index_granularity =2,且定义的age Int8占用1个字节,所以每两行对应一个索引,mrk2中也是没两行生成一条对应mark。

  • 由于本次存储的数据 < 默认的压缩大小块64KB,因此所有的数据都在一个压缩块内,压缩文件中的偏移量都是0

  • 由于最后一个mark对应的数据只有一条,所以最后一个行数为1

  • {column}.bin

    {column}.bin文件由若干数据块组成,默认使用LZ4压缩格式,用于存储某一列数据。

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

    如:0x821200065536

    0x82:是压缩方法

    12000:压缩后数据大小

    65536:压缩前数据大小

    {column}.bin文件存储如下:

age.bin文件查看:

  • 第一行16个字节是该文件的checksum值
  • 第二行(以Id.bin为例)
    • 第一个字节是0x82,是默认的LZ4算法
    • 第2个到第5个字节是压缩后的数据块的大小,这里是小端模式,Int64占4个字节, 倒着就是 00 00 00 1f = 15
    • 第6个字节到第9个字节是压缩前的数据块大小,同理00 00 00 05=5
    • 与 clickhouse-compressor --stat < Id.bin得到的结果一致
    • 第11~15个字节表示的数:14 16 19 1b 1e,就对应了表中age的22、22、25、27、30
$ hexdump -C age.bin
00000000  df 29 49 be ab 5f 11 25  36 3d 24 53 d0 e4 ff f5  |.)I.._.%6=$S....|
00000010  82 0f 00 00 00 05 00 00  00 50 14 16 19 1b 1e     |.........P.....|
0000001f$ clickhouse-compressor --stat <./age.bin | cat
5   15

这里可以发现,数据被压缩后的大小,反而大于压缩前的大小。这是因为我们测试的数据比较少,只有5条数据,而压缩时,还有一些必要的信息要压缩,所以压缩后会比压缩前大。

  • minmax_birthday.idx

    minmax文件里面存放的是该分区里分区字段的最小最大值。分区字段birthday的类型为Date,其底层由UInt16实现,存的是从1970年1月1号到现在所经过的天数。通过上面的INSERT语句我们可以知道Birthday的最小值为2023-03-06,最大值为2023-03-09。这两个时间转换成天数分别为19422和19425,再转换成16进制就是0x4bde和0x4be1。

    $ hexdump -C  minmax_birthday.idx
    00000000  de 4b e1 4b                                       |.K.K|
    00000004$ od -An -td2 -w4 minmax_birthday.idx19422  19425
    

这里,我们就逐一分析了primary.idx,{column}.mrk2,{column}.bin这三个主要文件包含的内容,可以更进一步的理解Clickhouse数据存储的基本原理。其他的文件也是类似的分析步骤,都比较简单了,就不再赘述。

索引过程

在对Clickhouse数据文件的结构和内容有过了解后,接下来探究基于这些数据文件利用索引来查询数据的过程。

通过上面对primary.idx和mrk2、bin文件的分析我们知道,对于每一个primary.idx中的索引,mrk2都有一条记录与之对应。而从mrk2中就可以找到bin文件中的压缩数据块和解压缩后的数据索引。

如果按照默认8192的索引粒度把数据分成批次,每批次读入数据的规则:

设x为批次数据的大小,

  • 如果单批次获取的数据 x<64k,则继续读下一个批次,找到size>64k则生成下一个数据块
  • 如果单批次数据64k<x<1M则直接生成下一个数据块
  • 如果x>1M,则按照1M切分数据,剩下的数据继续按照上述规则执行。

查询过程

下图展示了一个大致的查询流程:

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

上图中,以JavaEnable字段说明:1 b * 8192 = 8192b 8192b * 8 = 64k,相当于8个8192条数据形成一个数据块。而第二个数据块的压缩文件的偏移量是从12016 Bytes开始的,原因是加上了两个数据块的头文件部分(假设第一个数据块压缩后的大小为12000Bytes),压缩后的头文件大小为8 Bytes,以此类推。而未压缩大小的65536大小则表示这里每一块数据块的大小都正好是64K大小。

有了mrk2,MergeTree在读取数据时并不需要将数据一次性加载到内存。定位压缩数据块并读取数据的过程:

  • 读取压缩数据块:在查询某一列数据MergeTree无须一次性加载整个.bin文件。借助标记文件中的压缩文件偏移量加载指定的数据压缩块。
  • 读取数据:解压后的数据,MergeTree并不需要一次性扫描整段解压数据,借住标记文件中保存的数据块中偏移量以index_granularity的粒度加载特定一小段

数据标记与压缩数据块的对应关系

  • 一对一

此时,每一个压缩数据块都是64KB。这里图中压缩数块中,头信息数组的前一个位置65536表示解压后的大小,第二个43311表示压缩后的大小。

  • 多对一

  • 一对多

即mrk2中的一行对应两个压缩数据块中的数据。

Clickhouse的数据存储原理、二进制文件内容分析与索引详解相关推荐

  1. greendao连接mysql_Android数据存储框架GreenDao 3.0使用详解

    GreenDao 3.0 的使用 1.简介 简单的讲,GreenDao是一个将对象映射到 SQLite数据库中的轻量且快速的 ORM 解决方案.(GreenDaois a light & fa ...

  2. 基于位表示的8叉树数据存储原理

    3D物体建模在计算机辅助设计系统.医疗系统.机器人以及物体自动检测中扮演了越来越重要的角色.在这其中,八叉树是一种非常有效的储存数据的方法,尤其是对于像流形物体.点云图(point cloud).体素 ...

  3. 搞定 Redis 数据存储原理,别只会 set、get 了

    在上一篇通过源码编译构建出可调式环境之后,想必你想更深入了解我的整体架构.当你熟悉我的整体架构和每个模块,遇到问题才能直击本源,直捣黄龙,一笑破苍穹. 我的核心模块如图 1-10. 图1-10 图 1 ...

  4. MySQL存储引擎 lnnoDB逻辑架构 innodb存储引擎表空间(ibd文件)详解 回滚日志的物理空间

    文章目录 存储引擎 一 MySQL组织架构 二 查看存储引擎信息 三 修改存储引擎 3.1 配置文件修改存储引擎 3.2 临时修改存储引擎 3.3 建表时修改存储引擎 四 存储引擎实验 五 数据库升级 ...

  5. Sniff网络基础原理和软件实现技巧详解

    Sniff网络基础原理和软件实现技巧详解 前言 SNIFF真是一个古老的话题,关于在网络上采用SNIFF来获取敏感信息已经不是什么新鲜事,也不乏很多成功的案例,那么,SNIFF究竟是什么呢? SNIF ...

  6. 【 卷积神经网络CNN 数学原理分析与源码详解 深度学习 Pytorch笔记 B站刘二大人(9/10)】

    卷积神经网络CNN 数学原理分析与源码详解 深度学习 Pytorch笔记 B站刘二大人(9/10) 本章主要进行卷积神经网络的相关数学原理和pytorch的对应模块进行推导分析 代码也是通过demo实 ...

  7. 强化学习教程(四):从PDG到DDPG的原理及tf代码实现详解

    强化学习教程(四):从PDG到DDPG的原理及tf代码实现详解 原创 lrhao 公众号:ChallengeHub 收录于话题 #强化学习教程 前言 在前面强化学习教程(三)中介绍了基于策略「PG」算 ...

  8. 【存储】SCSI、iSCSI协议详解及对比

    作        者:  @NGDCN 原文链接:[存储]SCSI.iSCSI协议详解及对比 - NGDCN 版        权: 本文由 @NGDCN 于2022-11-11原创发布在 NGDCN ...

  9. 第41课:Checkpoint彻底解密:Checkpoint的运行原理和源码实现彻底详解

    第41课:Checkpoint彻底解密:Checkpoint的运行原理和源码实现彻底详解 一:Checkpoint到底是什么? 1,  Spark在生产环境下经常会面临Tranformations的R ...

最新文章

  1. 数据挖掘深入理解和学习路径
  2. 信号量 互斥量 条件变量
  3. 图像分类matlab结果,libsvm图像二分类后,分类的图像结果如何显示?
  4. python3 字典 dict 创建 添加 修改 复制 取值 删除
  5. CodeForces - 528D Fuzzy Search(多项式匹配字符串)
  6. jdbc连接mysql数据库的常用对象_JDBC常用对象
  7. bzoj3482,jzoj3238-超时空旅行hiperprostor【最短路,凸包,斜率优化】
  8. html如何与php,html页面怎么跟php文件连接
  9. 发一则自己创作的Lae程序员小漫画,仅供一乐
  10. NUAGENETWORKS第二代SDN方案推企业云服务
  11. 构建大数据网络 需要重视这五个地方
  12. PDF文件不支持直接编辑 PDF如何转化为可以编辑的Word文档
  13. 拓端tecdat|R语言计算资本资产定价模型(CAPM)中的Beta值和可视化
  14. JPA、JTA、XA相关索引
  15. 基于射频的石油热解开采的一些资料
  16. rx550 黑苹果_黑苹果免驱显卡速查表
  17. (精华2020年5月4日更新) vue教程篇 axio的使用
  18. [附源码]java+ssm计算机毕业设计交通违章举报平台lxsqm(源码+程序+数据库+部署)
  19. python 内置函数 和 匿名函数
  20. 用文本挖掘回顾2017年的一些人与事

热门文章

  1. [CQOI2018]九连环
  2. word文档怎么给数字加千分符_word数字千分位的批量添加方法
  3. ASP.NET 一文看懂repeater控件使用方法
  4. ArcGIS10地图包的使用
  5. v6!!!!!!!!!!!
  6. PAT甲级1002 All Roads Lead to Rome
  7. 微信小程序视频无缝衔接
  8. 关于微信公众号被动回复带有表情的文字
  9. Fundamentals of Computer Graphics 4th目录
  10. sldprt文件用什么软件打开(手机sldprt文件查看器)