跳到主要内容
跳到主要内容
编辑此页面

架构概览

ClickHouse 是一个真正的面向列的 DBMS。数据按列存储,并在数组(向量或列块)执行期间进行处理。在可能的情况下,操作会在数组上而不是在单个值上分派。这被称为“向量化查询执行”,它有助于降低实际数据处理的成本。

这个想法并不新鲜。它可以追溯到 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 方法接受一个过滤器字节掩码。它用于 WHEREHAVING 关系运算符。其他示例:IColumn :: permute 方法支持 ORDER BYIColumn :: cut 方法支持 LIMIT

各种 IColumn 实现(ColumnUInt8ColumnString 等)负责列的内存布局。内存布局通常是连续数组。对于整数类型的列,它只是一个连续数组,如 std :: vector。对于 StringArray 列,它是两个向量:一个用于所有数组元素,连续放置,第二个用于到每个数组开头的偏移量。还有 ColumnConst,它在内存中仅存储一个值,但看起来像一列。

字段

尽管如此,也可以处理单个值。为了表示单个值,使用了 FieldField 只是 UInt64Int64Float64StringArray 的可区分联合。IColumn 具有 operator [] 方法,用于获取第 n 个值作为 Field,以及 insert 方法,用于将 Field 附加到列的末尾。这些方法效率不高,因为它们需要处理表示单个值的临时 Field 对象。还有更有效的方法,例如 insertFrominsertRangeFrom 等。

Field 没有关于表特定数据类型的足够信息。例如,UInt8UInt16UInt32UInt64Field 中都表示为 UInt64

泄漏的抽象

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

可以使用 IColumn 方法提取 Field 值,以通用、非高效的方式实现列上的各种函数,或者使用对特定 IColumn 实现中数据内部内存布局的了解,以专门的方式实现。它是通过将函数强制转换为特定的 IColumn 类型并直接处理内部表示来实现的。例如,ColumnUInt64 具有 getData 方法,该方法返回对内部数组的引用,然后一个单独的例程直接读取或填充该数组。我们有“泄漏的抽象”来允许各种例程的有效专业化。

数据类型

IDataType 负责序列化和反序列化:用于以二进制或文本形式读取和写入列块或单个值。IDataType 直接对应于表中的数据类型。例如,有 DataTypeUInt32DataTypeDateTimeDataTypeString 等。

IDataTypeIColumn 彼此之间只有松散的关系。不同的数据类型可以在内存中由相同的 IColumn 实现表示。例如,DataTypeUInt32DataTypeDateTime 都由 ColumnUInt32ColumnConstUInt32 表示。此外,相同的数据类型可以由不同的 IColumn 实现表示。例如,DataTypeUInt8 可以由 ColumnUInt8ColumnConstUInt8 表示。

IDataType 仅存储元数据。例如,DataTypeUInt8 根本不存储任何内容(除了虚拟指针 vptr),而 DataTypeFixedString 仅存储 N(固定大小字符串的大小)。

IDataType 具有各种数据格式的辅助方法。示例是以可能的引号序列化值、为 JSON 序列化值以及将值序列化为 XML 格式一部分的方法。与数据格式没有直接对应关系。例如,不同的数据格式 PrettyTabSeparated 可以使用来自 IDataType 接口的相同 serializeTextEscaped 辅助方法。

Block 是一个容器,表示内存中表的子集(块)。它只是一组三元组:(IColumn, IDataType, 列名)。在查询执行期间,数据由 Block 处理。如果我们有一个 Block,我们有数据(在 IColumn 对象中),我们有关于其类型的信息(在 IDataType 中),它告诉我们如何处理该列,并且我们有列名。它可以是表中的原始列名,也可以是为获取计算的临时结果而分配的一些人工名称。

当我们在块中计算列上的某些函数时,我们会将另一列及其结果添加到块中,并且我们不会触及函数参数的列,因为操作是不可变的。稍后,可以从块中删除不需要的列,但不能修改。这对于消除公共子表达式很方便。

为每个处理的数据块创建块。请注意,对于相同类型的计算,列名和类型对于不同的块保持不变,只有列数据更改。最好将块数据与块头分开,因为小的块大小对于复制 shared_ptrs 和列名具有很高的临时字符串开销。

处理器

请参阅 https://github.com/ClickHouse/ClickHouse/blob/master/src/Processors/IProcessor.h 中的描述。

格式

数据格式通过处理器实现。

I/O

对于面向字节的输入/输出,有 ReadBufferWriteBuffer 抽象类。它们用于代替 C++ iostream。不用担心:每个成熟的 C++ 项目都出于充分的理由使用 iostream 以外的东西。

ReadBufferWriteBuffer 只是一个连续的缓冲区和一个指向该缓冲区中位置的光标。实现可能拥有或不拥有缓冲区的内存。有一个虚拟方法可以用以下数据填充缓冲区(对于 ReadBuffer)或将缓冲区刷新到某处(对于 WriteBuffer)。虚拟方法很少被调用。

ReadBuffer/WriteBuffer 的实现用于处理文件和文件描述符以及网络套接字,用于实现压缩(CompressedWriteBuffer 使用另一个 WriteBuffer 初始化,并在将数据写入其中之前执行压缩),以及用于其他目的——名称 ConcatReadBufferLimitReadBufferHashingWriteBuffer 不言自明。

Read/WriteBuffers 仅处理字节。ReadHelpersWriteHelpers 头文件中的函数有助于格式化输入/输出。例如,有一些助手以十进制格式写入数字。

让我们检查一下当您想以 JSON 格式将结果集写入 stdout 时会发生什么。您有一个准备好从拉取 QueryPipeline 获取的结果集。首先,您创建一个 WriteBufferFromFileDescriptor(STDOUT_FILENO) 以将字节写入 stdout。接下来,您将查询管道的结果连接到 JSONRowOutputFormat,该格式使用该 WriteBuffer 初始化,以 JSON 格式将行写入 stdout。这可以通过 complete 方法完成,该方法将拉取 QueryPipeline 转换为已完成的 QueryPipeline。在内部,JSONRowOutputFormat 将写入各种 JSON 分隔符,并使用对 IColumn 的引用和行号作为参数调用 IDataType::serializeTextJSON 方法。因此,IDataType::serializeTextJSON 将调用 WriteHelpers.h 中的方法:例如,数字类型的 writeTextDataTypeStringwriteJSONString

IStorage 接口表示表。该接口的不同实现是不同的表引擎。示例包括 StorageMergeTreeStorageMemory 等。这些类的实例只是表。

IStorage 中的关键方法是 readwrite,以及其他方法,如 alterrenamedropread 方法接受以下参数:要从表中读取的列集、要考虑的 AST 查询以及所需的流数。它返回一个 Pipe

在大多数情况下,read 方法仅负责从表中读取指定的列,而不负责任何进一步的数据处理。所有后续数据处理都由管道的另一部分处理,这超出了 IStorage 的责任范围。

但也有一些值得注意的例外

  • AST 查询传递给 read 方法,表引擎可以使用它来派生索引使用情况并从表中读取更少的数据。
  • 有时,表引擎可以将数据自行处理到特定阶段。例如,StorageDistributed 可以将查询发送到远程服务器,要求它们将数据处理到可以合并来自不同远程服务器的数据的阶段,并返回预处理的数据。然后,查询解释器完成数据处理。

表的 read 方法可以返回由多个 Processors 组成的 Pipe。这些 Processors 可以并行地从表中读取。然后,您可以将这些处理器与各种其他转换(例如表达式评估或筛选)连接起来,这些转换可以独立计算。然后,在它们之上创建一个 QueryPipeline,并通过 PipelineExecutor 执行它。

还有 TableFunction。这些函数返回一个临时的 IStorage 对象,用于查询的 FROM 子句中。

要快速了解如何实现您的表引擎,请查看一些简单的内容,如 StorageMemoryStorageTinyLog

作为 read 方法的结果,IStorage 返回 QueryProcessingStage – 关于查询的哪些部分已在存储中计算的信息。

解析器

手写的递归下降解析器解析查询。例如,ParserSelectQuery 只是递归地调用底层解析器来处理查询的各个部分。解析器创建一个 ASTAST 由节点表示,节点是 IAST 的实例。

由于历史原因,未使用解析器生成器。

解释器

解释器负责从 AST 创建查询执行管道。有简单的解释器,如 InterpreterExistsQueryInterpreterDropQuery,以及更复杂的 InterpreterSelectQuery

查询执行管道是处理器的组合,处理器可以消耗和生成块(具有特定类型的列集)。处理器通过端口进行通信,并且可以有多个输入端口和多个输出端口。更详细的描述可以在 src/Processors/IProcessor.h 中找到。

例如,解释 SELECT 查询的结果是一个“拉取” QueryPipeline,它有一个特殊的输出端口,用于从中读取结果集。解释 INSERT 查询的结果是一个“推送” QueryPipeline,它有一个输入端口,用于写入要插入的数据。解释 INSERT SELECT 查询的结果是一个“已完成” QueryPipeline,它没有输入或输出,但同时将数据从 SELECT 复制到 INSERT

InterpreterSelectQuery 使用 ExpressionAnalyzerExpressionActions 机制进行查询分析和转换。这是执行大多数基于规则的查询优化的地方。ExpressionAnalyzer 非常混乱,应该重写:各种查询转换和优化应该提取到单独的类中,以允许查询的模块化转换。

为了解决解释器中存在的问题,开发了一个新的 InterpreterSelectQueryAnalyzer。这是 InterpreterSelectQuery 的新版本,它不使用 ExpressionAnalyzer,并在 ASTQueryPipeline 之间引入了一个额外的抽象层,称为 QueryTree'。它已完全准备好在生产中使用,但为了以防万一,可以通过将 enable_analyzer 设置的值设置为 false` 来关闭它。

函数

有普通函数和聚合函数。对于聚合函数,请参阅下一节。

普通函数不会更改行数——它们的工作方式就像它们独立处理每一行一样。实际上,函数不是为单个行调用的,而是为数据 Block 调用的,以实现向量化查询执行。

有一些杂项函数,如 blockSizerowNumberInBlockrunningAccumulate,它们利用块处理并违反行的独立性。

ClickHouse 具有强类型,因此没有隐式类型转换。如果函数不支持特定类型组合,则会抛出异常。但是函数可以针对许多不同的类型组合工作(重载)。例如,plus 函数(用于实现 + 运算符)适用于任何数字类型组合:UInt8 + Float32UInt16 + 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 类继承,后者实现 clickhouse-server 本身。因此,可以通过 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_sizemax_io_thread_pool_free_sizeio_thread_pool_queue_size 设置。 IO 线程池的主要目的是避免 IO 作业耗尽全局池,这可能会阻止查询充分利用 CPU。 备份到 S3 会执行大量的 IO 操作,为了避免对交互式查询产生影响,有一个单独的 BackupsIOThreadPool,它使用 max_backups_io_thread_pool_sizemax_backups_io_thread_pool_free_sizebackups_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) 调用附加到其线程组。 线程组用于聚合配置文件事件计数器并跟踪专用于单个任务的所有线程的内存消耗(有关更多信息,请参见 MemoryTrackerProfileEvents::Counters 类)。

并发控制

可以并行化的查询使用 max_threads 设置来限制自身。 此设置的默认值选择的方式是允许单个查询以最佳方式利用所有 CPU 核心。 但是,如果有多个并发查询,并且每个查询都使用默认的 max_threads 设置值,会怎么样? 然后,查询将共享 CPU 资源。 操作系统将通过不断切换线程来确保公平性,这会带来一些性能损失。 ConcurrencyControl 有助于处理此性能损失并避免分配大量线程。 配置设置 concurrent_threads_soft_limit_num 用于限制在应用某种 CPU 压力之前可以分配多少并发线程。

注意

concurrent_threads_soft_limit_numconcurrent_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 可以处于两种不同的状态:grantedacquired。 前者是一种过渡状态,实际上应该很短暂(从 slot 分配给查询的那一刻到该查询的任何线程运行向上扩展过程的那一刻)。

ConcurrencyControl 的 API 由以下函数组成

  1. 为查询创建资源分配:auto slots = ConcurrencyControl::instance().allocate(1, max_threads);。 它将分配至少 1 个,最多 max_threads 个 slot。 请注意,第一个 slot 会立即授予,但其余 slot 可能会稍后授予。 因此,限制是软性的,因为每个查询都将获得至少一个线程。
  2. 对于每个线程,都必须从分配中获取一个 slot:while (auto slot = slots->tryAcquire()) spawnThread([slot = std::move(slot)] { ... });
  3. 更新 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 表中的数据存储在“parts”中。 每个 part 都按主键顺序存储数据,因此数据按主键元组按字典顺序排序。 所有表列都存储在这些 part 中的单独 column.bin 文件中。 文件由压缩块组成。 每个块通常包含 64 KB 到 1 MB 的未压缩数据,具体取决于平均值大小。 这些块由连续放置在一起的列值组成。 每个列的列值顺序相同(主键定义顺序),因此当您迭代多个列时,您将获得相应行的值。

主键本身是“稀疏的”。 它不寻址每一行,而仅寻址某些数据范围。 单独的 primary.idx 文件包含每个第 N 行的主键值,其中 N 称为 index_granularity(通常,N = 8192)。 此外,对于每一列,我们都有带有“marks”的 column.mrk 文件,这些“marks”是数据文件中每个第 N 行的偏移量。 每个 mark 是一对:文件中压缩块开头的偏移量,以及解压缩块中数据开头的偏移量。 通常,压缩块按 marks 对齐,并且解压缩块中的偏移量为零。 primary.idx 的数据始终驻留在内存中,而 column.mrk 文件的数据被缓存。

当我们要从 MergeTree 中的 part 读取某些内容时,我们会查看 primary.idx 数据并找到可能包含请求数据的范围,然后查看 column.mrk 数据并计算开始读取这些范围的偏移量。 由于稀疏性,可能会读取过多的数据。 ClickHouse 不适用于简单点查询的高负载,因为对于每个键,都必须读取具有 index_granularity 行的整个范围,并且对于每个列,都必须解压缩整个压缩块。 我们使索引稀疏,因为我们必须能够维护每个服务器数万亿行的数据,而索引的内存消耗却不明显。 此外,由于主键是稀疏的,因此它不是唯一的:它无法在 INSERT 时检查表中是否存在键。 您可以在一个表中拥有许多具有相同键的行。

当您将一批数据 INSERTMergeTree 中时,该批数据将按主键顺序排序并形成一个新的 part。 有后台线程定期选择一些 part 并将其合并为单个排序的 part,以保持 part 的数量相对较低。 这就是为什么它被称为 MergeTree。 当然,合并会导致“写放大”。 所有 part 都是不可变的:它们仅被创建和删除,但不会被修改。 执行 SELECT 时,它会保留表的快照(一组 part)。 合并后,我们还会保留旧的 part 一段时间,以便在发生故障后更容易恢复,因此如果我们看到某个合并的 part 可能已损坏,我们可以将其替换为源 part。

MergeTree 不是 LSM 树,因为它不包含 MEMTABLE 和 LOG:插入的数据直接写入文件系统。 此行为使 MergeTree 更适合批量插入数据。 因此,频繁插入少量行对于 MergeTree 来说不是理想的。 例如,每秒插入几行是可以的,但每秒插入一千次对于 MergeTree 来说不是最佳的。 但是,有一种用于小批量插入的异步插入模式可以克服此限制。 我们这样做是为了简单起见,并且因为我们已经在我们的应用程序中批量插入数据

有些 MergeTree 引擎在后台合并期间执行额外的工作。 示例是 CollapsingMergeTreeAggregatingMergeTree。 这可以被视为对更新的特殊支持。 请记住,这些不是真正的更新,因为用户通常无法控制后台合并的执行时间,并且 MergeTree 表中的数据几乎总是存储在多个 part 中,而不是完全合并的形式。

复制

ClickHouse 中的复制可以按表配置。 您可以在同一服务器上拥有一些复制表和一些非复制表。 您还可以以不同的方式复制表,例如一个表使用双因子复制,另一个表使用三因子复制。

复制在 ReplicatedMergeTree 存储引擎中实现。 ZooKeeper 中的路径被指定为存储引擎的参数。 ZooKeeper 中具有相同路径的所有表都将成为彼此的副本:它们同步数据并保持一致性。 可以通过创建或删除表来动态添加和删除副本。

复制使用异步多主方案。 您可以将数据插入到任何与 ZooKeeper 建立会话的副本中,并且数据会异步复制到所有其他副本。 由于 ClickHouse 不支持 UPDATE,因此复制是无冲突的。 由于默认情况下没有对插入进行仲裁确认,因此如果一个节点发生故障,刚插入的数据可能会丢失。 可以使用 insert_quorum 设置启用插入仲裁。

复制的元数据存储在 ZooKeeper 中。 有一个复制日志,列出了要执行的操作。 操作包括:获取 part;合并 part;删除分区等等。 每个副本都将复制日志复制到其队列,然后从队列中执行操作。 例如,在插入时,日志中会创建“获取 part”操作,并且每个副本都会下载该 part。 副本之间会协调合并,以获得字节相同的結果。 所有 part 在所有副本上以相同的方式合并。 其中一个领导者首先发起新的合并,并将“合并 part”操作写入日志。 多个副本(或所有副本)可以同时成为领导者。 可以使用 merge_tree 设置 replicated_can_become_leader 阻止副本成为领导者。 领导者负责调度后台合并。

复制是物理的:只有压缩的 part 在节点之间传输,而不是查询。 在大多数情况下,合并是在每个副本上独立处理的,以通过避免网络放大来降低网络成本。 仅在复制延迟显着的情况下,才通过网络发送大型合并的 part。

此外,每个副本都将其状态作为 part 集及其校验和存储在 ZooKeeper 中。 当本地文件系统上的状态与 ZooKeeper 中的参考状态不同时,副本通过从其他副本下载丢失和损坏的 part 来恢复其一致性。 当本地文件系统中存在一些意外或损坏的数据时,ClickHouse 不会删除它,而是将其移动到单独的目录并忽略它。

注意

ClickHouse 集群由独立的 shard 组成,每个 shard 由副本组成。 该集群是非弹性的,因此在添加新的 shard 后,数据不会在 shard 之间自动重新平衡。 相反,集群负载应该调整为不均匀。 此实现为您提供了更多控制权,对于相对较小的集群(例如数十个节点)来说,这是可以接受的。 但是对于我们生产环境中使用的数百个节点的集群,这种方法成为一个明显的缺点。 我们应该实现一个跨集群的表引擎,该引擎具有动态复制的区域,这些区域可以自动在集群之间拆分和平衡。