ClickHouse 架构(简要概述)

红目香薰
新星创作者: 人工智能技术领域
优质创作者: 大数据技术领域
领域专家: 后端开发技术领域
2022-03-02 08:11:54

 

概述

ClickHouse 是一个真正的列式数据库管理系统(DBMS)。在 ClickHouse 中,数据始终是按列存储的,包括矢量(向量或列块)执行的过程。只要有可能,操作都是基于矢量进行分派的,而不是单个的值,这被称为«矢量化查询执行»,它有利于降低实际的数据处理开销。

这个想法并不新鲜,其可以追溯到 APL 编程语言及其后代:A +JK 和 Q。矢量编程被大量用于科学数据处理中。即使在关系型数据库中,这个想法也不是什么新的东西:比如,矢量编程也被大量用于 Vectorwise 系统中。

通常有两种不同的加速查询处理的方法:矢量化查询执行和运行时代码生成。在后者中,动态地为每一类查询生成代码,消除了间接分派和动态分派。这两种方法中,并没有哪一种严格地比另一种好。运行时代码生成可以更好地将多个操作融合在一起,从而充分利用 CPU 执行单元和流水线。矢量化查询执行不是特别实用,因为它涉及必须写到缓存并读回的临时向量。如果 L2 缓存容纳不下临时数据,那么这将成为一个问题。但矢量化查询执行更容易利用 CPU 的 SIMD 功能。朋友写的一篇研究论文表明,将两种方法结合起来是更好的选择。ClickHouse 使用了矢量化查询执行,同时初步提供了有限的运行时动态代码生成。

列(Columns) 

要表示内存中的列(实际上是列块),需使用 IColumn 接口。该接口提供了用于实现各种关系操作符的辅助方法。几乎所有的操作都是不可变的:这些操作不会更改原始列,但是会创建一个新的修改后的列。比如,IColumn::filter 方法接受过滤字节掩码,用于 WHERE 和 HAVING 关系操作符中。另外的例子:IColumn::permute 方法支持 ORDER BY 实现,IColumn::cut 方法支持 LIMIT 实现等等。

不同的 IColumn 实现(ColumnUInt8ColumnString 等)负责不同的列内存布局。内存布局通常是一个连续的数组。对于数据类型为整型的列,只是一个连续的数组,比如 std::vector。对于 String 列和 Array 列,则由两个向量组成:其中一个向量连续存储所有的 String 或数组元素,另一个存储每一个 String 或 Array 的起始元素在第一个向量中的偏移。而 ColumnConst 则仅在内存中存储一个值,但是看起来像一个列。

字段 

尽管如此,有时候也可能需要处理单个值。表示单个值,可以使用 FieldField 是 UInt64Int64Float64String 和 Array 组成的联合。IColumn 拥有 operator[] 方法来获取第 n 个值成为一个 Field,同时也拥有 insert 方法将一个 Field 追加到一个列的末尾。这些方法并不高效,因为它们需要处理表示单一值的临时 Field 对象,但是有更高效的方法比如 insertFrom 和 insertRangeFrom 等。

Field 中并没有足够的关于一个表(table)的特定数据类型的信息。比如,UInt8UInt16UInt32 和 UInt64 在 Field 中均表示为 UInt64

抽象漏洞 

IColumn 具有用于数据的常见关系转换的方法,但这些方法并不能够满足所有需求。比如,ColumnUInt64 没有用于计算两列和的方法,ColumnString 没有用于进行子串搜索的方法。这些无法计算的例程在 Icolumn 之外实现。

列(Columns)上的各种函数可以通过使用 Icolumn 的方法来提取 Field 值,或根据特定的 Icolumn 实现的数据内存布局的知识,以一种通用但不高效的方式实现。为此,函数将会转换为特定的 IColumn 类型并直接处理内部表示。比如,ColumnUInt64 具有 getData 方法,该方法返回一个指向列的内部数组的引用,然后一个单独的例程可以直接读写或填充该数组。实际上,«抽象漏洞(leaky abstractions)»允许我们以更高效的方式来实现各种特定的例程。

数据类型 

IDataType 负责序列化和反序列化:读写二进制或文本形式的列或单个值构成的块。IDataType 直接与表的数据类型相对应。比如,有 DataTypeUInt32DataTypeDateTimeDataTypeString 等数据类型。

IDataType 与 IColumn 之间的关联并不大。不同的数据类型在内存中能够用相同的 IColumn 实现来表示。比如,DataTypeUInt32 和 DataTypeDateTime 都是用 ColumnUInt32 或 ColumnConstUInt32 来表示的。另外,相同的数据类型也可以用不同的 IColumn 实现来表示。比如,DataTypeUInt8 既可以使用 ColumnUInt8 来表示,也可以使用过 ColumnConstUInt8 来表示。

IDataType 仅存储元数据。比如,DataTypeUInt8 不存储任何东西(除了 vptr);DataTypeFixedString 仅存储 N(固定长度字符串的串长度)。

IDataType 具有针对各种数据格式的辅助函数。比如如下一些辅助函数:序列化一个值并加上可能的引号;序列化一个值用于 JSON 格式;序列化一个值作为 XML 格式的一部分。辅助函数与数据格式并没有直接的对应。比如,两种不同的数据格式 Pretty 和 TabSeparated 均可以使用 IDataType 接口提供的 serializeTextEscaped 这一辅助函数。

块(Block) 

Block 是表示内存中表的子集(chunk)的容器,是由三元组:(IColumn, IDataType, 列名) 构成的集合。在查询执行期间,数据是按 Block 进行处理的。如果我们有一个 Block,那么就有了数据(在 IColumn 对象中),有了数据的类型信息告诉我们如何处理该列,同时也有了列名(来自表的原始列名,或人为指定的用于临时计算结果的名字)。

当我们遍历一个块中的列进行某些函数计算时,会把结果列加入到块中,但不会更改函数参数中的列,因为操作是不可变的。之后,不需要的列可以从块中删除,但不是修改。这对于消除公共子表达式非常方便。

Block 用于处理数据块。注意,对于相同类型的计算,列名和类型对不同的块保持相同,仅列数据不同。最好把块数据(block data)和块头(block header)分离开来,因为小块大小会因复制共享指针和列名而带来很高的临时字符串开销。

块流(Block Streams) 

块流用于处理数据。我们可以使用块流从某个地方读取数据,执行数据转换,或将数据写到某个地方。IBlockInputStream 具有 read 方法,其能够在数据可用时获取下一个块。IBlockOutputStream 具有 write 方法,其能够将块写到某处。

块流负责:

  1. 读或写一个表。表仅返回一个流用于读写块。
  2. 完成数据格式化。比如,如果你打算将数据以 Pretty 格式输出到终端,你可以创建一个块输出流,将块写入该流中,然后进行格式化。
  3. 执行数据转换。假设你现在有 IBlockInputStream 并且打算创建一个过滤流,那么你可以创建一个 FilterBlockInputStream 并用 IBlockInputStream 进行初始化。之后,当你从 FilterBlockInputStream 中拉取块时,会从你的流中提取一个块,对其进行过滤,然后将过滤后的块返回给你。查询执行流水线就是以这种方式表示的。

还有一些更复杂的转换。比如,当你从 AggregatingBlockInputStream 拉取数据时,会从数据源读取全部数据进行聚集,然后将聚集后的数据流返回给你。另一个例子:UnionBlockInputStream 的构造函数接受多个输入源和多个线程,其能够启动多线程从多个输入源并行读取数据。

我们需要注意,查询执行流水线在每一步都会创建临时数据。我们要尽量使块的大小足够小,从而 CPU 缓存能够容纳下临时数据。在这个假设下,与其他计算相比,读写临时数据几乎是没有任何开销的。我们也可以考虑一种替代方案:将流水线中的多个操作融合在一起,使流水线尽可能短,并删除大量临时数据。这可能是一个优点,但同时也有缺点。比如,拆分流水线使得中间数据缓存、获取同时运行的类似查询的中间数据以及相似查询的流水线合并等功能很容易实现。

格式(Formats) 

数据格式同块流一起实现。既有仅用于向客户端输出数据的»展示«格式,如 IBlockOutputStream 提供的 Pretty 格式,也有其它输入输出格式,比如 TabSeparated 或 JSONEachRow

此外还有行流:IRowInputStream 和 IRowOutputStream。它们允许你按行 pull/push 数据,而不是按块。行流只需要简单地面向行格式实现。包装器 BlockInputStreamFromRowInputStream 和 BlockOutputStreamFromRowOutputStream 允许你将面向行的流转换为正常的面向块的流。

I/O 

对于面向字节的输入输出,有 ReadBuffer 和 WriteBuffer 这两个抽象类。它们用来替代 C++ 的 iostream。不用担心:每个成熟的 C++ 项目都会有充分的理由使用某些东西来代替 iostream

ReadBuffer 和 WriteBuffer 由一个连续的缓冲区和指向缓冲区中某个位置的一个指针组成。实现中,缓冲区可能拥有内存,也可能不拥有内存。有一个虚方法会使用随后的数据来填充缓冲区(针对 ReadBuffer)或刷新缓冲区(针对 WriteBuffer),该虚方法很少被调用。

ReadBuffer 和 WriteBuffer 的实现用于处理文件、文件描述符和网络套接字(socket),也用于实现压缩(CompressedWriteBuffer 在写入数据前需要先用一个 WriteBuffer 进行初始化并进行压缩)和其它用途。ConcatReadBufferLimitReadBuffer 和 HashingWriteBuffer 的用途正如其名字所描述的一样。

ReadBuffer 和 WriteBuffer 仅处理字节。为了实现格式化输入和输出(比如以十进制格式写一个数字),ReadHelpers 和 WriteHelpers 头文件中有一些辅助函数可用。

让我们来看一下,当你把一个结果集以 JSON 格式写到标准输出(stdout)时会发生什么。你已经准备好从 IBlockInputStream 获取结果集,然后创建 WriteBufferFromFileDescriptor(STDOUT_FILENO) 用于写字节到标准输出,创建 JSONRowOutputStream 并用 WriteBuffer 初始化,用于将行以 JSON 格式写到标准输出,你还可以在其上创建 BlockOutputStreamFromRowOutputStream,将其表示为 IBlockOutputStream。然后调用 copyData 将数据从 IBlockInputStream 传输到 IBlockOutputStream,一切工作正常。在内部,JSONRowOutputStream 会写入 JSON 分隔符,并以指向 IColumn 的引用和行数作为参数调用 IDataType::serializeTextJSON 函数。随后,IDataType::serializeTextJSON 将会调用 WriteHelpers.h 中的一个方法:比如,writeText 用于数值类型,writeJSONString 用于 DataTypeString 。

表(Tables) 

表由 IStorage 接口表示。该接口的不同实现对应不同的表引擎。比如 StorageMergeTreeStorageMemory 等。这些类的实例就是表。

IStorage 中最重要的方法是 read 和 write,除此之外还有 alterrename 和 drop 等方法。read 方法接受如下参数:需要从表中读取的列集,需要执行的 AST 查询,以及所需返回的流的数量。read 方法的返回值是一个或多个 IBlockInputStream 对象,以及在查询执行期间在一个表引擎内完成的关于数据处理阶段的信息。

在大多数情况下,read 方法仅负责从表中读取指定的列,而不会进行进一步的数据处理。进一步的数据处理均由查询解释器完成,不由 IStorage 负责。

但是也有值得注意的例外:

  • AST 查询被传递给 read 方法,表引擎可以使用它来判断是否能够使用索引,从而从表中读取更少的数据。
  • 有时候,表引擎能够将数据处理到一个特定阶段。比如,StorageDistributed 可以向远程服务器发送查询,要求它们将来自不同的远程服务器能够合并的数据处理到某个阶段,并返回预处理后的数据,然后查询解释器完成后续的数据处理。

表的 read 方法能够返回多个 IBlockInputStream 对象以允许并行处理数据。多个块输入流能够从一个表中并行读取。然后你可以通过不同的转换对这些流进行装饰(比如表达式求值或过滤),转换过程能够独立计算,并在其上创建一个 UnionBlockInputStream,以并行读取多个流。

另外也有 TableFunctionTableFunction 能够在查询的 FROM 字句中返回一个临时的 IStorage 以供使用。

要快速了解如何实现自己的表引擎,可以查看一些简单的表引擎,比如 StorageMemory 或 StorageTinyLog

作为 read 方法的结果,IStorage 返回 QueryProcessingStage - 关于 storage 里哪部分查询已经被计算的信息。当前我们仅有非常粗粒度的信息。Storage 无法告诉我们«对于这个范围的数据,我已经处理完了 WHERE 字句里的这部分表达式»。我们需要在这个地方继续努力。

解析器(Parsers) 

查询由一个手写递归下降解析器解析。比如, ParserSelectQuery 只是针对查询的不同部分递归地调用下层解析器。解析器创建 ASTAST 由节点表示,节点是 IAST 的实例。

解释器(Interpreters) 

解释器负责从 AST 创建查询执行流水线。既有一些简单的解释器,如 InterpreterExistsQuery 和 InterpreterDropQuery,也有更复杂的解释器,如 InterpreterSelectQuery。查询执行流水线由块输入或输出流组成。比如,SELECT 查询的解释结果是从 FROM 字句的结果集中读取数据的 IBlockInputStreamINSERT 查询的结果是写入需要插入的数据的 IBlockOutputStreamSELECT INSERT 查询的解释结果是 IBlockInputStream,它在第一次读取时返回一个空结果集,同时将数据从 SELECT 复制到 INSERT

InterpreterSelectQuery 使用 ExpressionAnalyzer 和 ExpressionActions 机制来进行查询分析和转换。这是大多数基于规则的查询优化完成的地方。ExpressionAnalyzer 非常混乱,应该进行重写:不同的查询转换和优化应该被提取出来并划分成不同的类,从而允许模块化转换或查询。

函数(Functions) 

函数既有普通函数,也有聚合函数。对于聚合函数,请看下一节。

普通函数不会改变行数 - 它们的执行看起来就像是独立地处理每一行数据。实际上,函数不会作用于一个单独的行上,而是作用在以 Block 为单位的数据上,以实现向量查询执行。

还有一些杂项函数,比如 块大小、rowNumberInBlock,以及 跑累积,它们对块进行处理,并且不遵从行的独立性。

ClickHouse 具有强类型,因此隐式类型转换不会发生。如果函数不支持某个特定的类型组合,则会抛出异常。但函数可以通过重载以支持许多不同的类型组合。比如,plus 函数(用于实现 + 运算符)支持任意数字类型的组合:UInt8 + Float32UInt16 + Int8 等。同时,一些可变参数的函数能够级接收任意数目的参数,比如 concat 函数。

实现函数可能有些不方便,因为函数的实现需要包含所有支持该操作的数据类型和 IColumn 类型。比如,plus 函数能够利用 C++ 模板针对不同的数字类型组合、常量以及非常量的左值和右值进行代码生成。

这是一个实现动态代码生成的好地方,从而能够避免模板代码膨胀。同样,运行时代码生成也使得实现融合函数成为可能,比如融合«乘-加»,或者在单层循环迭代中进行多重比较。

由于向量查询执行,函数不会«短路»。比如,如果你写 WHERE f(x) AND g(y),两边都会进行计算,即使是对于 f(x) 为 0 的行(除非 f(x) 是零常量表达式)。但是如果 f(x) 的选择条件很高,并且计算 f(x) 比计算 g(y) 要划算得多,那么最好进行多遍计算:首先计算 f(x),根据计算结果对列数据进行过滤,然后计算 g(y),之后只需对较小数量的数据进行过滤。

聚合函数 

聚合函数是状态函数。它们将传入的值激活到某个状态,并允许你从该状态获取结果。聚合函数使用 IAggregateFunction 接口进行管理。状态可以非常简单(AggregateFunctionCount 的状态只是一个单一的UInt64 值),也可以非常复杂(AggregateFunctionUniqCombined 的状态是由一个线性数组、一个散列表和一个 HyperLogLog 概率数据结构组合而成的)。

为了能够在执行一个基数很大的 GROUP BY 查询时处理多个聚合状态,需要在 Arena(一个内存池)或任何合适的内存块中分配状态。状态可以有一个非平凡的构造器和析构器:比如,复杂的聚合状态能够自己分配额外的内存。这需要注意状态的创建和销毁并恰当地传递状态的所有权,以跟踪谁将何时销毁状态。

聚合状态可以被序列化和反序列化,以在分布式查询执行期间通过网络传递或者在内存不够的时候将其写到硬盘。聚合状态甚至可以通过 DataTypeAggregateFunction 存储到一个表中,以允许数据的增量聚合。

聚合函数状态的序列化数据格式目前尚未版本化。如果只是临时存储聚合状态,这样是可以的。但是我们有 AggregatingMergeTree 表引擎用于增量聚合,并且人们已经在生产中使用它。这就是为什么在未来当我们更改任何聚合函数的序列化格式时需要增加向后兼容的支持。


以上就是ClickHouse 架构的简要概述,希望对大小稍有帮助。

...全文
202 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

111

社区成员

发帖
与我相关
我的任务
社区描述
ClickHouse开发者
其他 企业社区
社区管理员
  • csdnsqst0018
  • IT学习日记
  • karina17
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告

社区宗旨:意在给学习Clickhouse数据库的朋友提供一个氛围轻松、友好的交流平台,欢迎社区小伙伴们积极讨论。

 

欢迎大家加入到Clickhouse社区大家庭,为了对大家积极的参与感谢,现在准备以下一些奖品【20个CSDN定制帆布包和2个咖啡杯】,满足以下2个条件即有机会领取:

  • 1、加入Clickhouse社区,成为社区成员
  • 2、在社区中发布2个及以上的帖子
  • 帖子条件如下(示例地址:):
    • 推荐文章:需要是至少5篇的推荐内容,内容地址可以放超链接

    • 推荐书籍:需要大致介绍书籍重点内容,或者放书籍目录

    • 推荐视频:需要大致介绍视频重点内容

奖励评选规则:

  • 参与时间:2022年3月1号 ~ 2022年3月8号
  • CSDN定制帆布包【前20位】先到、先满足上面的条件可得(每人一个,机不可失)
  • 咖啡杯则需要根据大家的帖子内容质量,在【加精】帖子中抽奖得出(概率很大哦)

额外奖励:

  • 如果有小伙伴能够介绍给周围的小伙伴,并成功邀请他们加入社区,每拉取2个人奖励现金5元(赚个早餐钱简单)
  • 免费赠送Clickhouse学习电子书籍(即:Clickhouse教程-中文版、ClickHouse万亿数据双中心的设计与实践 )

 

奖励领取方式:

  • 添加博主微信:yyuuyy1235(备注:clickhouse社区)

试试用AI创作助手写篇文章吧