ClickHouse 的副本与分片

楔子

纵使单节点性能再强,也会有遇到瓶颈的那一天,业务量的持续增长、服务器的意外故障,都是 ClickHouse 需要面对的洪水猛兽。但常言道:一个好汉三个帮,一个篱笆三个桩,放在计算机领域就是,一个节点不够,就多来几个节点,下面就来介绍一下 ClickHouse 的集群、副本与分片。

概述

集群是副本和分片的基础,它将 ClickHouse 的服务拓扑由单节点延伸到多个节点,但它并不像 Hadoop 生态的某些系统那样,要求所有节点组成一个单一的大集群。ClickHouse 的集群配置非常灵活,用户既可以将所有节点组成一个单一集群,也可以按照业务的诉求把节点划分为多个小的集群。在每个小的集群区域之间,它们的节点、分区和副本数量可以各不相同,如图所示。

从作用来看,ClickHouse 集群的工作更多是针对逻辑层面的,集群定义了多个节点的拓扑关系,这些节点在后续服务过程中可能会协同工作,而执行层面的具体工作则交给了副本和分片来执行。

集群很好理解,那副本和分片又是什么呢?这对双胞胎兄弟有时看起来泾渭分明,有时又让人分辨不清。这里有两种区分办法,第一种是从数据层面区分,假设 ClickHouse 的 N 个节点组成了一个集群,在集群的各个节点上都有一张结构相同的数据表 Y,如果 Node1 和 Node2 上的 Y 的数据完全不同,则 Node1 和 Node2 互为分片;如果它们的数据完全相同,则它们互为副本。换言之,分片之间的数据是不同的,而副本之间的数据是完全相同的。所以抛开表引擎不同,但从数据层面来看,副本和分片有时候只有一线之隔。

另一种是从功能作用层面区分,使用副本的主要目的是防止数据丢失,增加数据存储的冗余;而使用分片的主要目的是实现数据的水平切分,如图所示:

下面我们就来逐步介绍副本、分片和集群的使用方法,我们当前是只有一个节点,也就是只有 1 个分片,并且数据只有一份,相当于没有备份,也就是 0 副本。注意:关于副本,不同框架有着不同的定义,比如 HDFS 中的三副本存储,那么数据总共有三份,如果是 1 副本,那么数据就只有 1 份。但是 ClickHouse 中 1 副本表示数据额外有一份备份,那么言下之意就是有两份,但是为了表述方便,后续我们仍采用副本 1、副本 2 来称呼。再比如 ClickHouse 中的 2 副本,那么说明数据有 3 份,但我们会用副本 1、副本 2、副本 3 来称呼。当然这些东西理解就好,只是为了避免出现歧义,需要提前先说清楚。

而我们接下来会从数据表的初始形态 1 分片、0 副本开始介绍;然后再说如何为它添加副本,从而形成 1 分片、1 副本的状态;然后接着再说如何引入分片,将其转化为多分片、1 副本的形态(多副本的形态以此类推)。

这种形态的变化过程像极了企业内的业务发展过程,在业务初期我们会从单张数据表开始;在业务上线一段时间之后,可能会为它增加副本,以保证数据的安全,或者希望进行读写分离从而增加并发量;但随着业务的发展,数据量越来越大,因此会进一步为其增加分片,从而实现数据的水平切分。

数据副本

我们之前介绍 MergeTree 的时候,介绍过它的命名规则,如果在 *MergeTree 的前面加上 Replicated 前缀,则能够组合成一个新的变种引擎,如图所示:

换言之,只有使用了 ReplicatedMergeTree 复制表系列引擎,才能应用副本的能力(后面会说另一种副本的实现方式);或者用一种更为直接的方式理解,即使用了 ReplicatedMergeTree 的数据表就是副本。

所以 ReplicatedMergeTree 是 MergeTree 的派生引擎,它在 MergeTree 的基础之上加入了分布式协同的能力,如图所示:

在 MergeTree 中,一个数据分区又开始创建到全部完成,会经历两个存储区域。

  • 1. 内存:数据首先会被写入内存缓冲区
  • 2. 本地磁盘:数据接着会被写入 tmp 临时目录分区,待全部完成之后再将临时目录重命名为正式分区

而 ReplicatedMergeTree 在上述基础之上增加了 ZooKeeper 的部分,它会进一步在 ZooKeeper 内创建一系列的监听节点,并以此实现多个实例之间的通信。在整个通信过程中,ZooKeeper 并不会涉及表数据的传输。

副本的特点

作为数据副本的主要实现载体,ReplicatedMergeTree 在设计上有一些显著特点。

  • 依赖 ZooKeeper:在执行 INSERT 和 ALTER 查询的时候,ReplicatedMergeTree 需要借助 ZooKeeper 的分布式协同能力,来实现多个副本之间的同步。但是在查询副本的时候,并不需要使用 ZooKeeper,关于这方面的信息,后续会详细介绍。
  • 表级别的副本:副本是在表级别定义的,所以每张表的副本配置都可以按照它的实际需求进行个性化定义,包括副本的数量,以及副本在集群内的分布位置。
  • 多主架构(Multi Master):可以在任意一个副本上执行 INSERT 和 ALTER 查询,它们的效果是相同的,这些操作会借助 ZooKeeper 的协同能力被分发至每个副本以本地形式执行。
  • Block 数据块:在执行 INSERT 命令写入数据时,会依据 max_insert_block_size 的大小(默认 1048576 行)将数据切分成若干个 Block 数据块。因此 Block 数据块是数据写入的基本单元,并且具有写入的原子性和唯一性。
  • 原子性:在数据写入时,一个 Block 数据块内的数据要么全部写入成功,要么全部写入失败。
  • 唯一性:在写入一个 Block 数据块的时候,会按照当前 Block 数据块的数据顺序、数据行和数据大小等指标,计算 Hash 信息摘要并记录。在此之后,如果某个待写入的 Block 数据块与先前已被写入的 Block 数据块拥有相同的 Hash 摘要(Block 数据块内数据顺序、数据大小和数据行均相同),则该 Block 数据块会被忽略。这项设置可以预防由异常原因引起的 Block 数据块重复写入的问题。

如果光看上面这些文字介绍的话, 可能不够直观,那么下面就用示例逐步展开。

ZooKeeper 的配置方式

在正式开始之前,还需要做一些准备工作,那就是安装并配置 ZooKeeper,因为 ReplicatedMergeTree 必须对接到它才能工作。关于 zk 的安装,此处不再赘述,使用 3.4.5 以上的版本均可,我这里安装完成,下面重点介绍如何在 ClickHouse 中增加 ZooKeeper 的配置。

ClickHouse 使用一组 zookeeper 标签定义相关配置,默认情况下在 config.xml 中配置即可,将配置写在 config.xml 的 <yandex> 标签里面。

<zookeeper>   
    <node index="1">  <!-- ZooKeeper 所在节点配置,可以配置多个地址 -->
        <!-- 节点 IP,也是当前 ClickHouse Server 所在节点 -->
        <host>47.94.174.89</host>  
        <!-- ZooKeeper 监听端口,默认 2181 -->
        <!-- 由于我们后面会搭建副本,并且只用这一个 ZooKeeper,所以要保证 2181 端口对外开放 -->
        <port>2181</port> 
    </node>
</zookeeper>

当然 config.xml 里面也提供了 zookeeper 这个标签,不过是被注释掉的,这里我们不管它,直接拷贝进去即可。

配置完之后,我们使用 clickhouse restart 重启服务。然后 ClickHouse 在它的系统表中贴心地准备了一张名为 zookeeper 的代理表,可以使用 SQL 查询的方式读取远端 ZooKeeper 的数据。

SELECT czxid, mzxid, name, value FROM system.zookeeper WHERE path = '/';

注意:查询的时候必须指定 path 进行条件筛选。

这里需要先来简单说一下 ZooKeeper 的数据模型,ZooKeeper 的数据模型和 UNIX 文件系统很类似,整体上可以看做是一棵树。树上的每一个节点都称之为一个 ZNode,可以通过路径进行唯一标识,因为每个节点都有一个名称,我们随便挑两个节点画张图就清晰了。

根节点就是 /,每一个节点下面可以创建多个子节点,并层层递归下去,所以就像文件系统一样。而我们在查找的时候也是如此,从根节点出发,层层组合,因为每一个节点都有自己的名称,按照顺序组合起来即可得到 path。因此我们指定 path 等于 / 即可查到根节点下面的所有节点,而图中的 name 字段显示的就是 ZNode 的名称,注意:只显示一层,不会递归显示。

并且每个 ZNode 默认能够存储 1MB 的数据,而图中 system.zookeeper 的 value 字段显示的就是 ZNode 存储的值,至于 ZNode 的其它属性可以自己查阅一下,这里就不多说了。另外,由于我这 ZooKeeper 很早就存在了,所以里面包含了很多与 ClickHouse 无关的数据,如果你是新安装的,那么信息不会像上面这么多。

-- 这里我们查询 clickhouse 下面所有的 ZNode,那么将条件改成 path = '/clickhouse' 即可
-- 当前只有一个 task_queue,原因是我们还没有建表,而建表之后,这里就会多出一个名为 tables 的 ZNode
-- 然后 /clickhouse/tables 下面又会存在名为 01、02... 的 ZNode,对应副本 1、副本 2....
SELECT czxid, mzxid, name, value FROM system.zookeeper
WHERE path = '/clickhouse';
/*
┌─czxid─┬─mzxid─┬─name───────┬─value─┐
│  1087 │  1087 │ task_queue │       │
└───────┴───────┴────────────┴───────┘
*/

副本的定义形式

正如前文所言,使用副本的好处甚多。首先,由于增加了数据的存储冗余,所以降低了数据丢失的风险;其次,由于副本采用了多主架构,所以每个副本实例都可以作为数据读、写的入口,这无疑分摊了节点的负载。

在使用副本时,不需要依赖任何集群的配置(关于集群后面说),ReplicatedMergeTree 结合 ZooKeeper 就能完成全部工作。

ReplicatedMergeTree 的定义方式如下:

ENGINE = ReplicatedMergeTree('zk_path', 'replica_name')

在上述配置项中,有 zk_path 和 replica_name 两项,首先介绍 zk_path 的作用。

zk_path 用于指定在 ZooKeeper 中创建的数据表的路径,路径名称是自定义的,并没有固定规则,用户可以设置成自己希望的任何路径。即便如此,ClickHouse 还是提供了一些约定俗成的配置模板以供参考,例如:

/clickhouse/tables/{shard}/table_name

其中:

  • /clickhouse/tables 是约定俗成的路径固定前缀,表示存放数据表的根路径。
  • {shard} 表示分片编号,通常用数值替代,例如 01、02、03,一张数据表可以有多个分片,而每个分片都拥有自己的副本。
  • table_name 表示数据表的名称,为了方便维护,通常与物理表的名字相同(虽然 ClickHouse 并不要求路径中的表名称和物理表名必须一致);而 replica_name 的作用是定义在 ZooKeeper 中创建的副本名称,该名称是区分不同副本实例的唯一标识。一种约定俗成的方式是使用所在服务器的域名称。

对于 zk_path 而言,同一张数据表的同一个分片的不同副本,应该定义相同的路径;而对于 replica_name 而言,同一张数据表的同一个分片的不同副本,应该定义不同的名称。读起来很拗口,我们举个栗子说明一下。

1 个分片、1 个副本的情形:

-- zk_path 相同,replica_name 不同
ReplicatedMergeTree('/clickhouse/tables/01/test_1', '192.168.0.1')
ReplicatedMergeTree('/clickhouse/tables/01/test_1', '192.168.0.2')

多个分片、1 个副本的情形:

-- 分片1(2 分片、1 副本),zk_path 相同,其中 shard = 01,replica_name 不同
ReplicatedMergeTree('/clickhouse/tables/01/test_1', '192.168.0.1')
ReplicatedMergeTree('/clickhouse/tables/01/test_1', '192.168.0.2')

-- 分片2(2 分片、1 副本),zk_path 相同,其中 shard = 02,replica_name 不同
ReplicatedMergeTree('/clickhouse/tables/02/test_1', '192.168.0.3')
ReplicatedMergeTree('/clickhouse/tables/02/test_1', '192.168.0.4')

首先是 zk_path,无论一张表有多少个分片、多少个副本,它们终归属于同一张表,所以 zk_path 的最后一部分、也就是表名称是不变的,这里始终是 test_1。但问题是多个分片之间要如何区分呢?所以此时就依赖于 {shard},/clickhouse/tables/01/test_1 表示 test_1 的第 1 个分片,/clickhouse/tables/02/test_1 表示 test_1 的第 2 个分片,第 3、4、5..... 个分片依次类推,至于其它的表也是同理。

而一个分片不管有多少个副本,这些副本终归都属于同一个分片、同一张表。所以同一张数据表的同一个分片的不同副本,应该定义相同的路径;例如表 test_2 的每个分片都有 3 个副本,那么以第 8 个分片为例,它的所有副本的 zk_path 就都应该配成:

/clickhouse/tables/08/test_2

然后是 replica_name,这个就比较简单了,因为要区分同一个分区内多个副本,显然它们要有不同的名称。所以上面读起来很拗口的第二个句话就解释完了,对于 replica_name 而言,同一张数据表的同一个分片的不同副本,应该定义不同的名称,这里我直接使用 IP 地址替代了。

ReplicatedMergeTree 原理解析

正如我们之前分析 MergeTree 一样,ReplicatedMergeTree 作为复制表系列的基础表引擎,涵盖了数据副本最为核心的逻辑,我们很明显要拿它入手。只要明白了 ReplicatedMergeTree 的核心原理,就能掌握整个 ReplicatedMergeTree 系列表引擎的使用方法。

数据结构

在 ReplicatedMergeTree 的核心逻辑中,大量运用了 ZooKeeper 的能力,以实现多个 ReplicatedMergeTree 副本实例之间的协同,包括主副本选举、副本状态感知、操作日志分发、任务队列和 BlockID 去重判断等等。在执行 INSERT 数据写入、MERGE 分区和 MUTATION 操作的时候,都会涉及与 ZooKeeper 的通信。但是在通信的过程中,并不会涉及任何表数据的传输,在查询数据的时候也不会访问 ZooKeeper,所以无需担心 ZooKeeper 的承载压力。

因为 ZooKeeper 对 ReplicatedMergeTree 非常重要,所以下面首先从它的数据结构开始介绍。

ZooKeeper 内的节点结构

ReplicatedMergeTree 需要依靠 ZooKeeper 的时间监听机制以实现各个副本之间的协调,所以在每张 ReplicatedMergeTree 表的创建过程中,它会以 zk_path 为根路径,在 ZooKeeper 中为这张表创建一组监听节点。而按照作用的不同,这些监听节点可以分成如下几类(先有个印象,后面会通过实际案例来体现):

1)元数据:

  • {zk_path}/metadata:保存元数据信息,包括主键、分区键、采样表达式等
  • {zk_path}/columns:保存列字段信息,包括列名称和数据类型
  • {zk_path}/replicas:保存副本名称,对应设置参数中的 replica_name

2)判断标识:

  • {zk_path}/leader_election:用于主副本的选举工作,主副本会主导 MERGE 和 MUTATION 操作(ALTER DELETE 和 ALTER UPDATE,类似关系型数据库中的 DELETE 和 UPDATE),这些任务在主副本完成之后再借助 ZooKeeper 将消息事件分发至其它副本
  • {zk_path}/blocks:记录 Block 数据块的 Hash 信息摘要,以及对应的 partition_id,通过 Hash 信息摘要能够判断 Block 块是否重复;通过 partition_id,则能够找到需要同步的数据分区
  • {zk_path}/block_numbers:按照分区的写入顺序,以相同的顺序记录 partition_id,各个副本在本地进行 MERGE 时,都会依照相同的 block_numbers 顺序进行
  • {zk_path}/quorum:记录 quorum 的数量,当至少有 quorum 数量的副本写入成功后,整个写入才算成功。quorum 的数量由 insert_quorum 参数控制,默认值为 0

3)操作日志:

  • {zk_path}/log:常规操作日志节点(INSERT、MERGE 和 DROP PARTITON),它是整个工作机制中最为重要的一环,保存了副本需要执行的任务指令。log 使用了 ZooKeeper 的持久顺序型节点,每条指令的名称以 log- 为前缀递增,例如 log-0000000000、log-0000000001 等。每一个副本实例都会监听 /log 节点,当有新的指令加入时,它们会把指令加入副本各自的任务队列,并执行任务。关于这方面的逻辑,后续详细介绍
  • {zk_path}/mutations:MUTATION 操作日志节点,作用与 log 日志类似,当执行 ALTER DELETE 和 ALTER UPDATE 查询时,操作指令会被添加到这个点。mutations 同样使用了 ZooKeeper 的持久顺序节点,但是它的命名没有前缀,每条指令直接以递增数字的形式保存,例如 0000000000、0000000001 等。关于这方面的逻辑,同样后续展开
  • {zk_path}/replicas/{replica_name}/*:每个副本各自的节点下的一组监听节点,用于指导副本在本地执行具体的任务指令,其中较为重要的节点有如下几个:
    • {zk_path}/replicas/{replica_name}/queue:任务队列节点,用于执行具体的操作任务。当副本从 /log 或 /mutations 节点监听到操作指令时,会将执行任务添加至该节点下,并基于队列执行
    • {zk_path}/replicas/{replica_name}/log_pointer:log 日志指针节点,记录了最后一次执行的 log 日志下标信息,例如 log_poniter:4 对应了 /log/log-0000000003(从 0 开始计数)
    • {zk_path}/replicas/{replica_name}/mutation_pointer:mutations 日志指针节点,记录了最后一次执行的 mutations 日志名称,例如 mutation_pointer:0000000000 对应了 {zk_path}/mutations/0000000000

Entry 日志对象的数据结构

从上面的介绍可知,ReplicatedMergeTree 在 ZooKeeper 中有两组非常重要的父节点,那就是 /log 和 /mutations,为了简便,接下来介绍路径时候就不写 {zk_path} 了。它们的作用犹如一座通信塔,是分发操作指令的信息通道,而分发操作之灵的方式,则是为这些父节点添加子节点。所有的副本实例,都会监听父节点的变化,当有子节点被添加时,它们能够实时感知。

当然这些被添加的子节点在 ZooKeeper 中就是相应的 ZNode,而 ZNode 的存储的值在 ClickHouse 中统一被抽象为 Entry 对象,而具体实现则由 LogEntry 和 MutationEntry 对象承载,分别对应 /log 和 /mutations 的子节点的 value。

1)LogEntry:

LogEntry 用于封装 /log 的子节点信息,大白话解释的话,其实 /log 下面的 ZNode 存储的 value 就是 LogEntry,它拥有如下几个核心属性。

  • source replica:发送这条 Log 指令的副本来源,对应 replica_name
  • block_id:当前分区的 BlockID,对应 /blocks 路径下子节点的名称
  • 操作指令类型,主要有 get、merge 和 mutate 三种,分别对应从远程副本下载分区、合并分区以及 MUTATION 操作
  • 当前分区目录的名称

2)MutationEntry:

MutationEntry 用于封装 /mutations 的子节点信息,它同样拥有如下几个核心属性。

  • source replica:发送这条 MUTATION 指令的副本来源,对应replica_name
  • commands:操作指令,主要有 ALTER DELETE 和 ALTER UPDATE
  • mutation_id:MUTATION 操作的版本号
  • partition_id:当前分区目录的 ID

以上就是 Entry 日志对象的数据结构信息,在接下来将要介绍的核心流程中,会看到它们的身影。

分区副本协同的核心流程

副本协同的核心流程主要有 INSERT、MERGE、MUTATION 和 ALTER 四种,分别对应了数据写入、分区合并、数据修改和元数据修改。INSERT 和 ALTER 查询是分布式执行的,借助 ZooKeeper 的事件通知机制,多个副本之间会自动进行有效协同,但是它们不会使用 ZooKeeper 存储任何分区数据。至于其他查询并不支持分布式执行,包括 SELECT、CREATE、DROP、RENAME 和 ATTACH。例如,为了创建多个副本,我们需要分别登录每个 ClickHouse 节点,在它们本地执行各自的 CREATE 语句(后面将会介绍如何利用集器配置简化这一操作)。接下来,会依次介绍上述流程的工作机理。为了便于理解,我们先来整体认识一下各个流程的介绍方法。

首先,拟定一个演示场景,即使用 ReplicatedMergeTree 实现一张拥有 1 分片、1 副本的数据表,并以此来贯穿整个讲解过程(对于大于 1 个副本的场景,流程以此类推)。

接着,通过对 ReplicatedMergeTree 分别执行 INSERT、MERGE、MUTATION 和 ALTER 操作,以此来讲解相应的工作原理。与此同时,通过实际案例,论证工作原理。

首先到这里我们只有一个节点就有些捉襟见肘了,因为我们要实现 1 分片、1 副本,那么至少要有两个节点。而在我当前阿里云上有三台服务器,相关信息如下:

  • 47.94.174.89,主机名为 satori,2 核心 8GB 内存
  • 47.93.39.238,主机名为 matsuri,2 核心 4GB 内存
  • 47.93.235.147,主机名为 aqua,2 核心 4GB 内存

主机 satori 就是我们当前一直使用的节点,然后再加上 matsuri 节点来实现我们的 1 分片、1 副本。

INSERT 的核心执行流程

当需要在 ReplicatedMergeTree 中执行 INSERT 查询以写入数据时,即会进入 INSERT 核心流程。而整个流程按照时间从上往下顺序进行,我们大致将其分了 8 个步骤,那么下面就依次讲解每一个过程。

 

1)创建第一个副本实例

使用副本之前,我们需要修改一下配置文件,我们的目的是为了让 satori 节点和 matsuri 节点组合形成 1 分片 1 副本策略,所以需要修改配置文件 config.xml。首先对于 1 分片多副本而言,我们不需要配置集群,只需要配置好 ZooKeeper 即可。当然 1 分片多副本也是可以配置集群的,只不过该集群只有一个分片罢了,但我们说不配置也能实现 1 分片,所以这里我们就采用不配置集群的方式实现;而关于集群的配置,我们在介绍多分片的时候再说,因为如果是多分片,则必须要配置集群,具体内容后面说。下面来看看需要修改哪些配置:

<!-- 配置 ZooKeeper,我们之前就配置过了,可以设置多个 ZooKeeper,这里我们设置单个就行 -->
<zookeeper>   
    <node index="1">  
        <host>47.94.174.89</host>  
        <port>2181</port>
    </node>
</zookeeper>

<!-- 开放给外界访问,我们在最开始的时候也改过了 -->
<listen_host>0.0.0.0</listen_host>

<!-- 节点之间进行通信的 IP,需要进行设置,让节点之间可以互相访问,否则副本无法同步 -->
<!-- 注意:我当前用的阿里云服务器不在同一个内网中,所以指定的是公网 IP
     但如果你的多个阿里服务器云在同一个内网,那么建议指定内网 IP,节点之间通信会比使用公网 IP 快很多 -->
<interserver_http_host>47.94.174.89</interserver_http_host>
<!-- 节点之间进行通信的端口,默认 9009,当然端口无所谓,只要保证彼此之间是开放的就行 -->
<interserver_http_port>9009</interserver_http_port>

修改完配置之后重启 ClickHouse,然后从 satori 节点(47.94.174.89)开始,执行下面的语句,从而创建第一个副本实例:

CREATE TABLE replicated_sales_1 (
    id String,
    price Float64,
    create_time DateTime
) 
-- 这里 replica_name 我们就以 "主机名_replica" 的形式命名
ENGINE=ReplicatedMergeTree('/clickhouse/tables/01/replicated_sales_1', 'satori_replica') 
PARTITION BY toYYYYMM(create_time)
ORDER BY id;

在创建的过程中,ReplicatedMergeTree 会进行一些初始化动作,例如:

  • 根据 zk_path 初始化所有的 ZooKeeper 节点
  • 在 /replicas/ 节点下注册自己的副本实例 satori_replica
  • 启动监听任务,监听 /log 日志节点
  • 参与副本选举,选举出主副本,选举的方式是向 /leader_election 插入子节点,第一个插入成功的副本就是主副本

此时在 satori 节点上,该表(副本)就创建完毕了。

 

2)创建第二个副本实例

接着在 matsuri 节点上创建第二个副本,当然要先安装 ClickHouse,这里我已经安装完毕了。然后我们也要修改配置文件。

<!-- 配置 ZooKeeper,ZooKeeper 在 satori 节点上,指定相关 IP 和端口 -->
<zookeeper>   
    <node index="1">  
        <host>47.94.174.89</host>  
        <port>2181</port>
    </node>
</zookeeper>

<!-- 同理,开放给外界 -->
<listen_host>0.0.0.0</listen_host>

<!-- 节点之间进行通信的 IP,这里改成 matsuri 节点的 IP -->
<interserver_http_host>47.93.39.238</interserver_http_host>
<interserver_http_port>9009</interserver_http_port>

修改完配置文件之后,启动 ClickHouse,如果已经启动,那么就重启 ClickHouse 。然后创建第二个副本,表结构和 zk_path 和第一个副本相同,而 replica_name 则设置成 matsuri 节点的 IP。

CREATE TABLE replicated_sales_1 (
    id String,
    price Float64,
    create_time DateTime
) 
-- 除了 replica_name,其它不变
ENGINE=ReplicatedMergeTree('/clickhouse/tables/01/replicated_sales_1', 'matsuri_replica') 
PARTITION BY toYYYYMM(create_time)
ORDER BY id;

在创建的过程中,第二个 ReplicatedMergeTree 同样会进行一些初始化动作,例如:

  • 在 /replicas/ 节点下注册自己的副本实例 matsuri_replica
  • 启动监听任务,监听 /log 日志节点
  • 参与副本选举,选举出主副本,显然当前的主副本是 satori_replica 副本

两个副本全部创建完了,接下来该干啥了,没错,写入数据。

 

3)向第一个副本实例写入数据

现在尝试向第一个副本 satori_replica 写入数据,执行如下命令:

INSERT INTO replicated_sales_1
VALUES ('A001', 100, '2019-05-10 00:00:00');

在执行上述命令后,首先会在本地完成分区目录的写入:

Renaming temporary part tmp_insert_201905_1_1_0 to 201905_0_0_0

接着向 /blocks 节点写入该数据分区的 block_id:

Wrote block with ID '201905_11789774603085290207_1081647372304402844'

可能有人觉得这个 ID 是从哪里来的,答案是从 ZooKeeper 上面查的:

因为目前只有一个分区,所以 /blocks 节点下面只有一个子节点(ZNode),ZNode 的名字就是 block_id,存储的值就是分区目录名。当然这个 block_id 的计算过程我们就不演示了,它不是重点,只要知道它是作为后续去重操作的判断依据即可。比如我们此时再执行一次刚才的 SQL 语句,试图写入重复数据,然后查询时会发现没有任何效果。

所以副本会自动忽略 block_id 重复的待写入数据。

此外,如果设置了 insert_quorum 参数(默认值为 0)并且值大于等于 2,那么 satori_replica 副本会进一步监控已完成写入操作的副本个数,只有当写入副本个数大于等于 insert_quorum 时,整个写入操作才算成功。

 

4)由第一个副本实例推送 Log 日志

在第 3 个步骤完成之后,会继续由执行了 INSERT 的副本向 /log 节点推送操作日志,在这个栗子中,会由第一个副本 satori_replica 担此重任。

显然里面只有一个 ZNode,其 name 就是日志的编号:/log/log-0000000000,而 value 就是对应的 LogEntry。但这里我们没有打印,原因是里面有特殊的换行,导致打印的时候看起来非常的丑,至于内容如下所示:

format version: 4
create_time: 2021-09-17 15:07:47
source replica: satori_replica
block_id: 201905_11789774603085290207_1081647372304402844
get
201905_0_0_0
part_type: Compact

信息不难理解,里面的 get 表示操作类型,这里是下载。而需要下载的分区是 201905_0_0_0,其余所有副本都会基于 Log 日志以相同的顺序执行命令。

 

5)第二个副本实例拉取 Log 日志

matsuri_replica 副本会一直监听 /log 节点变化,当 satori_replica 副本推送了 /log/log-0000000000 之后,matsuri_replica 副本便会触发日志的拉取任务并更新 log_pointer,将其指向最新日志下标:

/replicas/matsuri_replica/log_pointer: 1

-- /replicas/matsuri_replica 下面的 ZNode 有很多,log_pointer 只是其中一个,所以这里再通过 name 过滤一下
-- 注意:我们查看的是 log_pointer,所以不要将 path 指定为 /replicas/matsuri_replica/log_pointer
-- 上面的做法不对的,因为这等于查看 log_pointer 下面所有的 ZNode,而不是 log_pointer 这个 ZNode
SELECT name, value FROM system.zookeeper
WHERE path = '/clickhouse/tables/01/replicated_sales_1/replicas/matsuri_replica'
AND name = 'log_pointer'

每拉取一次日志,log_pointer 对应的 value 就会自增 1,初始值是 0。

当 matsuri_replica 副本拉取了 satori_replica 副本推送的日志(这里是 /log/log-0000000000),便会根据其内容(LogEntry)进行执行。只不过这个动作并不是马上就发生的,而是会将其转成任务对象放在队列中:

/replicas/matsuri_replica/queue

这里放入队列是让其成为 /replicas/matsuri_replica/queue 的子节点(ZNode),这么做的原因是在复杂的情况下,考虑到在同一个时间段内可能会连续收到许多个 LogEntry,所以通过队列的方式消化任务是一种更为合理的设计。注意:拉取的 LogEntry 是一个区间,这同样是因为可能会连续收到多个 LogEntry。不过当前只有一个 LogEntry,所以区间的开头和结尾是同一个 LogEntry。

Pulling 1 entries to queue: log-0000000000 - log-0000000000

但如果我们此时查看 /replicas/matsuri_replica/queue 的话,会发现拿不到任何的信息,这是因为我们往 satori_replica 副本写入数据的之后,很快就同步到 matsuri_replica 副本当中了,所以此时队列中的任务已经被消费掉了,因此就什么也看不到了。我们可以查询 matsuri_replica 副本,看看数据是否真的同步过来了。

可以看到数据已经在 matsuri_replica 节点上面了,所以创建数据表需要每个副本都要创建,但是插入数据只需要往一个副本中插入即可,剩余的副本会自动同步。不过数据虽然已经同步过来了,但我们的整个流程还没有说完。当把 LogEntry 转成任务对象放到 queue 之后,该做什么了呢?不用想,肯定是从 queue 取出依次执行。

 

6)第二个副本实例向其它副本发起下载请求

matsuri_replica 基于 queue 队列发起下载任务,依次取出任务对象(封装 LogEntry)进行执行,当看到类型为 get 的时候,ReplicatedMergeTree 就会明白此时在远端的其它副本中已经成功写入了数据分区,而自己需要同步这些数据。

format version: 4
create_time: 2021-09-17 15:07:47
source replica: satori_replica
block_id: 201905_11789774603085290207_1081647372304402844
get
201905_0_0_0
part_type: Compact

因此 matsuri_replica 副本会开始选择远端的某一个副本作为下载来源,那么要选择哪一个呢?算法如下:

  • 1. 从 /replicas 中拿到所有的副本节点
  • 2. 遍历这些副本,选取其中一个,选取的副本需要拥有最大的 log_pointer,因为 log_pointer 越大,执行的日志越多,数据也就越完整。以此同时还要 queue 下的 ZNode 少的,因为 ZNode 越少,说明任务执行负担越小

当前远端只有 satori_replica 一个副本实例,所以会从它这里下载,于是 matsuri_replica 向 satori_replica 发起了 HTTP 请求,希望下载分区 201905_0_0_0。

Fetching part 201905_0_0_0 from replicas/satori_replica
Sending request to http://47.94.174.89:9009/?endpoint=DataPartsExchange

如果第一次下载失败,在默认情况下 matsuri_replica 会再尝试 4 次,一共会尝试 5 次。具体尝试次数由 max_fetch_partition_retries_count 参数控制,默认为 5。

 

7)第一个副本实例向响应数据下载

satori_replica 的 DataPartsExchange 端口服务接收到调用请求,在得知对方来意之后,根据参数做出响应,再基于 DataPartsExchange 将本地分区 201905_0_0_0 响应给 matsuri_replica。

Sending part 201905_0_0_0

 

8)第二个副本实例下载数据并完成本地写入

matsuri_replica 在接收到 satori_replica 的分区数据后,首先将其写至临时目录:

tmp_fetch_201905_0_0_0

待全部数据接收完成之后,重命名该目录:

Renaming temporary part tmp_fetch_201905_0_0_0 to 201905_0_0_0

至此整个写入流程结束。

可以看到从头到尾,在整个 INSERT 写入的过程中,ZooKeeper 没有进行任何表数据的传输,它只是起着一个分布式协调的作用。客户端往一个副本里面写入分区数据,然后该副本会往 ZooKeeper 里面推送相关日志,其它副本再拉取日志,根据日志内容(LogEntry)从该副本这里下载数据并写入各自的本地文件系统中。

另外我们说如果设置了 insert_quorum 并且 insert_quorum >= 2,则还会由该副本监控完成写入的副本数量。总之核心就是数据写在哪一个副本,哪一个副本就向 ZooKeeper 里面推日志,然后其它副本拉日志,接着选择一个最合适的远端副本,进行分区数据的点对点下载。

 

流程图总结:

下面再用一张图总结一下上面的 8 个步骤:

MERGE 的核心执行流程

当前我们只写入了一条数据,如果往 201905 这个分区中再写入一条数据呢,显然会创建一个新的分区目录,然后后台线程会在一个合适的时机进行分区目录的合并。这里我们就还往 satori_replica 中写,然后在 matsuri_replica 中读,顺便再次验证副本之间是否会正常同步。

之前我们说,每拉取一次日志,log_pointer 就会自增 1,既然这里发生了数据同步,那么肯定涉及日志的拉取。那么按照分析,matsuri_replica 的 log_pointer 应该会变成 2,因为之前是 1,这里又拉取了一次。

插入数据我们算是已经明白了,那接下来就是合并数据了,也就是 ReplicatedMergeTree 的分区合并动作:MERGE。

其实无论 MERGE 操作从哪个副本发起,最终合并计划都会由主副本决定。在之前的例子中,satori_replica 已经竞选为主副本,所以为了论证,我们就从 matsuri_replica 开始。整个流程还是按照时间从上往下顺序进行,我们大致将其分了 5 个步骤,那么下面就依次讲解每一个过程。

 

1)创建远程连接,尝试与主副本通信

首先在 matsuri_replica 副本所在节点执行 OPTIMIZE,强制触发分区合并,这个时候 matsuri_replica 会通过 /replicas 找到 satori_replica,并尝试建立和它的远程连接。

optimize table replicated_sales_1
Connection (47.94.174.89:9000) : Connetion. Database: default. User: default

 

2)主副本接收通信

主副本接收并建立来自远端副本的连接。

 

3)由主副本制定 MERGE 计划并推送 Log 日志

由主副本 satori_replica 制定 MERGE 计划,并判断哪些分区需要合并,在选定之后 satori_replica 将合并计划转化为 Log 日志对象并推送,以通知所有副本开始合并。那么信息都有哪些呢,直接通过 ZooKeeper 查看即可。

这里我们使用 DataGrip 查询,命令行查询的话,输出的内容不好看。我们注意到 /log 下面有三个 ZNode,log-0000000000 是第一次往 satori_replica 写入数据所产生的日志,log-0000000001 是第二次往 satori_replica 写入数据所产生的日志,而 log-0000000002 显然就是 MEREG 操作产生的日志。

根据内容中的 merge 我们可以得知这是一个合并操作,将 201905_0_0_0 和 201905_1_1_1 合并成 201905_0_1_1。并且通过 source replica 我们也能得知,合并操作是从 matsuri_replica 副本发出的。与此同时,主副本还会锁住执行线程,对日志的接收情况进行监听。其监听行为由 replication_alter_partitions_sync 参数控制,默认值为 1。

  • 如果 replication_alter_partitions_sync 设置为 0,那么不做任何等待
  • 如果 replication_alter_partitions_sync 设置为 1,只等待主副本自身完成
  • 如果 replication_alter_partitions_sync 设置为 2,会等待所有副本拉取完成

 

4)各个副本分别拉取 Log 日志

主副本将 MERGE 计划制定好之后会推到 ZooKeeper 中,然后所有副本会进行拉取并推送到任务队列 /queue 中,也就是成为它的一个 ZNode。

Pulling 1 entries to queue: log-0000000002 - log-0000000002

 

5)各个副本分别在本地执行 MERGE

satori_replica 和 matsuri_replica 基于各自的 /queue 队列执行任务:

Executing log entry to merge parts 201905_0_0_0, 201905_1_1_0 to 201905_0_1_1

各个副本开始在本地执行 MERGE:

Merged 2 parts: from 201905_0_0_0 to 201905_1_1_0

到此,整个合并流程结束。可以看到和插入数据一样,在 MERGE 的过程中,ZooKeeper 也不会涉及任何表数据的传输,所有的合并操作都是由各个副本在本地完成的。并且无论合并动作在哪个副本被触发,首先都会被转交给主副本,再由主副本负责合并计划的制定、消息日志的推送以及日志接收情况的监控。

 

流程图总结:

同样可以用一张流程图总结一下上面 5 个步骤:

MUTATION 的核心流程

对 ReplicatedMergeTree 数据表执行 ALTER DELETE 或 ALTER UPDATE 操作的时候,会进入 MUTATION 部分的逻辑。和 MERGE 类似,无论 MUTATION 操作从哪个副本发起,都会由主副本进行响应,所以为了方便论证,我们还是从不是主副本的 matsuri_replica 副本开始。整个流程按照时间从上往下顺序进行,大致分为 5 个步骤,下面依次介绍。

 

1)推送 Mutation 日志

在 matsuri_replica 通过 ALTER DELETE 来删除数据(ALTER UPDATE 与之同理),执行如下命令:

ALTER TABLE replicated_sales_1 DELETE WHERE id = '1';

执行之后,该副本会进行两个重要动作。

  • 创建 MUTATION ID

created mutation with ID 0000000000

  • 将 MUTATION 操作转换为 Mutation 日志,并推送到 /mutations/0000000000,也就是在 /mutations 下面新建一个名为 0000000000 的 ZNode。其中 0000000000 就是 Mutation 日志的 name,而对应的值、也就是 value 被称为 MutationEntry,这里和 Log 日志类似。MutationEntry 内容如下:

由此也能知晓,MUTATION 的操作日志是经由 /mutations 节点分发至各个副本中的。

 

2)所有副本实例各自监听 Mutation 日志

所有副本都会监听 /mutations 节点,一旦有新的日志子节点加入,它们都能实时感知。

Loading 1 mutation entries: 0000000000 - 0000000000

当监听到有新的 Mutation 日志加入时,并不是所有的副本都会立即响应,它们会先判断自己是不是主副本。

 

3)由主副本实例响应 Mutation 日志并推送 Log 日志

只有主副本才会响应 Mutation 日志,在这个栗子中 satori_replica 为主副本,所以 satori_replica 将 Mutation 日志转换为 Log 日志并推送至 /log 节点,已通知各个副本执行的具体操作。之前 /log 下面最后一个 ZNode 是 log-0000000002,所以接下来会写入一个 log-0000000003。

从日志内容可以看出上述的操作类型为 mutate,而这次需要将 201905_0_1_1 分区修改成 201905_0_1_1_2,修改规则:"201905_0_1_1" + "_" + mutation_id。

 

4)各个副本实例分别拉取 Log 日志

satori_replica 副本和 matsuri_replica 副本分别监听 /log/log-0000000003 日志的推送,它们也会分别拉取日志到本地,通推送到各自的 /queue 任务队列。

Pulling 1 entries to queue: /log/log-0000000003 - /log/log-0000000003

 

5)各个副本分别在本地执行 MUTATION

satori_replica 副本和 matsuri_replica 副本基于各自的 /queue 队列开始执行任务:

Executing log entry to mutate part 201905_0_1_1 to 201905_0_1_1_2

各个副本开始在本地执行 MUTATION 操作:

Cloning part 201905_0_1_1 to tmp_clone_201905_0_1_1_2
Renaming temporary part tmp_clone_201905_0_1_1_2 to 201905_0_1_1_2

至此,整个 MUTATION 流程结束。

可以看到在 MUTATION 的整个过程中,ZooKeeper 同样不会进行任何实质性的数据传输,所有的 MUTATION 操作最终都是由各个副本在本地完成的。而 MUTATION 操作是经过 /mutations 节点实现分发的,本着谁执行谁负责的原则,当前是由 matsuri_replica 负责了消息的推送,在 /mutations 下面新建了 /mutations/0000000000。但无论 MUTATION 操作由哪个副本触发,最终都会转交给主副本,再由主副本负责推送到 Log 日志中,以通知各个副本执行最终的 MUTATION 逻辑,同时也由主副本对日志接收的情况进行监控。

 

流程图总结:

同样可以用一张流程图总结一下上面 5 个步骤:

ALTER 的核心执行流程

对 ReplicatedMergeTree 执行 ALTER 操作进行元数据修改的时候,会进入 ALTER 部分的逻辑,例如增加、删除表字段等等。但与之前的几个流程相比,ALTER 的逻辑要显得简单很多,因为整个过程不涉及 /log 日志的分发。整个流程按照时间从上往下顺序进行,大致可以分为 3 个步骤,下面依次介绍。

 

1)修改共享元数据

在 matsuri_replica 副本上修改,增加一个字段。

-- 增加一个字段 product,表示商品的名字,并且将该字段排在 id 后面
ALTER TABLE replicated_sales_1 ADD COLUMN product String AFTER id;

执行之后,matsuri_replica 副本会修改 ZooKeeper 内的共享元数据节点 /metadata、/columns:

Updated shared metadata nodes in ZooKeeper. Waiting for replicas to apply changes.

数据修改后,节点的版本号也会同时提升:

Version of metadata nodes in ZooKeeper changed. Waiting for structure write lock.

与此同时,matsuri_replica 还会负责监听所有副本的修改完成情况:

Waiting for satori_replica to apply changes
Waiting for matsuri_replica to apply changes

 

2)监听共享元数据变更并各自执行本地修改

satori_replica 和 matsuri_replica 各自监听共享元数据的变更,之后它们会分别对本地的元数据版本号和共享版本号进行对比。在当前这个案例中,它们会发现本地版本号低于共享版本号,于是开始在各自本地执行更新操作:

Metadata changed in ZooKeeper. Applying changes locally.
Applied changes to the metadata of the table.

 

3)确认所有副本完成修改

matsuri_replica 会确认所有副本均已完成修改:

ALTER finished
Done processing query

至此整个 ALTER 流程结束,可以看出该过程 ZooKeeper 同样不会涉及实质性的数据传输,所有的 ALTER 均使用各个副本在本地所完成的。本着谁执行谁负责的原则,在这个案例中由 matsuri_replica 负责对共享元数据进行修改以及对各个副本的修改进度进行监控。

具体执行案例如下,我们在 matsuri_replica 副本上增加一个 product 字段,然后在 satori_replica 副本上查询到了新增加的字段,证明确实进行了同步。

至于 matsuri_replica 副本我们就不用看了,它里面肯定也是会多出一个 product 字段的。

 

流程图总结:

同样可以用一张流程图总结一下上面 3 个步骤:

数据分片

通过引入数据副本,虽然能够有效降低数据丢失的风险(多份存储),并提升查询的性能(分摊查询、读写分离),但是仍然有一个问题没有解决,那就是数据表的容量问题。因为到目前为止,每个副本自身仍然保存了数据表的全量数据,所以在业务量十分庞大的场景中,依靠副本并不能解决单表的性能瓶颈。想要从根本上解决这类问题,需要借助另外一种手段,即进一步将数据水平切分,也就是我们将要介绍的数据分片。

ClickHouse 中的每个服务节点都可以称为一个 shard(分片),从理论上来讲,假设有 N 张数据表 T,分布在 N 个 ClickHouse 服务节点,而这些数据表之前没有重复数据,那么就可以说数据表 T 有 N 个分片。但是在工程实践中,如果只有这些分片表,那么整个 Sharding 方案基本是不可用的。因为对于一个完整的方案来说,还需要考虑数据在写入时,如何被均匀地写入至各个 shard;以及数据在查询时,如何路由到每个 shard,并组合形成结果集。所以 ClickHouse 的数据分片需要结合 Distributed 表引擎一同使用。

我们之前说过 Distributed 表引擎,Distributed 数据表本身不存储任何数据,它只是作为分布式表的一层透明代理,在集群内部自动开展数据的写入、分发、查询、路由等工作。

集群的配置方式

在 ClickHouse 中,集群配置用 shard 代表分片、replica 代表副本,那么逻辑层面,表示 1 分片 0 副本语义的配置就是:

<shard>
    <replica>
        <host></host>
        <port></port>
    </replica>
</shard>

而表示 1 分片、1 副本的语义则是:

<shard>
    <replica>
        <host></host>
        <port></port>
    </replica>
    
    <replica>
        <host></host>
        <port></port>
    </replica>
</shard>

而表示 1 分片、2 副本的语义则是:

<shard>
    <replica>
        <host></host>
        <port></port>
    </replica>
    
    <replica>
        <host></host>
        <port></port>
    </replica>
    
    <replica>
        <host></host>
        <port></port>
    </replica>
</shard>

而表示 2 分片、0 副本的语义则是:

<shard>
    <replica>
        <host></host>
        <port></port>
    </replica>
</shard>

<shard>
    <replica>
        <host></host>
        <port></port>
    </replica>
</shard>

可以看到这种配置再次体现了,shard 只是逻辑层面的分组,最终的承载都是由副本来实现。

相信分片和副本的概念还是很容易区分的,你就可以理解为分片是把多个副本进行分组,每一组就是一个分片,一个分片下面可以有任意个副本。因此还是那句话,副本才是用来实际承载数据的,而分片只是一个起到一个逻辑组织的作用。同一分片下面的所有副本存放的数据相同,实现高可用,一个副本挂了,其它副本顶上去;不同分片下的副本存放的数据不同,从而实现数据的水平切分。

下面就来搭建分片集群,如果是多分片,那么就需要在 config.xml 中搭建集群配置了。

 

这里我们搭建 3 分片 0 副本,那么配置如下:

<!-- 集群的名称,全局唯一,是后续引用集群配置的唯一标识 -->
<!-- 在一个配置文件内可以定义任意组集群,想用哪一个直接通过集群的名称进行引用即可 -->
<ch_cluster_3shard_0replica>
	<shard> <!-- 分片 1 -->
    	<replica> <!-- 该分片只有一个副本,由 satori 节点承载 -->
            <!-- 注意:这里的 host 需要写主机名,因此我们需要在 /etc/hosts 中配置其它节点的公网 IP 到主机名的映射 -->
            <host>satori</host>
            <port>9000</port>
        </replica>
    </shard>
    
    <shard> <!-- 分片 2 -->
    	<replica> <!-- 该分片只有一个副本,由 matsuri 节点承载 -->
            <host>matsuri</host>
            <port>9000</port>
        </replica>
    </shard>
    
    <shard> <!-- 分片 3 -->
    	<replica> <!-- 该分片只有一个副本,由 aqua 节点承载 -->
            <host>aqua</host>
            <port>9000</port>
        </replica>
    </shard>
</ch_cluster_3shard_0replica>

如果只是 1 分片,那么只需要配置 ZooKeeper 即可,但如果是多分片,那么除了 ZooKeeper 之外我们还要配置集群。这里首先要为集群起一个名字,然后自定义分片,当然我们说分片只是逻辑层面的分组,副本才是真正存储数据的。所以还需要在 shard 标签中定义 replica 标签,replica 标签中指定相应的 IP 和端口,从配置中我们可以看出一个 shard 可以有多个 replica,具体有多少个则由我们自己决定;此外也可以有多个 shard,每个 shard 内的 replica 也可以不同。我们上面相当于创建了 3 个 shard,每个 shard 都只有 1 个 replica,在 ClickHouse 中也就是 3 分片 0 副本。

那么问题来了,虽然我们这里只是演示多分片,但上面的配置如果真要放在生产上会有什么后果呢?没错,无法达到高可用,因为每个分片只有一个副本,如果一个节点挂了,那么整个服务就不可用了,所以每个 shard 应该配置多个 replica。这里我们不管那么多,先看看如何实现多分片。

我们关闭 satori、matsuri 节点上的 ClickHouse 服务,然后修改其 config.xml 文件,将上面的配置拷贝到 remote_servers 标签下面即可。最后是 aqua 节点,我阿里云上有三个 CentOS,主机名分别是 satori、matsuri、aqua,目前使用了前两个。而 aqua 节点上还没有 ClickHouse,所以我们需要先安装,安装之后将配置拷贝过去。注意:除了上面的关于集群的配置,还有下面这些配置也别忘记,也就是我们在前两个节点中所做的配置,所有节点都应保持一致。

<zookeeper>   
    <node index="1">  
        <host>47.94.174.89</host>  
        <port>2181</port>
    </node>
</zookeeper>

<listen_host>0.0.0.0</listen_host>
<!-- 内部通信 IP,改成 aqua 节点 -->
<interserver_http_host>47.93.235.147</interserver_http_host>
<interserver_http_port>9009</interserver_http_port>

所有配置都完成之后,我们启动三台节点上的 ClickHouse,然后验证集群是否搭建成功。

我们在 aqua 节点上进行查询,cluster 列表示集群的名字,这里我们选择 cluster 为 ch_cluster_3shard_0replica 的记录;然后 host_name、host_address 表示当前副本所在的节点的主机名、IP;replica_num 表示该副本属于当前所在分片的第几个副本,因为每个分片只有一个副本,所以它们都是 1;shard_num 表示该副本所在的分片是第几个分片,显然 satori 副本处于第一个分片,matsuri 副本处于第二个分片,aqua 处于第三个分片。

基于集群实现分布式 DDL

我们前面介绍副本的时候为了创建副本表,需要分别登录到每个 ClickHouse 节点,在它们本地执行各自的 CREATE 语句,这是因为在默认情况下,CREATE、DROP、RENAME 等 DDL 语句不支持分布式执行。而在加入集群配置之后,就可以使用新的语法实现分布式 DDL 执行了,语法形式如下:

CREATE/DROP/RENAME TABLE table_name ON CLUSTER cluster_name ...

ON CLUSTER 位于 TABLE table_name 之后,表示集群的每个节点都执行该 DDL 语句,而 cluster_name 就是集群的名称,ClickHouse 会通过 cluster_name 找到该集群的配置信息,然后顺藤摸瓜,分别去各个节点中执行 DDL 语句。下面就用分布式 DLL 的形式建表:

CREATE TABLE distributed_test_1 ON CLUSTER ch_cluster_3shard_0replica(
    id UInt64
) ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/distributed_test_1', '{replica}')
ORDER BY id;

该语句先不要执行,我们还有一些细节要说,首先就是这里 ENGINE,可以指定其它任意的表引擎。然后是 ON CLUSTER,我们可以在任何一个节点执行这个建表语句,而 ClickHouse 在看到 ON CLUSTER 之后就知道这是一个分布式 DDL,会根据 ch_cluster_3shard_0replica 找到相关的集群配置,然后在其它节点上也执行这个 DDL。

最后是 ReplicatedMergeTree,首先如果在不同的节点上建表,那么 zk_path 和 replica_name 显然是不同的,因此我们不可以写死。否则 ClickHouse 在广播 DDL 给其它节点执行之后,所有分片下的所有副本的 zk_path 和 replica_name 就都是一样的了。因此 ClickHouse 提供了两个动态宏变量 {shard} 和 {replica},用于替换之前的硬编码方式,而动态宏变量的值可以通过系统表 system.macros 进行查看。

但问题来了,我们看到查询的结果是空的,原因是我们没有在 config.xml 中没有进行配置。在 config.xml 下面有一个 macros 标签,是被注释掉的,我们配置一下就行了。

配置如下:

<!-- satori 节点 -->
<macros>
    <shard>01</shard>
    <replica>satori_name</replica>
</macros>

<!-- matsuri 节点 -->
<macros>
    <shard>02</shard>
    <replica>matsuri_name</replica>
</macros>

<!-- aqua 节点 -->
<macros>
    <shard>03</shard>
    <replica>aqua_name</replica>
</macros>

我们将注释打开,分别进行修改,或者不打开注释,直接在粘贴在 yandex 标签下即可,然后重启三台节点上的 ClickHouse 服务。

然后我们查看宏变量是否生效:

现在我们就能执行上面那条分布式建表语句了,然后 shard_num 和 replica_name 会用 {shard} 和 {replica} 两个宏变量进行替代。我们下面随便挑一个节点,就在 matsuri 节点上执行上面那条建表语句吧。

根据返回的内容,我们看到表在 satori、matsuri、aqua 三个节点上均已成功创建。同理,如果想删除的话,那么也可以执行分布式 DROP。

DROP TABLE distributed_test_1 ON CLUSTER ch_cluster_3shard_0replica

通过搭建集群,我们便可以执行分布式 DDL,记得在上面介绍 1 分片、多副本的时候我们说过,只有 1 个分片的话,不需要搭建集群,只需要借助 ZooKeeper 即可。但很明显,1 分片、多副本这种模式只是不需要搭建,但不是说不能搭建,如果搭建了集群(1 shard、多 replica),一样可以执行分布式 DLL。

然后我们来介绍一下整个流程,当我们在一个节点上执行分布式 DDL,该 DDL 是如何被广播到其它节点上的。

数据结构

和 ReplicatedMergeTree 类似,分布式 DDL 语句在执行的过程中也需要借助 ZooKeeper 的协同能力,以实现日志分发。

1)ZooKeeper 内的节点结构

在默认情况下,分布式 DDL 在 ZooKeeper 内使用的根路径为:

/clickhouse/task_queue/ddl

该路径由 config.xml 内的 distributed_ddl 配置指定:

在此根路径下,还有一些其它的监听节点,其中包括 DDL 操作日志 /query-[seq],每执行一次分布式 DDL 查询,在该节点下就会新增一条操作日志,以记录相应的操作指令。当各个节点监听到有新日志加入的时候,便会响应执行。DDL 操作日志使用 ZooKeeper 的持久顺序型节点,每条指令的名称以 query- 为前缀,后面的序号递增,例如 query-0000000000、query-0000000001 等等。

另外在每条 query-[seq] 操作日志之下,还有两个状态节点:

  • 1)/query-[seq]/active:用于状态监控等用途,在任务执行的过程中,该节点下会临时保存当前集群内状态为 active 的节点
  • 2)/query-[seq]/finished:用于检查任务的完成情况,在任务的执行过程中,每当集群内的某个 host 节点执行完毕之后,便会在该节点下写入记录

上述语句表示集群内的 satori、matsuri、aqua 三个节点已经完成任务。

 

2)DDLLogEntry 日志对象的数据结构

在 /query-[seq] 下记录的日志信息由 DDLLogEntry 承载,它拥有如下几个核心属性:

query 记录了 DDL 查询的执行语句:

CREATE TABLE default.distributed_test_1
UUID \'d5d4c459-4e89-47b2-95d4-c4594e8957b2\'
ON CLUSTER ch_cluster_3shard_0replica
(
    `id` UInt64
) ENGINE = ReplicatedMergeTree(\'/clickhouse/tables/{shard}/distributed_test_1\', \'{replica}\') ORDER BY id

hosts 记录了指定集群的 hosts 主机列表,集群由分布式 DDL 语句中的 ON CLUSTER 指定。在分布式 DDL 的执行过程中,会根据 hosts 列表逐个判断它们的执行状态。

initiator 记录初始化 host 主机的名称,说白就是分布式 DDL 最开始是在哪个节点执行的

分布式 DDL 的核心执行流程

与副本协同的核心流程类似,接下来就以上面创建 distributed_test_1 为例,解释分布式 DDL 的核心执行流程。整个流程从上到下按照时间顺序进行,大致可以分为三个步骤:

 

1)推送 DDL 日志

首先在 matsuri 节点执行 CREATE TABEL ON CLUSTER,本着谁执行谁负责的原则,在这个案例中也会由 matsuri 节点负责创建 DDLLogEntry 日志并将日志推送到 ZooKeeper,同时也会由这个节点负责监控任务的执行进度。

2)拉取日志并执行

matsuri、satori、aqua 三个节点分别监听 /ddl/query-0000000000 日志的推送,于是它们分别拉取日志到本地。首先它们会判断各自的 host 是否被包含在 DDLLogEntry 的 hosts 列表中,如果包含在内,则进入执行流程,执行完毕后写入 finished 节点;如果不包含,则忽略这次日志的推送。

3)确认执行进度

在步骤 1 执行 DDL 语句之后,客户端会阻塞 180 秒(由参数 distributed_ddl_task_timeout 指定),以期望所有 host 都执行完这个分布式 DDL。如果阻塞时间超过了 180 秒,则会转入后台线程继续等待。

流程图总结:

我们这里有三个副本,但是为了画图方便,图中只画了两个。因为 satori_replica 和 aqua_replica 的表现是一致的,所以 satori_replica 就不画了。

Distributed 原理解析

Distributed 表引擎是分布式表的代名词,它自身不存储任何数据,而是作为数据分片的透明代理,能够自动路由数据至集群中的各个节点,所以 Distributed 表引擎需要和其它数据表引擎一起协同工作,如图所示:

从实体表的层面来看,一张分片表由两部分组成:

  • 本地表:通常以 _local 为后缀进行命名,本地表是承接数据的载体,可以使用非 Distributed 的任意表引擎,一张本地表对应了一个数据分片
  • 分布式表:通常以 _all 为后缀进行命名,分布式表只能使用 Distributed 表引擎,它和本地表形成一对多的映射关系,后续将通过分布式表代理操作多张本地表

我们刚才在建表的时候,将表起名为 distributed_test_1 实际上是不符合规范的,应该以 _local 为后缀进行命名,不过无所谓,理解就好。

对于分布式表与本地表之间表结构的一致性检查,Distributed 表引擎采用了读时检查的机制,这意味着如果它们的表结构不兼容,只有在查询时才会抛出错误,而在创建表时并不会进行检查。另外不同 ClickHouse 节点上本地表之间可以使用不同的表引擎,但是通常不建议这么做,保持它们的结构一致,有利于后期的维护、并避免造成不可预计的错误。

定义形式

Distributed 表引擎的定义形式如下:

ENGINE = Distributed(cluster, database, table [, sharding_key])

里面的参数也比较简单,cluster 表示集群的名称,也就是在 config.xml 中配置的集群名称,在分布式表执行写入和查询的过程中,它会使用集群的配置信息来找到相应的 host 节点;database 和 table 则表示数据库和数据表的名称,分布式表使用这组配置映射到本地表;shard_key 表示分片键,在数据写入的过程中,分布式表会依据分片键的规则,将数据分布到各个 host 节点的本地表。

我们之前在三个节点中创建了本地表 distributed_test_1,下面就来创建一张 Distributed 表来作为它们的代理表。

CREATE TABLE distributed_test_1_all ON CLUSTER ch_cluster_3shard_0replica(
    id UInt64
)ENGINE = Distributed(ch_cluster_3shard_0replica, default, distributed_test_1, rand());

上面的建表语句会创建一张 Distributed 表,名为 distributed_test_1_all,然后通过 ON CLUSTER 会将该建表语句广播到集群的每一个分片节点上,确保它们都会创建一张 Distributed 表,这样就可以在任意一个分片节点发起对所有分片的读、写请求。当然只在一个节点上创建分布式表也是可以的,但是后续的操作只能在该节点上进行。然后是表引擎参数,我们可以看出代理的本地表为 distributed_test_1,其分布在集群 ch_cluster_3shard_0replica 的各个 shard 中,并且在数据写入的时候会根据 rand() 随机函数的取值决定数据写入哪个分片。

下面在 matsuri 节点执行上述 Distributed 表(分布式表)的建表语句,当然在哪个节点上执行都无所谓的,因为是分布式 DDL,每个分片节点上都会执行:

至此本地表和分布式表就都创建好了,这里再多提一句,即使没有本地表,也是可以创建分布式表的。因为 Distributed 表运用的是读时检查的机制,所以对于分布式表和本地表的创建顺序没有要求。

查询种类

Distributed 表的查询操作可以分为如下几类:

1)会作用于本地表的查询:对于 INSERT 和 SELECT 查询,Distributed 表将会以分布式的方式作用于 local 本地表,而对于这些查询的具体规则,一会介绍。

2)只会影响 Distributed 表本身,不会作用于本地表的查询:Distributed 表支持部分元数据的操作,包括 CREATE、DROP、RENAME 和 ALTER,其中 ALTER 并不包括分区的操作(ATTACH PARTITION、REPLACE PARTITION 等)。这些查询只会修改 Distributed 表自身,并不会修改 local 本地表,所以如果想将分布式表和本地表都删掉的话,那么要分别删除。

-- 删除分布式表
DROP TABLE distributed_test_1_all ON CLUSTER ch_cluster_3shard_0replica;

-- 删除本地表
DROP TABLE distributed_test_1 ON CLUSTER ch_cluster_3shard_0replica;

3)不支持的查询:Distributed 表不支持任何 MUTATION 类型的操作,包括 ALTER DELETE 和 ALTER UPDATE。

分片规则

这里再进一步说明一下分片的规则,我们在创建 Distributed 表的时候,表引擎中指定了 sharding_key,也就是分片键。而分片键是由要求的,它必须返回一个整型类型的取值,比如:

-- 按照用户 id 的余数划分,userid 是整型
ENGINE = Distributed(cluster, database, table, userid)

-- 也可以是一个返回整数的表达式,比如按照随机数划分
ENGINE = Distributed(cluster, database, table, rand())

-- 按照用户 id 的散列值划分
ENGINE = Distributed(cluster, database, table, intHash64(userid))

如果不声明分片键,那么分布式表只能包含一个分片,这意味着只能映射一张本地表,否则在写入数据的时候会抛出异常。但如果一张分布式表只能包含一个分片,那还不如单机,显然此时就没意义了。因此,虽然 sharding_key 是选填参数,但是通常都会按照业务规则进行设置。

那么设置完分片键之后,数据又是如何被划分的呢?想要搞清楚这一点,需要先明确几个概念。

1.分片权重

在集群的配置中,有一项 weight(分片权重)的设置:

<!-- 集群的名称 -->
<ch_cluster_3shard_0replica>
	<shard> <!-- 分片 1 -->
        <weight>10</weight> <!-- 分片权重 -->
        ......
    </shard>
    
    <shard> <!-- 分片 2 -->
        <weight>20</weight> <!-- 分片权重 -->
        ......
    </shard>
......

shard 里面不仅仅可以指定 replica,还可以指定其它属性,比如 weight(权重)。weight 默认为 1,虽然它可以设置成任意整数,但官方建议应该尽可能设置成较小的值,分片权重会影响数据在分片中的倾斜程度,一个分片的权重值越大,那么它被写入的数据就越多。

slot(槽)

如果把数据比作是水的话,那么 slot 就可以理解为许多的小水槽,数据会顺着这些水槽流进每个数据分片。slot 的数量等于所有分片的权重之和,假设每个集群有两个 shard,第一个 shard 的 weight 为 10,第二个 shard 的 weight 为 20,那么 slot 的数量则等于 30。slot 按照权重元素的取值区间,与对应的分片形成映射关系,如果 slot 值落在 [0, 10) 区间,则对应第一个分片;如果 slot 值落在 [10, 30) 区间,则对应第二个分片。还是比较简单粗暴好理解的,因此 weight 值越大,那么区间范围也就越广,slot 值落在该区间的概率也就越大。

选择函数

选择函数用于判断一行待写入的数据应该被写入哪个分片,整个判断过程可以分为两步:

1)它会找出 slot 的取值,计算公式如下:

slot = shard_value % sum_weight

其中,shard_value 是分片键的取值,sum_weight 是所有分片的权重之和,slot 等于 shard_value 对 sum_weight 取余。假设某一行的数据的 shard_value 是 10,sum_weight 是 30(两分片,第一个分片权重为 10,第二个分片权重为 20),那么 slot 值就等于 10(10 % 30);再比如 shard_value 如果为 90,那么 slot 值就为 0(90 % 30)。

2)基于 slot 值找到对应的数据分片,当 slot 值等于 10 的时候,它属于 [10, 30) 区间,此时这行数据会对应到第二个 shard;当 slot 值为 0 的时候,它属于 [0, 10) 区间,所以这行数据会对应到第一个分片。

分布式写入的核心流程

在向集群内的分片写入数据时,通常有两种思路:一种是借助外部计算系统,事先根据分片数量将数据计算均匀,再借由计算系统直接将数据写入 ClickHouse 集群的各个本地表。

上述这种方案通常拥有更好的写入性能,因为分片数据是被并行点对点写入的。但这种方案重度依赖于外部系统,而不在 ClickHouse 自身,所以这里主要会介绍第二种思路。

第二种思路是通过 Distributed 表引擎代理写入分片数据,下面就来介绍整个流程。首先关于流程我们大致也能猜到,就是 Distributed 表将数据写入到每个分片的一个副本中,然后获得数据的副本再将数据同步到自己所在分片的其它副本中。因此核心可以分为两个步骤,分别是 "分片写入" 和 "副本复制(同步)",由于我们目前搭建的是 3 分片 0 副本,所以先介绍 "分片写入";后续再通过 1 分片 2 副本来介绍 "副本复制",由于只有三台服务器,所以无法给 3 个分片都配置副本,因为这样至少需要 6 台服务器,于是在介绍副本复制的时候我们会使用 1 分片。当然副本复制和分片数量无关,因为每个分片之间的副本复制所做的事情都是一样的,所在在介绍副本复制的时候,1 分片 和 多分片之间没有什么区别。下面先来看看分片写入的整个过程是怎么样的吧。

将数据写入分片的核心流程

对 Distributed 表执行 INSERT 查询的时候,会进入数据写入分片的执行逻辑,我们按照时间顺序从上往下,大致可以分为 5 个步骤,下面依次介绍。

 

1)在第一个分片节点写入本地分片数据

首先在 aqua 节点,对分布式表 distributed_test_1_all 执行 INSERT 查询:

INSERT INTO distributed_test_1_all 
VALUES (10), (30), (50), (200);

执行之后分布式表会做两件事情:第一,根据分片规则划分数据,不过由于分片键是随机函数,所以我们也不知道数据会如何划分;第二,将数据当前分片的数据直接写入本地表 distributed_test_1。

因为每个节点上都有分布式表,所以我们在任意一个节点执行都是可以的。注意:此时不需要加 ON CLUSTER,我们只在一个节点上执行即可,如果加上 ON CLUSTER,那么每个节点就都会执行一遍,这样可能导致数据的重复写入。

对于当前的栗子来说,50 、10 和 100 、30 分别划分到了不同的分片中,注意:图中打印的顺序不代表分片的顺序,所以它们分别划分到了哪一个分片中,我们是不知道的。

 

2)第一个分片节点建立远端连接,准备发送数据

将发送给远端分片节点的数据以分区为单位(我们创建本地表时没有指定 PARTITION BY,所以当前只有一个分区),分别写入 distributed_test_1_all 存储目录下的临时 bin 文件,该数据文件的命名规则如下:

{database}@{host_name}:{port}/{increase_num}.bin

我们是在 aqua 节点中写入的数据,所以对于 aqua 节点来说,它有两个远端分片节点:satori、matsuri,因此临时数据文件如下所示:

distributed_test_1_all/default@satori/1.bin
distributed_test_1_all/default@matsuri/1.bin

然后和远端的 satori 分片节点、matsuri 分片节点建立连接:

Connection (satori:9000): Connetced to ClickHouse server
Connection (matsuri:9000): Connetced to ClickHouse server

 

3)第一个分片节点向远端发送数据

此时会有另一种监听任务负责监听 /distributed_test_1_all 目录下的文件变化,这些任务负责将目录数据发送至远端分片节点:

distributed_test_1_all.Distributed.DirectoryMonitor:
Started processing /distributed_test_1_all/default@satori/1.bin
Started processing /distributed_test_1_all/default@matsuri/1.bin

其中每个目录将会由独立的线程负责发送,数据在传输之前会被压缩。

 

4)其余的分片节点接收数据并写入本地

satori、matsuri 节点会确认建立和 aqua 节点的连接,然后在收到来 aqua 发送的数据之后,将它们写入到本地表。

 

5)由第一个分片确认完成写入

最后,还是由 aqua 分片节点确认所有的数据发送完毕。至此,整个流程结束,因为过程比较简单,所以流程图就不画了。可以看到,在整个过程中 Distributed 表负责所有分片是我写入工作,本着谁执行谁负责的原则,在当前这个例子中,由 aqua 节点的分布式表负责切分数据,并向所有其它分片节点发送数据。

在由 Distributed 表负责向远端分片节点发送数据时,有异步写和同步写两种模式:如果是异步写,则在 Distributed 表写完本地分片之后,INSERT 查询就会返回成功写入的信息;如果是同步写,则在执行 INSERT 查询之后,会等待所有分片完成写入。至于选择何种模,由 insert_distributed_sync 参数控制,默认为 false,也就是异步写;如果将其设置成 true,那么可以进一步通过 insert_distributed_timeout 参数控制同步等待的超时时间。

我们来测试一下,看看在 aqua 节点上写的数据,有没有被切分、并写入到不同的分片节点中。

显然输出一切正常,并且根据输出我们也可以看出:10 和 100 被划分到了 satori 分片节点(shard 1)、30 被划分到了 matsuri 分片节点(shard 2)、50 被划分到了 aqua 分片节点(shard 3)。

副本复制数据的核心流程

如果在集群的配置中包含了副本,那么除了刚才的分片写入流程之外,还会触发副本数据的复制流程。而数据在多个副本之间,有两种复制方式:第一种是继续借助 Distributed 表引擎,由它将数据写入副本;第二种是借助于 ReplicatedMergeTree 表引擎实现副本数据的分发。

 

1)通过 Distributed 表复制数据

下面我们增加一下配置,实现 1 分片 2 副本。

<!-- 
还记得吗,我们之前配置过 1 分片 1 副本,当时我们说,对于单分片而言,可以不配置集群,只配置 ZooKeeper 即可 
但很明显,单分片也是可以配置集群的
-->
<ch_cluster_1shard_2replica>
    <shard>
    	<replica>
            <host>satori</host>
            <port>9000</port>
        </replica>
        
    	<replica>
            <host>matsuri</host>
            <port>9000</port>
        </replica>
        
    	<replica>
            <host>aqua</host>
            <port>9000</port>
        </replica>        
    </shard>
</ch_cluster_1shard_2replica>

我们将上面的配置增加到 config.xml 的 remote_server 标签中,而我们之间的 ch_cluster_3shard_0replica 不需要动,因为 ClickHouse 支持多个集群,我们想用哪一个,直接通过集群的名称指定即可。

修改完三个节点的配置文件之后,分别重启 ClickHouse。

我们查看集群,发现已经生效了,并且 shard_num 都是 1,因为只有 1 个分片。然后 replica_num 分别为 1、2、3,因为该分片下有 3 个副本。

接下来我们在这个集群内创建数据表,首先创建本地表:

-- 我们之前不指定集群的时候,需要在每个副本上分别执行一遍建表语句
-- 当搭建了集群之后,通过 ON CLUSTER 的方式只需要在一个节点上执行即可
CREATE TABLE distributed_test_2 ON CLUSTER ch_cluster_1shard_2replica (
    id UInt64
) ENGINE = MergeTree() 
ORDER BY id;

这里我们指定的集群是 ch_cluster_1shard_2replica,那么  ClickHouse 会根据集群信息,自动将建表语句分发到集群中的所有节点上执行。并且,由于是通过 Distributed 表复制数据,所以对表引擎没有任何要求。

显然创建成功,那么接下来创建分布式表:

CREATE TABLE distributed_test_2_all ON CLUSTER ch_cluster_1shard_2replica (
    id UInt64
) ENGINE = Distributed(ch_cluster_1shard_2replica, default, distributed_test_2)

然后我们向 Distributed 表写入数据,它会负责将数据写入集群内的每个 replica。

当然,如果查询 satori 节点和 aqua 节点的本地表,肯定也是有数据的。

如果我们配置的是多分片、多副本也是可以的,因为数据会写入到所有分片的所有副本中。不过从这里也能看出,当前方案对 Distributed 表所在节点会造成很大压力,因为它需要同时负责分片以及所有副本的数据写入工作,因此它有可能成为写入的单点瓶颈,所以就有了下面的第二种方案。

 

2)通过 ReplicatedMergeTree 表复制数据

如果在集群的 shard 配置中增加 internal_replication 参数并将其设置为 true(默认为 false),那么 Distributed 表在该 shard 中只会选择一个合适的 replica 并对其写入数据。此时,如果使用 ReplicatedMergeTree 作为本地表的引擎,该 shard 内的多个 replica 之间的数据则会交给 ReplicatedMergeTree 自己处理,不再由 Distributed 负责,从而为其减负。

在 shard 中选择 replica 的算法大致如下:首先在 ClickHouse 的服务节点中,拥有一个全局技术器 errors_count,当服务出现任何异常时,该计数器都会加 1;然后当一个 shard 拥有多个 replica 时,选择 errors_count 最少的那个。下面我们修改一下配置:

<ch_cluster_1shard_2replica>
    <!-- 增加该配置 -->
    <internal_replication>true</internal_replication>
    <shard>
    	<replica>
            <host>satori</host>
            <port>9000</port>
        </replica>
        
    	<replica>
            <host>matsuri</host>
            <port>9000</port>
        </replica>
        
    	<replica>
            <host>aqua</host>
            <port>9000</port>
        </replica>        
    </shard>
</ch_cluster_1shard_2replica>

修改三个节点的配置文件 config.xml,然后重启 ClickHouse。

然后我们创建表,注意:由于指定了 internal_replication 为 true,那么 Distributed 表只会往一个副本中写,这就要求副本之间是可以进行同步的,也就是必须指定 Replicated* 表引擎。我们测试一下,先以 MergeTree 为例,然后再以 ReplicatedMergeTree 为例,看看数据在两者之间的同步情况。

-- 创建本地表,表引擎为 MergeTree
CREATE TABLE distributed_test_3 ON CLUSTER ch_cluster_1shard_2replica (
    id UInt64
) ENGINE = MergeTree() 
ORDER BY id;

-- 创建分布式表
CREATE TABLE distributed_test_3_all ON CLUSTER ch_cluster_1shard_2replica (
    id UInt64
) ENGINE = Distributed(ch_cluster_1shard_2replica, default, distributed_test_3)

指定表引擎为 MergeTree,副本之间无法发生同步。然后我们再创建一张表,将表引擎指定为 ReplicatedMergeTree,看看副本之间是否会发生同步。

-- 创建本地表,表引擎为 ReplicatedMergeTree
CREATE TABLE distributed_test_4 ON CLUSTER ch_cluster_1shard_2replica (
    id UInt64
-- 注意这里的 zk_path 和 replica_name,zk_path 显然都是一样的,但是 replica_name 不同
-- 我们可以不使用 ON CLUSTER 和宏变量,而是每个节点手动执行一遍,不同节点指定不同的 replica_name,像最开始介绍副本那样
-- 当然也可以使用 ON CLUSTER,只不过此时不同副本的 replica_name 不同,所以我们需要使用宏变量 {replica},而在之前我们已经配好了
-- 然后我们知道还有一个宏变量 {shard},因为是单分片,所以我们不需要使用它,如果是多分片多副本,那么就需要时候用了
) ENGINE = ReplicatedMergeTree('/clickhouse/tables/01/distributed_test_4', '{replica}') 
ORDER BY id;

-- 创建分布式表
CREATE TABLE distributed_test_4_all ON CLUSTER ch_cluster_1shard_2replica (
    id UInt64
) ENGINE = Distributed(ch_cluster_1shard_2replica, default, distributed_test_4)

我们测试一下:

分布式查询的核心流程

当面临数据查询时,毫无疑问要通过分布式表实现,当分布式表接收到 SELECT 查询的时候,会依次查询每个分片的数据,再合并汇总返回。当然我们也可以手动将每个节点都查询一遍,然后手动汇总,但显然不会有人这么做,不然还要分布式表做什么呢。下面就来介绍分布式表的数据查询。

多副本的路由规则

在查询数据的时候,如果集群中的一个 shard 拥有多个 replica,那么 Distributed 表引擎需要面临副本选择的问题。它会使用负载均衡算法从众多 replica 中选择一个,而具体使用何种算法,则由 load_balancing 参数控制,其取值有如下几种。

 

1)random

random 是默认的负载均衡算法,正如前文所述,在 ClickHouse 的服务节点中,拥有一个全局计数器 errors_count,当服务发生任何故障时,该计数器都会自增 1。而 randon 算法会选择 errors_count 最小的 replica,如果拥有最小 errors_count 的 replica 有多个,那么就从中随机选择一个。

 

2)nearest_hostname

nearest_hostname 可以看做是 random 算法的变种,首先它也会选择 errors_count 最小的 replica,但如果拥有最小 errors_count 的 replica 有多个,则比较集群配置中的 host(主机名)和当前 Distributed 表所在节点的主机名的相似度,选择相似度最高的哪一个。

 

3)in_order

in_order 可以同样可以看做是 random 算法的变种,首先它也会选择 errors_count 最小的 replica,但如果拥有最小 errors_count 的 replica 有多个,则根据集群中 replica 的定义顺序进行选择。

 

4)first_or_random

first_or_random 可以看做是 in_order 算法的变种,首先它还是会选择 errors_count 最小的 replica,但如果拥有最小 errors_count 的 replica 有多个,则根据集群中 replica 的定义顺序进行选择。但如果选择的 replica 不可用,则进一步随机选择一个其它的 replica。

感觉都没有什么太大区别,直接使用 random 就好。

多分片查询的核心流程

分布式查询与分布式写入类似,同样本着谁执行谁负责的原则,它会接收 SELECT 查询的 Distributed 表,并负责串联起整个过程。首先它会将针对分布式表的 SQL 语句,按照分片数量拆分成若干个针对本地表的子查询,然后向各个分片发起查询,最后再汇总各个分片的返回结果。以我们之前创建的 distributed_test_1_all 为例,如果对它发起如下查询:

SELECT * FROM distributed_test_1_all

那么它会将其转化为如下形式,然后再发送到远端分片节点来执行:

SELECT * FROM distributed_test_1

再比如执行如下查询:

SELECT count() FROM distributed_test_1_all

那么 Distributed 表引擎会将查询计划转换为多个分片 UNION 联合查询,如图所示:

整个执行计划从上大小大致分为两个步骤,下面进行介绍,我们当前是在 aqua 节点查询的分布式表。

 

1)查询各个分片数据

在上图中,One 和 Remote 步骤是并行执行的,它们分别负责了本地和远端分片的查询动作。其中,在 One 这个步骤会将 SQL 转成对本地表的查询:

SELECT count() FROM default.distributed_test_1

而在 Remote 步骤中,会和其它两个副本节点(satori、matsuri)建立连接,并向其发起远程查询:

Connection (satori:9000): Connecting. Database: ...
Connection (matsuri:9000): Connecting. Database: ...

satori 节点和 matsuri 节点在收到 aqua 的查询请求后,会分别在本地开始执行,同样,SQL 会转换成对本地表的查询。

 

2)合并返回结果

多个分片数据均查询返回后,在 aqua 节点将其合并。

以上显然是比较简单的,即使执行一些复杂的语句也是没有问题的。

所以即使 SQL 语句复杂,也是没有问题的,当然我们这个 SQL 语句说复杂就明显太过了。总之你对普通表所做的查询,对分布式表同样可以,像一般的 WHERE 子句、GROUP BY 子句、ORDER BY 子句、LIMIT OFFSET 子句,都没有什么区别。

但我们需要注意的是子查询,不像单表,分布式表的子查询是由很多坑点的。

使用 Global 优化分布式子查询

如果在分布式查询中使用子查询,可能会面临两难的局面。下面举个栗子,首先使用之前的 3 分片 0 副本集群创建本地表和分布式表:

CREATE TABLE in_clause_test_1 ON CLUSTER ch_cluster_3shard_0replica (
    id UInt64,
    repo UInt64
) ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/in_clause_test_1', '{replica}')
ORDER BY id;

-- 创建分布式表
CREATE TABLE in_clause_test_1_all ON CLUSTER ch_cluster_3shard_0replica (
    id UInt64,
    repo UInt64
) ENGINE = Distributed(ch_cluster_3shard_0replica, default, in_clause_test_1)

然后写入数据:

查询三张本地表的数据如图所示,而将图中的三个结果集合在一块显然就是查询分布式表所得到的结果。其中 id 代表用户的编号,repo 代表仓库的编号。

重点来了,如果我想查询至少拥有两个仓库的用户 id 以及对应的仓库编号,这个时候该怎么做呢?如果是关系型数据库、或者说不是分布式表的话,那么显然一个子查询就搞定了,我们上面已经通过 GROUP BY 查询出了相应的 id,然后根据 id 去筛选即可。

SELECT
    id,
    repo
FROM in_clause_test_1_all
WHERE id IN (
    SELECT id
    FROM in_clause_test_1_all
    GROUP BY id
    HAVING count() >= 2
)

首先可以肯定这个语句本身没有任何问题,但执行的时候 ClickHouse 会报出如下错误:

Code: 288. DB::Exception: Received from localhost:9000. DB::Exception: Double-distributed IN/JOIN subqueries is denied (distributed_product_mode = 'deny'). You may rewrite query to use local tables in subqueries, or use GLOBAL keyword, or set distributed_product_mode to suitable value.: While processing in_clause_test_1_all: While processing id IN (SELECT id FROM in_clause_test_1_all GROUP BY id HAVING count() >= 2).

其原因就在于 ClickHouse 是默认不支持分布式子查询的,而解决办法有两种。

1)通过 distributed_product_mode 参数让其支持分布式子查询

根据报错信息我们知道 distributed_product_mode 默认为 'deny',表示禁止分布式子查询,我们在 users.xml 将其设置成 'allow' 即可,或者直接在命令行中设置也行。

将 distributed_product_mode 设置为 'allow' 之后就可以成功执行并获取数据了,虽然结果是符合我们的预期了,但是还有一些需要思考🤔的东西。首先为什么 ClickHouse 会默认将分布式子查询给禁止呢?子查询这么常见,明显不应该禁止啊。所以,我们目前这种使用分布式子查询的方式一定是存在弊端的。

仔细思考一下会很容易发现存在效率上问题,我们知道查询分布式表会依次查询所有的本地表,而子查询里面查询的还是分布式表,这就导致了查询请求会被放大 N 的平方倍,其中 N 是集群内分片节点数量。我们上面只有 3 个分片节点,会进行 9 次查询,这还没有什么;但如果有 100 个分片节点呢,那么最终会导致 10000 次查询请求。而带有一个子查询的 SQL 会导致 10000 次的查询请求,不用想,这绝对是无法接受的。并且这里只有一个子查询,如果有多个子查询呢?如果有的老铁没有对 SQL 进行优化,导致子查询内部又嵌套了子查询,那就不是按平方倍扩大了,而是按立方倍扩大。

而为了解决查询放大的问题,可以使用 GLOBAL IN 进行优化,而从名字上可以看出,它和 IN 相比,在结果上没有什么却别,但是解决了查询放大的问题。

那么 GLOBAL IN 是如何做的呢?大致可以分为以下几步:

  • 将 IN 子句单独提出,发起了一次分布式查询
  • 将分布式表转成本地表后,分别是在本地分片节点和远端分片节点执行查询
  • 将 IN 子句查询的结果进行汇总,并放入一张临时的内存表进行保存
  • 将内存表发送到远端分片节点
  • 将分布式表转为本地表后,开始执行完整的 SQL 语句,IN 子句直接使用临时内存表的数据

至此,整个核心流程结束。可以看到在使用 GLOBAL 修饰符之后,ClickHouse 使用内存表临时保存饿了 IN 子句查询到的数据,并将其发送到远端分片节点,以此达到了数据共享的目的,从而避免了查询放大的问题。由于数据会在网络间分发,所以需要特别注意临时表的大小,IN 子句返回的数据不宜过大。如果表内存在重复数据,也可以事先在 IN 子句中加入 DISTINCT 进行去重,以减少内存表中的数据量,从而实现更快的传输。

除了 GLOBAL IN 之外,还有 GLOBAL JOIN 作用是相似的,但我们说能不用 JOIN 就不用 JOIN,将其存成一张宽表是最好的。

小结

以上就是副本、分片和集群的使用方法、作用,以及核心流程。

我们首先介绍了数据副本的特点,并详细解释了 ReplicatedMergeTree 表引擎,它是 MergeTree 表引擎的变种,同时也是数据副本的代名词,接着又介绍了数据分片的特点和作用。同时在这个过程中引入了 ClickHouse 集群的概念,并讲解了它的工作原理,最后介绍了 Distributed 表引擎的核心功能与工作流程,借助它的能力,可以实现分布式写入与查询。

posted @ 2021-09-27 20:30  古明地盆  阅读(2504)  评论(1编辑  收藏  举报