ClickHouse 简介
ClickHouse是一个用于联机分析(OLAP)的列式数据库管理系统(DBMS)。
ClickHouse是 Yandex 开发的高性能开源分析数据库。最初,ClickHouse 是为Yandex.Metrica任务创建的,但逐渐在 Yandex 和其他公司中发现了许多用途。
OLAP场景的关键特征
绝大多数是读请求
数据以相当大的批次(> 1000行)更新,而不是单行更新;或者根本没有更新。
已添加到数据库的数据不能修改。
对于读取,从数据库中提取相当多的行,但只提取列的一小部分。
宽表,即每个表包含着大量的列
查询相对较少(通常每台服务器每秒查询数百次或更少)
对于简单查询,允许延迟大约50毫秒
列中的数据相对较小:数字和短字符串(例如,每个URL 60个字节)
处理单个查询时需要高吞吐量(每台服务器每秒可达数十亿行)
事务不是必须的
对数据一致性要求低
每个查询有一个大表。除了他以外,其他的都很小。
查询结果明显小于源数据。换句话说,数据经过过滤或聚合,因此结果适合于单个服务器的RAM中。
很容易可以看出,OLAP场景与其他通常业务场景(例如,OLTP或K/V)有很大的不同, 因此想要使用OLTP或Key-Value数据库去高效的处理分析查询场景,并不是非常完美的适用方案。例如,使用OLAP数据库去处理分析请求通常要优于使用MongoDB或Redis去处理分析请求。
ClickHouse的特性
真正的列式数据库管理系统
在一个真正的列式数据库管理系统中,除了数据本身外不应该存在其他额外的数据。这意味着为了避免在值旁边存储它们的长度«number»,你必须支持固定长度数值类型。例如,10亿个UInt8类型的数据在未压缩的情况下大约消耗1GB左右的空间,如果不是这样的话,这将对CPU的使用产生强烈影响。即使是在未压缩的情况下,紧凑的存储数据也是非常重要的,因为解压缩的速度主要取决于未压缩数据的大小。
这是非常值得注意的,因为在一些其他系统中也可以将不同的列分别进行存储,但由于对其他场景进行的优化,使其无法有效的处理分析查询。例如: HBase,BigTable,Cassandra,HyperTable。在这些系统中,你可以得到每秒数十万的吞吐能力,但是无法得到每秒几亿行的吞吐能力。
需要说明的是,ClickHouse不单单是一个数据库, 它是一个数据库管理系统。因为它允许在运行时创建表和数据库、加载数据和运行查询,而无需重新配置或重启服务。
数据压缩
在一些列式数据库管理系统中(例如:InfiniDB CE 和 MonetDB) 并没有使用数据压缩。但是, 若想达到比较优异的性能,数据压缩确实起到了至关重要的作用。
除了在磁盘空间和CPU消耗之间进行不同权衡的高效通用压缩编解码器之外,ClickHouse还提供针对特定类型数据的专用编解码器,这使得ClickHouse能够与更小的数据库(如时间序列数据库)竞争并超越它们。
数据的磁盘存储
许多的列式数据库(如 SAP HANA, Google PowerDrill)只能在内存中工作,这种方式会造成比实际更多的设备预算。
ClickHouse被设计用于工作在传统磁盘上的系统,它提供每GB更低的存储成本,但如果可以使用SSD和内存,它也会合理的利用这些资源。
多核心并行处理
ClickHouse会使用服务器上一切可用的资源,从而以最自然的方式并行处理大型查询。
多服务器分布式处理
上面提到的列式数据库管理系统中,几乎没有一个支持分布式的查询处理。
在ClickHouse中,数据可以保存在不同的shard上,每一个shard都由一组用于容错的replica组成,查询可以并行地在所有shard上进行处理。这些对用户来说是透明的
支持SQL
ClickHouse支持一种基于SQL的声明式查询语言,它在许多情况下与ANSI SQL标准相同。
支持的查询GROUP BY, ORDER BY, FROM, JOIN, IN以及非相关子查询。
相关(依赖性)子查询和窗口函数暂不受支持,但将来会被实现。
向量引擎
为了高效的使用CPU,数据不仅仅按列存储,同时还按向量(列的一部分)进行处理,这样可以更加高效地使用CPU。
实时的数据更新
ClickHouse支持在表中定义主键。为了使查询能够快速在主键中进行范围查找,数据总是以增量的方式有序的存储在MergeTree中。因此,数据可以持续不断地高效的写入到表中,并且写入的过程中不会存在任何加锁的行为。
索引
按照主键对数据进行排序,这将帮助ClickHouse在几十毫秒以内完成对数据特定值或范围的查找。
适合在线查询
在线查询意味着在没有对数据做任何预处理的情况下以极低的延迟处理查询并将结果加载到用户的页面中。
支持近似计算
ClickHouse提供各种各样在允许牺牲数据精度的情况下对查询进行加速的方法:
用于近似计算的各类聚合函数,如:distinct values, medians, quantiles
基于数据的部分样本进行近似查询。这时,仅会从磁盘检索少部分比例的数据。
不使用全部的聚合条件,通过随机选择有限个数据聚合条件进行聚合。这在数据聚合条件满足某些分布条件下,在提供相当准确的聚合结果的同时降低了计算资源的使用。
Adaptive Join Algorithm
ClickHouse支持自定义JOIN多个表,它更倾向于散列连接算法,如果有多个大表,则使用合并-连接算法
支持数据复制和数据完整性
ClickHouse使用异步的多主复制技术。当数据被写入任何一个可用副本后,系统会在后台将数据分发给其他副本,以保证系统在不同副本上保持相同的数据。在大多数情况下ClickHouse能在故障后自动恢复,在一些少数的复杂情况下需要手动恢复。
更多信息,参见 数据复制。
角色的访问控制
ClickHouse使用SQL查询实现用户帐户管理,并允许角色的访问控制,类似于ANSI SQL标准和流行的关系数据库管理系统。
限制
没有完整的事务支持。
缺少高频率,低延迟的修改或删除已存在数据的能力。仅能用于批量删除或修改数据,但这符合 GDPR。
稀疏索引使得ClickHouse不适合通过其键检索单行的点查询。
性能¶
根据Yandex的内部测试结果,ClickHouse表现出了比同类可比较产品更优的性能。你可以在 这里 查看具体的测试结果。
许多其他的测试也证实这一点。你可以使用互联网搜索到它们,或者你也可以从 我们收集的部分相关连接 中查看。
单个大查询的吞吐量
吞吐量可以使用每秒处理的行数或每秒处理的字节数来衡量。如果数据被放置在page cache中,则一个不太复杂的查询在单个服务器上大约能够以2-10GB/s(未压缩)的速度进行处理(对于简单的查询,速度可以达到30GB/s)。如果数据没有在page cache中的话,那么速度将取决于你的磁盘系统和数据的压缩率。例如,如果一个磁盘允许以400MB/s的速度读取数据,并且数据压缩率是3,则数据的处理速度为1.2GB/s。这意味着,如果你是在提取一个10字节的列,那么它的处理速度大约是1-2亿行每秒。
对于分布式处理,处理速度几乎是线性扩展的,但这受限于聚合或排序的结果不是那么大的情况下。
处理短查询的延迟时间
如果一个查询使用主键并且没有太多行(几十万)进行处理,并且没有查询太多的列,那么在数据被page cache缓存的情况下,它的延迟应该小于50毫秒(在最佳的情况下应该小于10毫秒)。 否则,延迟取决于数据的查找次数。如果你当前使用的是HDD,在数据没有加载的情况下,查询所需要的延迟可以通过以下公式计算得知: 查找时间(10 ms) * 查询的列的数量 * 查询的数据块的数量。
处理大量短查询的吞吐量
在相同的情况下,ClickHouse可以在单个服务器上每秒处理数百个查询(在最佳的情况下最多可以处理数千个)。但是由于这不适用于分析型场景。因此我们建议每秒最多查询 100次。
数据的写入性能
我们建议每次写入不少于1000行的批量写入,或每秒不超过一个写入请求。当使用tab-separated格式将一份数据写入到MergeTree表中时,写入速度大约为50到200MB/s。如果您写入的数据每行为1Kb,那么写入的速度为50,000到200,000行每秒。如果您的行更小,那么写入速度将更高。为了提高写入性能,您可以使用多个INSERT进行并行写入,这将带来线性的性能提升。
ClickHouse 架构概述
ClickHouse 整体架构图如下:
ClickHouse 是一个真正的列式数据库管理系统(DBMS)。
在 ClickHouse 中,数据始终是按列存储的,包括矢量(向量或列块)执行的过程。只要有可能,操作都是基于矢量进行分派的,而不是单个的值,这被称为«矢量化查询执行»(SIMD),它有利于降低实际的数据处理开销。
这个想法并不新鲜,其可以追溯到 APL 编程语言及其后代:A +、J、K 和 Q。矢量编程被大量用于科学数据处理中。即使在关系型数据库中,这个想法也不是什么新的东西:比如,矢量编程也被大量用于 Vectorwise 系统中。
通常有两种不同的加速查询处理的方法:矢量化查询执行和运行时代码生成。在后者中,动态地为每一类查询生成代码,消除了间接分派和动态分派。这两种方法中,并没有哪一种严格地比另一种好。运行时代码生成可以更好地将多个操作融合在一起,从而充分利用 CPU 执行单元和流水线。矢量化查询执行不是特别实用,因为它涉及必须写到缓存并读回的临时向量。如果 L2 缓存容纳不下临时数据,那么这将成为一个问题。但矢量化查询执行更容易利用 CPU 的 SIMD 功能。朋友写的一篇研究论文表明,将两种方法结合起来是更好的选择。ClickHouse 使用了矢量化查询执行,同时初步提供了有限的运行时动态代码生成。
列(Columns)
要表示内存中的列(实际上是列块),需使用 IColumn 接口。该接口提供了用于实现各种关系操作符的辅助方法。几乎所有的操作都是不可变的:这些操作不会更改原始列,但是会创建一个新的修改后的列。比如,IColumn::filter 方法接受过滤字节掩码,用于 WHERE 和 HAVING 关系操作符中。另外的例子:IColumn::permute 方法支持 ORDER BY 实现,IColumn::cut 方法支持 LIMIT 实现等等。
不同的 IColumn 实现(ColumnUInt8、ColumnString 等)负责不同的列内存布局。内存布局通常是一个连续的数组。对于数据类型为整型的列,只是一个连续的数组,比如 std::vector。对于 String 列和 Array 列,则由两个向量组成:其中一个向量连续存储所有的 String 或数组元素,另一个存储每一个 String 或 Array 的起始元素在第一个向量中的偏移。而 ColumnConst 则仅在内存中存储一个值,但是看起来像一个列。
字段(Field)
尽管如此,有时候也可能需要处理单个值。表示单个值,可以使用 Field。Field 是 UInt64、Int64、Float64、String 和 Array 组成的联合。IColumn 拥有 operator[] 方法来获取第 n 个值成为一个 Field,同时也拥有 insert 方法将一个 Field 追加到一个列的末尾。这些方法并不高效,因为它们需要处理表示单一值的临时 Field 对象,但是有更高效的方法比如 insertFrom 和 insertRangeFrom 等。
Field 中并没有足够的关于一个表(table)的特定数据类型的信息。比如,UInt8、UInt16、UInt32 和 UInt64 在 Field 中均表示为 UInt64。
抽象漏洞
IColumn 具有用于数据的常见关系转换的方法,但这些方法并不能够满足所有需求。比如,ColumnUInt64 没有用于计算两列和的方法,ColumnString 没有用于进行子串搜索的方法。这些无法计算的例程在 Icolumn 之外实现。
列(Columns)上的各种函数可以通过使用 Icolumn 的方法来提取 Field 值,或根据特定的 Icolumn 实现的数据内存布局的知识,以一种通用但不高效的方式实现。为此,函数将会转换为特定的 IColumn 类型并直接处理内部表示。比如,ColumnUInt64 具有 getData 方法,该方法返回一个指向列的内部数组的引用,然后一个单独的例程可以直接读写或填充该数组。实际上,«抽象漏洞(leaky abstractions)»允许我们以更高效的方式来实现各种特定的例程。
数据类型
IDataType 负责序列化和反序列化:读写二进制或文本形式的列或单个值构成的块。IDataType 直接与表的数据类型相对应。比如,有 DataTypeUInt32、DataTypeDateTime、DataTypeString 等数据类型。
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 方法,其能够将块写到某处。
块流负责:
读或写一个表。表仅返回一个流用于读写块。
完成数据格式化。比如,如果你打算将数据以 Pretty 格式输出到终端,你可以创建一个块输出流,将块写入该流中,然后进行格式化。
执行数据转换。假设你现在有 IBlockInputStream 并且打算创建一个过滤流,那么你可以创建一个 FilterBlockInputStream 并用 IBlockInputStream 进行初始化。之后,当你从 FilterBlockInputStream 中拉取块时,会从你的流中提取一个块,对其进行过滤,然后将过滤后的块返回给你。查询执行流水线就是以这种方式表示的。
还有一些更复杂的转换。比如,当你从 AggregatingBlockInputStream 拉取数据时,会从数据源读取全部数据进行聚集,然后将聚集后的数据流返回给你。另一个例子:UnionBlockInputStream 的构造函数接受多个输入源和多个线程,其能够启动多线程从多个输入源并行读取数据。
块流使用«pull»方法来控制流:当你从第一个流中拉取块时,它会接着从嵌套的流中拉取所需的块,然后整个执行流水线开始工作。»pull«和«push»都不是最好的方案,因为控制流不是明确的,这限制了各种功能的实现,比如多个查询同步执行(多个流水线合并到一起)。这个限制可以通过协程或直接运行互相等待的线程来解决。如果控制流明确,那么我们会有更多的可能性:如果我们定位了数据从一个计算单元传递到那些外部的计算单元中其中一个计算单元的逻辑。阅读这篇文章来获取更多的想法。
我们需要注意,查询执行流水线在每一步都会创建临时数据。我们要尽量使块的大小足够小,从而 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 进行初始化并进行压缩)和其它用途。ConcatReadBuffer、LimitReadBuffer 和 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 接口表示。该接口的不同实现对应不同的表引擎。比如 StorageMergeTree、StorageMemory 等。这些类的实例就是表。
IStorage 中最重要的方法是 read 和 write,除此之外还有 alter、rename 和 drop 等方法。read 方法接受如下参数:需要从表中读取的列集,需要执行的 AST 查询,以及所需返回的流的数量。read 方法的返回值是一个或多个 IBlockInputStream 对象,以及在查询执行期间在一个表引擎内完成的关于数据处理阶段的信息。
在大多数情况下,read 方法仅负责从表中读取指定的列,而不会进行进一步的数据处理。进一步的数据处理均由查询解释器完成,不由 IStorage 负责。
但是也有值得注意的例外:
AST 查询被传递给 read 方法,表引擎可以使用它来判断是否能够使用索引,从而从表中读取更少的数据。
有时候,表引擎能够将数据处理到一个特定阶段。比如,StorageDistributed 可以向远程服务器发送查询,要求它们将来自不同的远程服务器能够合并的数据处理到某个阶段,并返回预处理后的数据,然后查询解释器完成后续的数据处理。
表的 read 方法能够返回多个 IBlockInputStream 对象以允许并行处理数据。多个块输入流能够从一个表中并行读取。然后你可以通过不同的转换对这些流进行装饰(比如表达式求值或过滤),转换过程能够独立计算,并在其上创建一个 UnionBlockInputStream,以并行读取多个流。
另外也有 TableFunction。TableFunction 能够在查询的 FROM 字句中返回一个临时的 IStorage 以供使用。
要快速了解如何实现自己的表引擎,可以查看一些简单的表引擎,比如 StorageMemory 或 StorageTinyLog。
作为 read 方法的结果,IStorage 返回 QueryProcessingStage - 关于 storage 里哪部分查询已经被计算的信息。当前我们仅有非常粗粒度的信息。Storage 无法告诉我们«对于这个范围的数据,我已经处理完了 WHERE 字句里的这部分表达式»。我们需要在这个地方继续努力。
解析器(Parsers)
查询由一个手写递归下降解析器解析。比如, ParserSelectQuery 只是针对查询的不同部分递归地调用下层解析器。解析器创建 AST。AST 由节点表示,节点是 IAST 的实例。
由于历史原因,未使用解析器生成器。
解释器(Interpreters)
解释器负责从 AST 创建查询执行流水线。既有一些简单的解释器,如 InterpreterExistsQuery 和 InterpreterDropQuery,也有更复杂的解释器,如 InterpreterSelectQuery。查询执行流水线由块输入或输出流组成。比如,SELECT 查询的解释结果是从 FROM 字句的结果集中读取数据的 IBlockInputStream;INSERT 查询的结果是写入需要插入的数据的 IBlockOutputStream;SELECT INSERT 查询的解释结果是 IBlockInputStream,它在第一次读取时返回一个空结果集,同时将数据从 SELECT 复制到 INSERT。
InterpreterSelectQuery 使用 ExpressionAnalyzer 和 ExpressionActions 机制来进行查询分析和转换。这是大多数基于规则的查询优化完成的地方。ExpressionAnalyzer 非常混乱,应该进行重写:不同的查询转换和优化应该被提取出来并划分成不同的类,从而允许模块化转换或查询。
函数(Functions)
函数既有普通函数,也有聚合函数。对于聚合函数,请看下一节。
普通函数不会改变行数 - 它们的执行看起来就像是独立地处理每一行数据。实际上,函数不会作用于一个单独的行上,而是作用在以 Block 为单位的数据上,以实现向量查询执行。
还有一些杂项函数,比如 块大小、rowNumberInBlock,以及 跑累积,它们对块进行处理,并且不遵从行的独立性。
ClickHouse 具有强类型,因此隐式类型转换不会发生。如果函数不支持某个特定的类型组合,则会抛出异常。但函数可以通过重载以支持许多不同的类型组合。比如,plus 函数(用于实现 + 运算符)支持任意数字类型的组合:UInt8 + Float32,UInt16 + 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),之后只需对较小数量的数据进行过滤。
聚合函数(AggregateFunction)
聚合函数是状态函数。它们将传入的值激活到某个状态,并允许你从该状态获取结果。聚合函数使用 IAggregateFunction 接口进行管理。状态可以非常简单(AggregateFunctionCount 的状态只是一个单一的UInt64 值),也可以非常复杂(AggregateFunctionUniqCombined 的状态是由一个线性数组、一个散列表和一个 HyperLogLog 概率数据结构组合而成的)。
为了能够在执行一个基数很大的 GROUP BY 查询时处理多个聚合状态,需要在 Arena(一个内存池)或任何合适的内存块中分配状态。状态可以有一个非平凡的构造器和析构器:比如,复杂的聚合状态能够自己分配额外的内存。这需要注意状态的创建和销毁并恰当地传递状态的所有权,以跟踪谁将何时销毁状态。
聚合状态可以被序列化和反序列化,以在分布式查询执行期间通过网络传递或者在内存不够的时候将其写到硬盘。聚合状态甚至可以通过 DataTypeAggregateFunction 存储到一个表中,以允许数据的增量聚合。
聚合函数状态的序列化数据格式目前尚未版本化。如果只是临时存储聚合状态,这样是可以的。但是我们有 AggregatingMergeTree 表引擎用于增量聚合,并且人们已经在生产中使用它。这就是为什么在未来当我们更改任何聚合函数的序列化格式时需要增加向后兼容的支持。
服务器(Server)
服务器实现了多个不同的接口:
一个用于任何外部客户端的 HTTP 接口。
一个用于本机 ClickHouse 客户端以及在分布式查询执行中跨服务器通信的 TCP 接口。
一个用于传输数据以进行拷贝的接口。
在内部,它只是一个没有协程、纤程等的基础多线程服务器。服务器不是为处理高速率的简单查询设计的,而是为处理相对低速率的复杂查询设计的,每一个复杂查询能够对大量的数据进行处理分析。
服务器使用必要的查询执行需要的环境初始化 Context 类:可用数据库列表、用户和访问权限、设置、集群、进程列表和查询日志等。这些环境被解释器使用。
我们维护了服务器 TCP 协议的完全向后向前兼容性:旧客户端可以和新服务器通信,新客户端也可以和旧服务器通信。但是我们并不想永久维护它,我们将在大约一年后删除对旧版本的支持。
对于所有的外部应用,我们推荐使用 HTTP 接口,因为该接口很简单,容易使用。TCP 接口与内部数据结构的联系更加紧密:它使用内部格式传递数据块,并使用自定义帧来压缩数据。我们没有发布该协议的 C 库,因为它需要链接大部分的 ClickHouse 代码库,这是不切实际的。
分布式查询执行(Distributed Query)
集群设置中的服务器大多是独立的。你可以在一个集群中的一个或多个服务器上创建一个 Distributed 表。Distributed 表本身并不存储数据,它只为集群的多个节点上的所有本地表提供一个«视图(view)»。当从 Distributed 表中进行 SELECT 时,它会重写该查询,根据负载平衡设置来选择远程节点,并将查询发送给节点。Distributed 表请求远程服务器处理查询,直到可以合并来自不同服务器的中间结果的阶段。然后它接收中间结果并进行合并。分布式表会尝试将尽可能多的工作分配给远程服务器,并且不会通过网络发送太多的中间数据。
当 IN 或 JOIN 子句中包含子查询并且每个子查询都使用分布式表时,事情会变得更加复杂。我们有不同的策略来执行这些查询。
分布式查询执行没有全局查询计划。每个节点都有针对自己的工作部分的本地查询计划。我们仅有简单的一次性分布式查询执行:将查询发送给远程节点,然后合并结果。但是对于具有高基数的 GROUP BY 或具有大量临时数据的 JOIN 这样困难的查询的来说,这是不可行的:在这种情况下,我们需要在服务器之间«改组»数据,这需要额外的协调。ClickHouse 不支持这类查询执行,我们需要在这方面进行努力。
合并树(MergeTree)
MergeTree 是一系列支持按主键索引的存储引擎。主键可以是一个任意的列或表达式的元组。MergeTree 表中的数据存储于«分块»中。每一个分块以主键序存储数据(数据按主键元组的字典序排序)。表的所有列都存储在这些«分块»中分离的 column.bin 文件中。column.bin 文件由压缩块组成,每一个块通常是 64 KB 到 1 MB 大小的未压缩数据,具体取决于平均值大小。这些块由一个接一个连续放置的列值组成。每一列的列值顺序相同(顺序由主键定义),因此当你按多列进行迭代时,你能够得到相应列的值。
主键本身是«稀疏»的。它并不是索引单一的行,而是索引某个范围内的数据。一个单独的 primary.idx 文件具有每个第 N 行的主键值,其中 N 称为 index_granularity(通常,N = 8192)。同时,对于每一列,都有带有标记的 column.mrk 文件,该文件记录的是每个第 N 行在数据文件中的偏移量。每个标记是一个 pair:文件中的偏移量到压缩块的起始,以及解压缩块中的偏移量到数据的起始。通常,压缩块根据标记对齐,并且解压缩块中的偏移量为 0。primary.idx 的数据始终驻留在内存,同时 column.mrk 的数据被缓存。
当我们要从 MergeTree 的一个分块中读取部分内容时,我们会查看 primary.idx 数据并查找可能包含所请求数据的范围,然后查看 column.mrk 并计算偏移量从而得知从哪里开始读取些范围的数据。由于稀疏性,可能会读取额外的数据。ClickHouse 不适用于高负载的简单点查询,因为对于每一个键,整个 index_granularity 范围的行的数据都需要读取,并且对于每一列需要解压缩整个压缩块。
我们使索引稀疏,是因为每一个单一的服务器需要在索引没有明显内存消耗的情况下,维护数万亿行的数据。另外,由于主键是稀疏的,导致其不是唯一的:无法在 INSERT 时检查一个键在表中是否存在。你可以在一个表中使用同一个键创建多个行。
当你向 MergeTree 中插入一堆数据时,数据按主键排序并形成一个新的分块。为了保证分块的数量相对较少,有后台线程定期选择一些分块并将它们合并成一个有序的分块,这就是 MergeTree 的名称来源。当然,合并会导致«写入放大»。所有的分块都是不可变的:它们仅会被创建和删除,不会被修改。当运行 SELECT 查询时,MergeTree 会保存一个表的快照(分块集合)。合并之后,还会保留旧的分块一段时间,以便发生故障后更容易恢复,因此如果我们发现某些合并后的分块可能已损坏,我们可以将其替换为原分块。
MergeTree 不是 LSM 树,因为它不包含»memtable«和»log«:插入的数据直接写入文件系统。这使得它仅适用于批量插入数据,而不适用于非常频繁地一行一行插入 - 大约每秒一次是没问题的,但是每秒一千次就会有问题。我们这样做是为了简单起见,因为我们已经在我们的应用中批量插入数据。
MergeTree 表只能有一个(主)索引:没有任何辅助索引。在一个逻辑表下,允许有多个物理表示,比如,可以以多个物理顺序存储数据,或者同时表示预聚合数据和原始数据。
有些 MergeTree 引擎会在后台合并期间做一些额外工作,比如 CollapsingMergeTree 和 AggregatingMergeTree。这可以视为对更新的特殊支持。请记住这些不是真正的更新,因为用户通常无法控制后台合并将会执行的时间,并且 MergeTree 中的数据几乎总是存储在多个分块中,而不是完全合并的形式。
复制(Replication)
这些是部分排序的数据块。并且副本尝试使这组片段彼此同步。
在这种情况下,可能会发生三种类型的事件:
INSERT - 插入副本
FETCH - 一个副本从另一个副本下载一个片段
MERGE - 一个复制品需要几块并将它们合并成一个
插入是如何进行的?我们插入任何副本。在这里可以看到Replica 1并不是最好的,但是还是可以插入进去的。并将插入信息写入 ZooKeeper。也就是说,为了让复制为您工作,您必须安装 ZooKeeper。
同时,仍然保持插入的完整顺序。您的所有副本都看到相同的一组块,并且它们看到其中有一些它们没有的孔,并且它们尝试使用 fetch 填充它们。
接下来,我们还需要进行merge,也就是将碎片合并。合并必须始终如一地执行,否则块的集合会发散。为此,一个副本成为领导者。我们不会称它为 master,因为关联会立即与 master 关联,只有在那里你才能插入,但事实并非如此。也就是说,我们的 Replica 2 是领导者。她决定需要冻结这些部分,将其写在 ZooKeeper 中,其余关于此的回复将收到信息,也会进行相同的合并。
在这种情况下,副本不断地相互比较校验和。如果出现问题,他们会丢弃该块并重新下载,即他们尝试保持数据集字节相同。这个地方也是需要监控的,就是监控你的复制进行的怎么样,有什么滞后。
ClickHouse 中的复制是基于表实现的。你可以在同一个服务器上有一些可复制的表和不可复制的表。你也可以以不同的方式进行表的复制,比如一个表进行双因子复制,另一个进行三因子复制。
复制是在 ReplicatedMergeTree 存储引擎中实现的。ZooKeeper 中的路径被指定为存储引擎的参数。ZooKeeper 中所有具有相同路径的表互为副本:它们同步数据并保持一致性。只需创建或删除表,就可以实现动态添加或删除副本。
复制使用异步多主机方案。你可以将数据插入到与 ZooKeeper 进行会话的任意副本中,并将数据复制到所有其它副本中。由于 ClickHouse 不支持 UPDATEs,因此复制是无冲突的。由于没有对插入的仲裁确认,如果一个节点发生故障,刚刚插入的数据可能会丢失。
用于复制的元数据存储在 ZooKeeper 中。其中一个复制日志列出了要执行的操作。操作包括:获取分块、合并分块和删除分区等。每一个副本将复制日志复制到其队列中,然后执行队列中的操作。比如,在插入时,在复制日志中创建«获取分块»这一操作,然后每一个副本都会去下载该分块。所有副本之间会协调进行合并以获得相同字节的结果。所有的分块在所有的副本上以相同的方式合并。为实现该目的,其中一个副本被选为领导者,该副本首先进行合并,并把«合并分块»操作写到日志中。
复制是物理的:只有压缩的分块会在节点之间传输,查询则不会。为了降低网络成本(避免网络放大),大多数情况下,会在每一个副本上独立地处理合并。只有在存在显著的合并延迟的情况下,才会通过网络发送大块的合并分块。
另外,每一个副本将其状态作为分块和校验和组成的集合存储在 ZooKeeper 中。当本地文件系统中的状态与 ZooKeeper 中引用的状态不同时,该副本会通过从其它副本下载缺失和损坏的分块来恢复其一致性。当本地文件系统中出现一些意外或损坏的数据时,ClickHouse 不会将其删除,而是将其移动到一个单独的目录下并忘记它。
ClickHouse 集群由独立的分片组成,每一个分片由多个副本组成。集群不是弹性的,因此在添加新的分片后,数据不会自动在分片之间重新平衡。相反,集群负载将变得不均衡。该实现为你提供了更多控制,对于相对较小的集群,例如只有数十个节点的集群来说是很好的。但是对于我们在生产中使用的具有数百个节点的集群来说,这种方法成为一个重大缺陷。我们应该实现一个表引擎,使得该引擎能够跨集群扩展数据,同时具有动态复制的区域,这些区域能够在集群之间自动拆分和平衡。
Single-Shard Write Procedure in ClickHouse
参考资料:
https://clickhouse.com/docs/zh/development/architecture/
https://www.alibabacloud.com/blog/clickhouse-vs--elasticsearch_597898
https://clickhouse.com/docs/zh/
https://habr.com/ru/post/509540/