ClickHouse 架构概述
ClickHouse 是一个真正的列式数据库管理系统。数据按列存储,并在执行数组(向量或列块)时进行处理。只要有可能,操作就会在数组上分派,而不是在单个值上分派。这被称为“向量化查询执行”,它有助于降低实际数据处理的成本。
这个想法并不新鲜。它可以追溯到
APL
(一种编程语言,1957 年)及其后代:A +
(APL 方言)、J
(1990 年)、K
(1993 年)和Q
(来自 Kx Systems 的编程语言,2003 年)。数组编程用于科学数据处理。在关系数据库中,这个想法也不是什么新鲜事:例如,它在VectorWise
系统(也称为 Actian Corporation 的 Actian Vector Analytic Database)中使用。
有两种不同的方法可以加速查询处理:向量化查询执行和运行时代码生成。后者消除了所有间接寻址和动态分派。这两种方法都没有严格意义上的优劣之分。当运行时代码生成融合了许多操作时,它可以更好地充分利用 CPU 执行单元和流水线。向量化查询执行可能不太实用,因为它涉及必须写入缓存并读回的临时向量。如果临时数据不适合 L2 缓存,就会成为问题。但是向量化查询执行更容易利用 CPU 的 SIMD 功能。我们朋友撰写的一篇 研究论文 表明,最好结合这两种方法。ClickHouse 使用向量化查询执行,并且对运行时代码生成具有有限的初始支持。
列
IColumn
接口用于表示内存中的列(实际上是列块)。此接口为各种关系运算符的实现提供了辅助方法。几乎所有操作都是不可变的:它们不会修改原始列,而是创建一个新的修改后的列。例如,IColumn :: filter
方法接受一个过滤器字节掩码。它用于 WHERE
和 HAVING
关系运算符。其他示例:IColumn :: permute
方法支持 ORDER BY
,IColumn :: cut
方法支持 LIMIT
。
各种 IColumn
实现(ColumnUInt8
、ColumnString
等)负责列的内存布局。内存布局通常是连续数组。对于整数类型的列,它只是一个连续数组,就像 std :: vector
一样。对于 String
和 Array
列,它是两个向量:一个用于所有数组元素,连续放置,另一个用于每个数组开头的偏移量。还有一个 ColumnConst
,它只在内存中存储一个值,但看起来像一个列。
字段
但是,也可以使用单个值。为了表示单个值,使用了 Field
。Field
只是 UInt64
、Int64
、Float64
、String
和 Array
的区分联合。IColumn
有 operator []
方法来获取第 n 个值作为 Field
,以及 insert
方法来将 Field
附加到列的末尾。这些方法效率不高,因为它们需要处理表示单个值的临时 Field
对象。还有更有效的方法,例如 insertFrom
、insertRangeFrom
等。
Field
没有足够的信息来了解表的特定数据类型。例如,UInt8
、UInt16
、UInt32
和 UInt64
在 Field
中都表示为 UInt64
。
泄漏抽象
IColumn
有用于数据常见关系转换的方法,但它们不能满足所有需求。例如,ColumnUInt64
没有计算两个列之和的方法,ColumnString
没有运行子字符串搜索的方法。这些无数的例程是在 IColumn
之外实现的。
可以使用 IColumn
方法以通用、非高效的方式实现列上的各种函数以提取 Field
值,或者可以使用特定 IColumn
实现中数据内部内存布局的知识以专门的方式实现。它是通过将函数强制转换为特定 IColumn
类型并直接处理内部表示来实现的。例如,ColumnUInt64
有 getData
方法,它返回对内部数组的引用,然后单独的例程直接读取或填充该数组。我们有“泄漏抽象”以允许各种例程的高效专门化。
数据类型
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
是一个容器,表示内存中表的子集(块)。它只是一组三元组:(IColumn, IDataType, 列名)
。在查询执行过程中,数据由 Block
处理。如果我们有一个 Block
,我们有数据(在 IColumn
对象中),我们有关于其类型的信息(在 IDataType
中),它告诉我们如何处理该列,并且我们有列名。它可以是表中的原始列名,也可以是为获取计算的临时结果而分配的一些人工名称。
当我们在块中的列上计算某个函数时,我们会向块中添加另一列及其结果,并且我们不会触及函数参数的列,因为操作是不可变的。稍后,可以从块中删除不需要的列,但不能修改。这对于消除公共子表达式很方便。
为每个处理的数据块创建块。请注意,对于相同类型的计算,列名和类型在不同的块中保持相同,只有列数据发生变化。最好将块数据与块标题分开,因为较小的块大小具有较高的临时字符串开销,用于复制 shared_ptrs 和列名。
处理器
请参阅 https://github.com/ClickHouse/ClickHouse/blob/master/src/Processors/IProcessor.h 中的描述。
格式
数据格式使用处理器实现。
I/O
对于面向字节的输入/输出,有 ReadBuffer
和 WriteBuffer
抽象类。它们用于代替 C++ iostream
。不用担心:每个成熟的 C++ 项目都出于充分的理由使用 iostream
之外的东西。
ReadBuffer
和 WriteBuffer
只是一个连续的缓冲区和一个指向该缓冲区中位置的光标。实现可以拥有或不拥有缓冲区的内存。有一个虚拟方法用于用后续数据填充缓冲区(对于 ReadBuffer
)或将缓冲区刷新到某个位置(对于 WriteBuffer
)。很少调用虚拟方法。
ReadBuffer
/WriteBuffer
的实现用于处理文件和文件描述符以及网络套接字,用于实现压缩(CompressedWriteBuffer
用另一个 WriteBuffer 初始化,并在将数据写入它之前执行压缩),以及用于其他目的——名称 ConcatReadBuffer
、LimitReadBuffer
和 HashingWriteBuffer
不言而喻。
Read/WriteBuffer 仅处理字节。ReadHelpers
和 WriteHelpers
头文件中有函数可以帮助格式化输入/输出。例如,有一些帮助程序可以以十进制格式写入数字。
让我们检查一下当您想以 JSON
格式将结果集写入标准输出时会发生什么。您有一个结果集准备从拉取式 QueryPipeline
中获取。首先,您创建一个 WriteBufferFromFileDescriptor(STDOUT_FILENO)
将字节写入标准输出。接下来,您将查询管道中的结果连接到 JSONRowOutputFormat
,后者用该 WriteBuffer
初始化,以便以 JSON
格式将行写入标准输出。这可以通过 complete
方法完成,该方法将拉取式 QueryPipeline
转换为已完成的 QueryPipeline
。在内部,JSONRowOutputFormat
将写入各种 JSON 分隔符并调用 IDataType::serializeTextJSON
方法,并将 IColumn
的引用和行号作为参数。因此,IDataType::serializeTextJSON
将调用 WriteHelpers.h
中的方法:例如,对于数值类型使用 writeText
,对于 DataTypeString
使用 writeJSONString
。
表
IStorage
接口表示表。该接口的不同实现是不同的表引擎。例如 StorageMergeTree
、StorageMemory
等。这些类的实例就是表。
IStorage
中的关键方法是 read
和 write
,以及其他一些方法,如 alter
、rename
和 drop
。read
方法接受以下参数:要从表中读取的一组列、要考虑的 AST
查询和所需的流数量。它返回一个 Pipe
。
在大多数情况下,read
方法只负责从表中读取指定的列,而不负责任何进一步的数据处理。所有后续的数据处理都由管道中的另一部分处理,这超出了 IStorage
的职责范围。
但有一些值得注意的例外情况。
AST
查询传递给read
方法,表引擎可以使用它来推导出索引的使用情况并从表中读取更少的数据。- 有时表引擎可以将数据本身处理到特定阶段。例如,
StorageDistributed
可以将查询发送到远程服务器,要求它们将数据处理到可以合并来自不同远程服务器的数据的阶段,并返回该预处理数据。然后,查询解释器完成数据处理。
表的 read
方法可以返回一个包含多个 Processors
的 Pipe
。这些 Processors
可以并行地从表中读取数据。然后,您可以将这些处理器与各种其他转换(例如表达式计算或过滤)连接起来,这些转换可以独立计算。然后,在它们之上创建一个 QueryPipeline
,并通过 PipelineExecutor
执行它。
还有 TableFunction
。这些函数返回一个临时 IStorage
对象,用于查询的 FROM
子句中。
要快速了解如何实现您的表引擎,请查看一些简单的示例,例如 StorageMemory
或 StorageTinyLog
。
作为
read
方法的结果,IStorage
返回QueryProcessingStage
——关于存储内部已计算查询的哪些部分的信息。
解析器
一个手写的递归下降解析器解析查询。例如,ParserSelectQuery
只是递归地调用查询各个部分的底层解析器。解析器创建 AST
。AST
由节点表示,这些节点是 IAST
的实例。
出于历史原因,未使用解析器生成器。
解释器
解释器负责根据 AST 创建查询执行管道。有一些简单的解释器,例如 InterpreterExistsQuery
和 InterpreterDropQuery
,以及更复杂的 InterpreterSelectQuery
。
查询执行管道是处理器的组合,这些处理器可以消费和生成块(具有特定类型的一组列)。处理器通过端口通信,可以有多个输入端口和多个输出端口。可以在 src/Processors/IProcessor.h 中找到更详细的说明。
例如,解释 SELECT
查询的结果是一个“拉取式”QueryPipeline
,它有一个特殊的输出端口用于读取结果集。INSERT
查询的结果是一个“推送式”QueryPipeline
,它有一个输入端口用于写入要插入的数据。解释 INSERT SELECT
查询的结果是一个“已完成”QueryPipeline
,它没有输入或输出,但同时将数据从 SELECT
复制到 INSERT
。
InterpreterSelectQuery
使用 ExpressionAnalyzer
和 ExpressionActions
机制进行查询分析和转换。这是执行大多数基于规则的查询优化的地方。ExpressionAnalyzer
非常混乱,应该重写:各种查询转换和优化应该提取到单独的类中,以允许对查询进行模块化转换。
为了解决解释器中存在的问题,开发了一个新的 InterpreterSelectQueryAnalyzer
。这是 InterpreterSelectQuery
的新版本,它不使用 ExpressionAnalyzer
,并在 AST
和 QueryPipeline
之间引入了额外的抽象层,称为 QueryTree
。它已完全准备好用于生产环境,但以防万一,可以通过将 enable_analyzer
设置的值设置为 false
来关闭它。
函数
有普通函数和聚合函数。有关聚合函数,请参见下一节。
普通函数不会更改行数——它们的工作方式就像独立处理每一行一样。实际上,函数不是针对单个行调用的,而是针对数据块调用的,以实现矢量化查询执行。
有一些杂项函数,例如 blockSize、rowNumberInBlock 和 runningAccumulate,它们利用块处理并违反了行的独立性。
ClickHouse 具有强类型,因此没有隐式类型转换。如果函数不支持特定类型的组合,则会抛出异常。但函数可以(被重载)用于许多不同的类型组合。例如,plus
函数(用于实现 +
运算符)适用于任何数值类型的组合:UInt8
+ Float32
、UInt16
+ Int8
等。此外,一些可变参数函数可以接受任意数量的参数,例如 concat
函数。
实现函数可能稍微有些不方便,因为函数明确地分派支持的数据类型和支持的 IColumns
。例如,plus
函数的代码是通过为每种数值类型的组合以及常量或非常量左右参数实例化 C++ 模板生成的。
这是一个实现运行时代码生成以避免模板代码膨胀的绝佳位置。此外,它还可以添加融合函数,例如融合乘加或在一个循环迭代中进行多次比较。
由于矢量化查询执行,函数不会短路。例如,如果您编写 WHERE f(x) AND g(y)
,则计算两侧,即使对于行,当 f(x)
为零时(除非 f(x)
是零常量表达式)。但是,如果 f(x)
条件的选择性很高,并且 f(x)
的计算比 g(y)
便宜得多,那么最好实现多遍计算。它将首先计算 f(x)
,然后根据结果过滤列,然后仅对较小、已过滤的数据块计算 g(y)
。
聚合函数
聚合函数是有状态的函数。它们将传递的值累积到某个状态中,并允许您从该状态获取结果。它们由 IAggregateFunction
接口管理。状态可以相当简单(AggregateFunctionCount
的状态只是一个 UInt64
值)或相当复杂(AggregateFunctionUniqCombined
的状态是线性数组、哈希表和 HyperLogLog
概率数据结构的组合)。
状态在 Arena
(内存池)中分配,以便在执行高基数 GROUP BY
查询时处理多个状态。状态可以具有非平凡的构造函数和析构函数:例如,复杂的聚合状态可以自己分配额外的内存。它需要一些注意来创建和销毁状态,并正确传递其所有权和销毁顺序。
聚合状态可以序列化和反序列化,以便在分布式查询执行期间通过网络传递或将它们写入磁盘(在没有足够的 RAM 时)。它们甚至可以存储在具有 DataTypeAggregateFunction
的表中,以允许对数据进行增量聚合。
聚合函数状态的序列化数据格式目前没有版本控制。如果聚合状态仅临时存储,则可以。但是我们有
AggregatingMergeTree
表引擎用于增量聚合,并且人们已经在生产环境中使用它。这就是为什么将来更改任何聚合函数的序列化格式时需要向后兼容性的原因。
服务器
服务器实现了几个不同的接口。
- 用于任何外部客户端的 HTTP 接口。
- 用于原生 ClickHouse 客户端以及分布式查询执行期间服务器间通信的 TCP 接口。
- 用于传输复制数据的接口。
在内部,它只是一个简单的多线程服务器,没有协程或纤程。由于服务器不是为了处理大量简单查询而设计的,而是为了处理相对较少的复杂查询而设计的,因此每个查询都可以处理大量数据以进行分析。
服务器使用必要的查询执行环境初始化 Context
类:可用数据库列表、用户和访问权限、设置、集群、进程列表、查询日志等。解释器使用此环境。
我们维护服务器 TCP 协议的完全向后和向前兼容性:旧客户端可以与新服务器通信,新客户端可以与旧服务器通信。但我们不想永远维护它,并且在大约一年后我们将删除对旧版本的支持。
对于大多数外部应用程序,我们建议使用 HTTP 接口,因为它简单易用。TCP 协议与内部数据结构的关联更紧密:它使用内部格式传递数据块,并使用自定义帧传递压缩数据。我们还没有为此协议发布 C 库,因为它需要链接大部分 ClickHouse 代码库,这在实践中不可行。
配置
ClickHouse 服务器基于 POCO C++ 库,并使用Poco::Util::AbstractConfiguration
来表示其配置。配置由Poco::Util::ServerApplication
类持有,该类被DaemonBase
类继承,而DaemonBase
类又被DB::Server
类继承,DB::Server
类实现了 ClickHouse 服务器本身。因此,可以通过ServerApplication::config()
方法访问配置。
配置从多个文件(XML 或 YAML 格式)读取,并由ConfigProcessor
类合并到单个AbstractConfiguration
中。配置在服务器启动时加载,如果其中一个配置文件更新、删除或添加,则稍后可以重新加载。ConfigReloader
类负责定期监控这些更改以及重新加载过程。SYSTEM RELOAD CONFIG
查询也会触发配置重新加载。
对于除Server
之外的查询和子系统,可以使用Context::getConfigRef()
方法访问配置。每个能够在不重启服务器的情况下重新加载其配置的子系统都应该在Server::main()
方法中的重新加载回调中注册自身。请注意,如果较新的配置存在错误,大多数子系统将忽略新配置,记录警告消息并继续使用先前加载的配置。由于AbstractConfiguration
的特性,无法传递对特定部分的引用,因此通常使用String config_prefix
代替。
线程和作业
为了执行查询和执行辅助活动,ClickHouse 从一个线程池中分配线程,以避免频繁创建和销毁线程。有几个线程池,根据作业的目的和结构进行选择。
- 用于传入客户端会话的服务器池。
- 用于通用作业、后台活动和独立线程的全局线程池。
- 用于主要阻塞在某些 IO 上且不占用 CPU 的作业的 IO 线程池。
- 用于周期性任务的后台池。
- 用于可抢占任务的池,这些任务可以拆分为多个步骤。
服务器池是Server::main()
方法中定义的Poco::ThreadPool
类实例。它最多可以有max_connection
个线程。每个线程都专用于单个活动连接。
全局线程池是GlobalThreadPool
单例类。要从中分配线程,请使用ThreadFromGlobalPool
。它具有类似于std::thread
的接口,但会从全局池中提取线程并执行所有必要的初始化。它使用以下设置进行配置
max_thread_pool_size
- 池中线程数量的限制。max_thread_pool_free_size
- 等待新作业的空闲线程数量的限制。thread_pool_queue_size
- 计划作业数量的限制。
全局池是通用的,下面描述的所有池都在其之上实现。这可以被认为是池的层次结构。任何专门的池都使用ThreadPool
类从全局池中获取其线程。因此,任何专门池的主要目的是限制同时作业的数量并执行作业调度。如果计划的作业数量超过池中的线程数,ThreadPool
会将作业累积到具有优先级的队列中。每个作业都有一个整数优先级。默认优先级为零。所有具有较高优先级值的作业都将在任何具有较低优先级值的作业之前启动。但是,正在执行的作业之间没有区别,因此优先级仅在池过载时才重要。
IO 线程池实现为一个简单的ThreadPool
,可以通过IOThreadPool::get()
方法访问。它与全局池以相同的方式配置,使用max_io_thread_pool_size
、max_io_thread_pool_free_size
和io_thread_pool_queue_size
设置。IO 线程池的主要目的是避免 IO 作业耗尽全局池,这可能会阻止查询充分利用 CPU。S3 备份执行大量 IO 操作,为了避免对交互式查询造成影响,有一个单独的BackupsIOThreadPool
,使用max_backups_io_thread_pool_size
、max_backups_io_thread_pool_free_size
和backups_io_thread_pool_queue_size
设置进行配置。
对于周期性任务执行,有BackgroundSchedulePool
类。您可以使用BackgroundSchedulePool::TaskHolder
对象注册任务,池确保没有任务同时运行两个作业。它还允许您将任务执行推迟到将来的某个特定时间点或暂时停用任务。全局Context
为不同目的提供了此类的几个实例。对于通用任务,使用Context::getSchedulePool()
。
还有一些专门的线程池用于可抢占任务。此类IExecutableTask
任务可以拆分为称为步骤的有序作业序列。为了以允许短任务优先于长任务的方式调度这些任务,使用了MergeTreeBackgroundExecutor
。顾名思义,它用于与 MergeTree 相关的后台操作,例如合并、变异、提取和移动。可以使用Context::getCommonExecutor()
和其他类似方法获得池实例。
无论使用什么池来执行作业,在开始时都会为该作业创建ThreadStatus
实例。它封装了所有每个线程的信息:线程 ID、查询 ID、性能计数器、资源消耗以及许多其他有用的数据。作业可以通过线程局部指针通过CurrentThread::get()
调用访问它,因此我们不需要将其传递给每个函数。
如果线程与查询执行相关,则附加到ThreadStatus
的最重要内容是查询上下文ContextPtr
。每个查询在服务器池中都有其主线程。主线程通过持有ThreadStatus::QueryScope query_scope(query_context)
对象来进行附加。主线程还会创建一个由ThreadGroupStatus
对象表示的线程组。在此查询执行期间分配的每个附加线程都通过CurrentThread::attachTo(thread_group)
调用附加到其线程组。线程组用于聚合配置文件事件计数器并跟踪专用于单个任务的所有线程的内存消耗(有关更多信息,请参阅MemoryTracker
和ProfileEvents::Counters
类)。
并发控制
可以并行化的查询使用max_threads
设置来限制自身。此设置的默认值以允许单个查询以最佳方式利用所有 CPU 内核的方式选择。但是,如果有多个并发查询,并且每个查询都使用默认的max_threads
设置值会发生什么情况?然后查询将共享 CPU 资源。操作系统将通过不断切换线程来确保公平性,这会带来一些性能损失。ConcurrencyControl
有助于处理此性能损失并避免分配大量线程。配置设置concurrent_threads_soft_limit_num
用于限制在应用某种 CPU 压力之前可以分配多少个并发线程。
concurrent_threads_soft_limit_num
和concurrent_threads_soft_limit_ratio_to_cores
默认情况下处于禁用状态(等于 0)。因此,必须在使用前启用此功能。
引入了 CPU slot
的概念。Slot 是并发的一个单位:要运行线程,查询必须预先获取一个 Slot,并在线程停止时释放它。服务器中 Slot 的数量全局限制。如果总需求超过 Slot 的总数,多个并发查询将争夺 CPU Slot。ConcurrencyControl
负责通过以公平的方式进行 CPU Slot 调度来解决此竞争。
每个 Slot 可以看作是一个具有以下状态的独立状态机
free
:Slot 可供任何查询分配。granted
:Slot 已由特定查询分配
,但尚未被任何线程获取。acquired
:Slot 已由特定查询分配
并被线程获取。
请注意,分配
的 Slot 可以处于两种不同的状态:granted
和acquired
。前者是过渡状态,实际上应该很短(从 Slot 分配给查询的那一刻到任何线程运行向上扩展过程的那一刻)。
ConcurrencyControl
的 API 包括以下函数
- 为查询创建资源分配:
auto slots = ConcurrencyControl::instance().allocate(1, max_threads);
。它将至少分配 1 个,最多分配max_threads
个 Slot。请注意,第一个 Slot 会立即授予,但其余 Slot 可能会在以后授予。因此,限制是软限制,因为每个查询至少会获得一个线程。 - 对于每个线程,必须从分配中获取一个 Slot:
while (auto slot = slots->tryAcquire()) spawnThread([slot = std::move(slot)] { ... });
。 - 更新 Slot 的总数:
ConcurrencyControl::setMaxConcurrency(concurrent_threads_soft_limit_num)
。可以在运行时完成,无需重启服务器。
此 API 允许查询至少以一个线程启动(在存在 CPU 压力的情况下),然后扩展到max_threads
。
分布式查询执行
集群设置中的服务器大多是独立的。您可以在集群中的一个或所有服务器上创建一个Distributed
表。Distributed
表本身不存储数据,它只提供对集群中多个节点上的所有本地表的“视图”。当您从Distributed
表中执行 SELECT 时,它会重写该查询,根据负载均衡设置选择远程节点,并将查询发送给它们。Distributed
表请求远程服务器处理查询,直到可以合并来自不同服务器的中间结果的阶段。然后它接收中间结果并合并它们。分布式表尝试将尽可能多的工作分配给远程服务器,并且不会通过网络发送太多中间数据。
当您在 IN 或 JOIN 子句中存在子查询,并且每个子查询都使用Distributed
表时,情况会变得更加复杂。我们有不同的策略来执行这些查询。
分布式查询执行没有全局查询计划。每个节点都有其本地查询计划,用于其工作的一部分。我们只有简单的单遍分布式查询执行:我们发送远程节点的查询,然后合并结果。但这对于具有高基数GROUP BY
或大量用于 JOIN 的临时数据的复杂查询来说是不可行的。在这种情况下,我们需要在服务器之间“重新洗牌”数据,这需要额外的协调。ClickHouse 不支持这种类型的查询执行,我们需要对此进行改进。
Merge Tree
MergeTree
是一系列存储引擎,支持按主键索引。主键可以是列或表达式的任意元组。MergeTree
表中的数据存储在“分区”中。每个分区按主键顺序存储数据,因此数据按主键元组按字典顺序排序。所有表列都存储在这些分区中的单独column.bin
文件中。这些文件由压缩块组成。每个块通常为 64 KB 到 1 MB 的未压缩数据,具体取决于平均值的大小。块由连续放置的列值组成。每个列的列值顺序相同(主键定义顺序),因此当您迭代多列时,您会获得对应行的值。
主键本身是“稀疏”的。它并不指向每一行,而只指向某些数据范围。一个单独的primary.idx
文件存储了每第N行的主键值,其中N称为index_granularity
(通常,N = 8192)。此外,对于每一列,我们都有column.mrk
文件,其中包含“标记”,这些标记是指向数据文件中每第N行的偏移量。每个标记都是一对:数据文件压缩块起始位置的偏移量,以及解压缩块中数据起始位置的偏移量。通常,压缩块会与标记对齐,并且解压缩块中的偏移量为零。primary.idx
的数据始终驻留在内存中,而column.mrk
文件的数据会被缓存。
当我们要从MergeTree
中的某一部分读取数据时,我们会查看primary.idx
数据并定位可能包含请求数据的范围,然后查看column.mrk
数据并计算读取这些范围的起始偏移量。由于稀疏性,可能会读取到多余的数据。ClickHouse不适合处理大量简单的点查询,因为对于每个键,都必须读取包含index_granularity
行的整个范围,并且对于每一列,都必须解压缩整个压缩块。我们之所以将索引设计为稀疏的,是因为我们必须能够在单个服务器上维护数万亿行数据,而不会显著增加索引的内存消耗。此外,由于主键是稀疏的,因此它不是唯一的:它无法在INSERT时检查键在表中是否存在。表中可以有多行具有相同的键。
当您将大量数据INSERT
到MergeTree
时,这些数据会按主键顺序排序并形成一个新的数据块。后台线程会定期选择一些数据块并将它们合并成一个排序后的数据块,以保持数据块数量相对较少。这就是它被称为MergeTree
的原因。当然,合并会导致“写入放大”。所有数据块都是不可变的:它们只能创建和删除,而不能修改。当执行SELECT时,它会持有表的快照(一组数据块)。合并后,我们还会保留旧数据块一段时间,以便在发生故障后更容易恢复,因此如果我们发现某些合并后的数据块可能已损坏,我们可以用其源数据块替换它。
MergeTree
不是LSM树,因为它不包含MEMTABLE和LOG:插入的数据会直接写入文件系统。此行为使MergeTree更适合批量插入数据。因此,频繁插入少量行对于MergeTree来说并不理想。例如,每秒插入几行是可以的,但每秒插入一千次对于MergeTree来说并不是最佳选择。但是,存在一个异步插入模式,用于克服此限制,以便进行少量插入。我们之所以这样做,是为了简化起见,并且因为我们已经在应用程序中批量插入数据了。
有一些MergeTree引擎在后台合并期间会执行其他工作。例如CollapsingMergeTree
和AggregatingMergeTree
。这可以被视为对更新的特殊支持。请记住,这些不是真正的更新,因为用户通常无法控制后台合并执行的时间,并且MergeTree
表中的数据几乎总是存储在多个数据块中,而不是完全合并的形式。
复制
ClickHouse中的复制可以在每个表的基础上进行配置。您可以在同一服务器上拥有某些复制的表和某些未复制的表。您还可以以不同的方式复制表,例如一个表使用双因子复制,另一个表使用三因子复制。
复制是在ReplicatedMergeTree
存储引擎中实现的。ZooKeeper
中的路径被指定为存储引擎的参数。所有在ZooKeeper
中具有相同路径的表都成为彼此的副本:它们同步其数据并保持一致性。只需创建或删除表,就可以动态添加和删除副本。
复制使用异步多主方案。您可以将数据插入到任何具有ZooKeeper
会话的副本中,数据会异步复制到所有其他副本。由于ClickHouse不支持UPDATE,因此复制是无冲突的。由于默认情况下没有插入的仲裁确认,因此如果一个节点发生故障,刚刚插入的数据可能会丢失。可以使用insert_quorum
设置启用插入仲裁。
复制的元数据存储在ZooKeeper中。有一个复制日志,其中列出了要执行的操作。操作包括:获取数据块;合并数据块;删除分区,等等。每个副本将其复制日志复制到其队列中,然后从队列中执行操作。例如,在插入时,会在日志中创建“获取数据块”操作,并且每个副本都会下载该数据块。副本之间会协调合并以获得字节相同的输出。所有数据块都在所有副本上以相同的方式合并。其中一个领导者首先启动新的合并并将“合并数据块”操作写入日志。多个副本(或所有副本)可以同时成为领导者。可以使用merge_tree
设置replicated_can_become_leader
阻止副本成为领导者。领导者负责调度后台合并。
复制是物理的:只有压缩后的数据块会在节点之间传输,而不是查询。在大多数情况下,每个副本独立处理合并以降低网络成本,避免网络放大。只有在复制延迟很大的情况下,才会通过网络发送大型合并后的数据块。
此外,每个副本将其状态存储在ZooKeeper中,作为数据块集及其校验和。当本地文件系统上的状态与ZooKeeper中的参考状态不一致时,副本会通过从其他副本下载丢失和损坏的数据块来恢复其一致性。当本地文件系统中存在一些意外或损坏的数据时,ClickHouse不会删除它,而是将其移动到一个单独的目录并将其忽略。
ClickHouse集群由独立的分片组成,每个分片由副本组成。集群**不是弹性的**,因此在添加新的分片后,数据不会自动在分片之间重新平衡。相反,集群负载应该被调整为不均匀的。这种实现为您提供了更多控制权,并且对于相对较小的集群(例如数十个节点)来说是可以的。但是对于我们在生产中使用的数百个节点的集群来说,这种方法成为一个显著的缺点。我们应该实现一个跨越集群的表引擎,并具有动态复制的区域,这些区域可以自动拆分并在集群之间平衡。