1. 概述

本文介绍 ClickHouse(后续叫 ck) 的分布式集群架构,很多人都像我一样,从 ElasticSearch(后续叫 es) 走向 ck,在讲解 ck 的分布式架构前,先回顾一下 es 的分布式架构。

1.1. es 分布式架构回顾

1. 服务器节点

es 集群,在服务器节点上,有三类节点:

  1. master 节点群:负责维护整个集群的相关工作,管理集群的变更,如创建/删除索引、节点健康状态监测、节点上/下线等。作为集群大脑,不存储数据,会进行选举建议配置成 2n+1。
  2. 数据节点群:主要负责索引数据的保存工作,此外也执行数据的其他操作,如文档的删除、修改和查询操作。主要用来存储数据。
  3. 协调节点群:客户端可以向 ES 集群的节点发起请求,这个节点叫作协调节点。协调节点主要用来负载均衡,把客户端的请求转发分配给最合适的节点来处理,降低主节点和数据节点负载。

虽说节点的这三种身份并没有严格划分,一个服务器节点可以即当 master 节点,也当数据节点。但在生产环境上,还是建议职责单一,否则会影响性能和安全。

2. 分片

ES 会把一个索引分解成多个小索引,每个小的索引就叫做分片,这些分片数据都存储在数据节点群上。

  1. 主分片(primary shard) :用于索引数据的水平分割,在创建索引时指定。创建索引后不能修改。因为数据存储时,会拿 hash 值,根据索引中主分片数量取余,定位所在分片的地址。
  2. 复制分片(replica shard)(或副分片):用于索引数据的容灾、拷贝,在创建索引后,依然能修改。

分片是索引级别的的,每个索引在创建时可指定分片情况,但创建完成后只有复制分片能改。

注意的是:主分片和对应的副本分片是不会在同一个节点上的,所以副本分片数的最大值是 n -1(其中 n 为节点数)。

es 三种服务器节点身份,其实是提供了集群环境的物理基础。而真正在数据存储上实现分布式的,则是在创建索引时, 两种分片的搭配使用。

1.2. ck 分布式架构

多主架构

针对节点来说,ck 采用的是 Muli-Master 多主架构,集群中每个节点的身份都是对等的。这点和 es 不一样,es 集群中节点有上述3中身份。

es 集群的大脑是 master 节点的角色,而 ck 就没有自己实现了,而是引用外部的 ZooKeeper(后续叫 zk) 作为 ck 集群的大脑。所以简述一下搭建 ck 集群的步骤:

  1. 先搭建 zk 集群
  2. 在各个服务器节点上部署单机版 ck
  3. 在各节点上配置 /etc/clickhouse-server/config.xml,包含 zk、自身集群分片信息

因为 ck 节点的功能单一,上述配置完成后就可以了。前面两步没啥好讲的,核心关注第三步,/etc/clickhouse-server/config.xml里面的配置。

1.3. 集群的定义

我在对比 es、ck 的集群架构时,有点恍惚集群的定义是什么。

就拿 es 来说吧,是 master 节点、数据节点、协调节点,这些不同职责分工的节点组成了 es 的集群架构?还是在创建索引时,主分片和副本分片的配合,组成了 es 数据的集群架构?分片和节点之间的关联,好想也就是同一个索引的主分片和对应的副本分片不能在同一个节点上。

而 ck 的集群定义呢,则倾向于后者,将分片和副本的组合定义为集群。所以一群 ck 节点搭配在一起的时候,可以只维护一套分片和副本的组合,只有一个集群。也可以维护多套组合,多个集群,ck 的表在创建时,可指定使用哪个集群。

这里体现出 es 和 ck 在分片使用上的不同使用习惯。虽然二者的分片组合都是表(索引)级别的,在创建每个表(索引)时,可以自由定义成不同的分片组合。但 ck 中将一套分片组合定义成集群,需要先在配置中创建好集群中的分片信息,就是在引导用户将相似的一批表放在同一个集群中,也就是使用同一套分片组合搭配。

2. 配置

这里着重讲一下 /etc/clickhouse-server/config.xml 中的具体配置。

2.1. zookeeper

<zookeeper>
    <node index="1">  #index是连接zk的顺序
        <host>node01</host> #znode地址
        <port>2181</port>   #znode端口
    </node>
    <node index="2">
        <host>node02</host>
        <port>2181</port>
    </node>
    <node index="3">
        <host>node03</host>
        <port>2181</port>
    </node>
  </zookeeper>

这里假设 zk 集群有 3 个节点,那么上述就需要配置每个节点的链接:

  • index: 连接 zk 节点的顺序
  • host: zk 节点的地址
  • port: zk 节点服务的端口

上述 zk 的配置不支持热更改,必须要重启 ck 服务。

另外,ck 提供了查询 zk 的表,可以通过 sql 查询 zk 目录信息(一定要加上 path 条件,否则无法查询):

select * from system.zookeeper where path = '/';

2.2. 分片

ck 的分片概念和 es 的分片概念基本没区别,水平拆分都是通过分片(shard)来实现,拓展读以及容灾都通过副本(replica)来实现。二者在配置分片时还是有些区别,下面对比一下。

假设我们有 6 个 ck 服务器节点,host 从 node01 到 node06,下面我们在这 6 个节点中配置了 2 个集群:order_cluster、log_cluster:

<order_cluster>
    <shard>
        <replica>
            <host>node01</host>
            <port>9000</port>
        </replica>
        <replica>
            <host>node02</host>
            <port>9000</port>
        </replica>
        <replica>
            <host>node03</host>
            <port>9000</port>
        </replica>
    </shard>
    <shard>
        <replica>
            <host>node04</host>
            <port>9000</port>
        </replica>
        <replica>
            <host>node05</host>
            <port>9000</port>
        </replica>
        <replica>
            <host>node06</host>
            <port>9000</port>
        </replica>
    </shard>
</order_cluster>
<log_cluster>
    <shard>
        <replica>
            <host>node01</host>
            <port>9000</port>
        </replica>
        <replica>
            <host>node02</host>
            <port>9000</port>
        </replica>
    </shard>
    <shard>
        <replica>
            <host>node03</host>
            <port>9000</port>
        </replica>
        <replica>
            <host>node04</host>
            <port>9000</port>
        </replica>
    </shard>
    <shard>
        <replica>
            <host>node05</host>
            <port>9000</port>
        </replica>
    </shard>
</log_cluster>

在 order_cluster 集群中:配置了 2 个分片,每个分片有 3 个副本(包含 1 个主副本,2 个复制副本)。
在 log_clster 集群中:配置了 3 个分片,前 2 个分片各自有 2 个副本(包含 1 个主副本,2 个复制副本),第 3 个分片只有一个副本(主副本)。

针对 order_cluster ,我们回顾一下,如果在 es 中某个索引该如何配置分片:

"settings":{
        "number_of_shards":2,
        "number_of_replicas":2
    }

es 中其实配置和 order_cluster 中一样,也是 2 个分片,然后每个分片除了有 1 个主副本,还有 2 个复制副本。

对比下来可以发现几点不同:

  • ck 中将所有副本的数量都计算在内,而 es 中只计算了复制副本的。
  • ck 中分片、副本具体分布在哪个服务器节点上,都是要配置的。而 es 中则并不透明,无需关系在哪个节点。
  • ck 中可以定义每个分片的副本数量不同,而 es 中不行。

ck 在配置文件中配置好以后,cluster 可以热加载,所以不需要重启服务,同样可以通过系统表可以查看 cluster:

select * from system.clusters where cluster='order_cluster';

2.3. 宏

配置文件中,每台机器可以自定义设置自己的 macros,后续再将它的使用场景。

注意,macros 是可以基于 集群去设置的,即不同集群中的 分片、副本名称都可以不同:

<macros>
    <cluster>log_cluster</cluster>
    <shard>02</shard>
    <replica>node03</replica>
</macros>

同样也可以通过查询系统表:

select * from system.macros

3. 分布式DDL

因为是集群,那么在执行某张表的 DDL 操作时,实际上应该对集群上配置的每个节点每个副本都执行操作。虽然也可以每个节点挨个执行,但更推荐下面的方法,在任意节点执行一次,就会自动给集群所有副本都执行,cluster_name 对应配置文件中集群的名称:

create / drop / rename / alter table on cluster cluster_name

接下来相信的看一张表的创建 DDL,在 order_cluster 集群上创建一张测试表:

CREATE TABLE t_shard ON CLUSTER oreder_cluster
(
    `id` UInt8,
    `name` String,
    `date` DateTime
)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/t_shard', '{replica}')
PARTITION BY toYYYYMM(date)
ORDER BY id

-- 删除 t_shard 表
DROP TABLE t_shard ON cluster shard_2;
  1. Replica 引擎

ck 表通常使用 MergeTree 引擎,但只有Replicated 单词开通的引擎,才支持分布式副本。

  1. 引擎参数

ReplicatedMergeTree 构造参数分别是 zk_pathreplica_name,会对应表的元数据在 zk 中的路径。

一般推荐 zk_path/clickhouse/tables/{shard}/table_name

  • /clickhouse/tables/:是约定俗成的路径固定前缀,标识在 zk 中存放数据表的跟路径
  • {shard}:表示分片编号,通常以数值替代,如:01、02。
  • table_name:数据表的名称,虽说 ck 不强制路径中的表名和物理表名相同,但建议还是和物理表名相同,便于运维。
  • {repalic_name}:是定义在 zk 中所创建的副本名称,该名称是区分不同副本实例的唯一标识,而且同一个集群中的副本,不能在同一个节点,所以一种约定俗成的命名方式是使用所在节点服务器的域名。

所以对应 order_cluster 中每个节点的引擎分别是:

  • ReplicatedMergeTree('/clickhouse/tables/01/t_shard', 'node01')
  • ReplicatedMergeTree('/clickhouse/tables/01/t_shard', 'node02')
  • ReplicatedMergeTree('/clickhouse/tables/01/t_shard', 'node03')
  • ReplicatedMergeTree('/clickhouse/tables/02/t_shard', 'node04')
  • ReplicatedMergeTree('/clickhouse/tables/02/t_shard', 'node05')
  • ReplicatedMergeTree('/clickhouse/tables/02/t_shard', 'node06')

但是既然集群上的同一张表,在每个节点执行建表的引擎都不一样,那如何实现之前说的,在任意节点执行一次 DDL,在所有节点生效呢?这里就用到了下面所说的 macros 宏。

  1. macros 宏

再回顾一下 ReplicatedMergeTree('/clickhouse/tables/{shard}/table_name', '{replica}'),在不同节点中不同的参数只有 {shard}{repalic} ,那么是不是可以提前维护好节点的这两个值,使用宏替代。

百度一下宏的定义: 计算机科学里的宏是一种抽象,它根据一系列预定义的规则替换一定的文本模式。

还记得之前我们在配置文件中维护的 macros 宏属性吗,就是干这个的。{shard}{replica} 这两个动态宏变量就替代了之前的硬编码,在执行包含这两个宏变量的 DDL 时,在各节点执行时,会自动被替换成各节点配置的值。

但如果一个节点只在一个集群,可以只配置节点的 shard、replica 标签。但如果一个节点处于多个集群,replica 到还好,都是域名。但 shard 就不一定了,可能在不同集群中所处不同的分片编号,这里就需要再维护 cluster 标签。如 node03 上的配置:

<macros>
    <cluster>order_cluster</cluster>
    <shard>01</shard>
    <replica>node03</replica>
</macros>
<macros>
    <cluster>log_cluster</cluster>
    <shard>01</shard>
    <replica>node03</replica>
</macros>

4. 副本与zk

在 ReplicatedMergeTree 的核心逻辑大量使用 zk,以实现多个 ReplicatedMergeTree 副本实例之间的协同,包括副本选举、副本状态感知、操作分发日志、任务队列和 BlockID 去重判断等。以及在执行 insert 、merge 和 mutation 操作的时候,也会涉及与 zk 的通信。

不过在与 zk 通信时并不会涉及任何数据的传输,在查询数据的时候也不会访问 zk,因此不必担心 zk 承担太多压力。

本段具体讲解,同一个分片下不同副本之间,如何通过zk来做协调。

4.1. zk路径

ReplicatedMergeTree 需要依靠 zk 的事件监听机制实现各个副本之间的协同,当 ReplicatedMergeTree 表创建过程中会以 zk_path/clickhouse/tables/{shard}/table_name)为根路径,在 zk 中为这张表创建一组监听节点,按照作用不同,监听节点可以分为如下几类:

1.元数据:
  • /metadata:保存元数据信息,包括主键、分区间、采样表达式等
  • /columns:保存列字段信息,包括列名称和数据类型
  • /replicas:保存副本名称,对应设置参数中的 replica_name
  1. 判断标识:
  • /leader_election:对于副本的选举工作,主副本会主导 Merge 和 mutation 操作(alter delete/和 alter update),这些任务在主副本完成之后,借助 zookeeper 将消息时间分发至其他副本。
  • /blocks:记录 Block 数据库的 Hash 摘要,以及对应的 partition id 。通过 Hash 摘要能够判断 Block 是否重复,通过 partition ID 能够找到需要同步的分区。
  • /block_numbers:按照分区的写入顺序,以相同顺序记录 partition ID。各个副本在本地进行 Merge 时,都会依照相同的 block_numbers 顺序进行。
  • /quorum:记录 quorum 的数量,当至少有 quorum 数量的副本写入成功后,整个写操作才算成功,quorum 的数量有 insert_quorum 参数控制,默认为 0。
  1. 日志操作
  • /log:常规日志节点(insert、Merge 和 drop table),它是整个工作机制中最为重要的一环,保存了副本需要执行的任务指令。log 使用了 zk 的持久顺序型节点,每条指令以 log- 为前缀递增,例如:log-0000000000、log-0000000001 等,每个副本实例都会监听 /log 节点,当有新的指令加入时,他们会把指令加入副本各自的任务队列,并执行任务。
  • /mutation:mutation 操作(alter delete/和 alter update)日志节点,也是使用了 zk 的持久顺序型节点,不过节点命名没有前缀,例如:0000000000、0000000001 等,其余逻辑与 /log 相同。
  • /replicas/{replica_name}/*:每个副本各自的节点下的一组监听节点,用于指导副本在本地执行具体的任务指令,比较重要的有下面几个:

    • /queue:任务队列节点,用于执行具体的操作任务从/log 或/mutations 节点监听到操作指令时,会将执行任务添加到该节点下,并给基于列执行。
    • /log_pointer:log 日志指针节点,记录了最后一次执行 log 日志下标信息。
    • /mutation_pointer:mutation 日志指针节点,记录了最后一次执行 mutation 日志下标信息。

4.2. 初始化流程

当在创建表时,在同一个分片中,ReplicatedMergeTree 会进行一些初始化操作:

  • 根据 zk_path 初始化所有的 zk 节点。
  • 每个节点会在 /replicas/ 节点下,注册自己的副本实例,即对应上一节的 /replicas/{replica_name}/*
  • 当前节点启动监听任务,监听 /log 日志节点。
  • 参与当前分片的副本选举,选举出主副本。选举方式是像 zk 的 /leader_election/ 插入子节点,第一个插入成功的副本就是主副本。

这里假设是模拟在 order_cluster 集群上的第一个分片的流程(副本:node01、node02、node03)。

4.3. Insert 写入

  1. 第一个副本实例写入数据

第一个收到 insert 命令的副本,假设是 node01(不一定是主副本),执行如下命令写入数据:

insert into t_shard values(1,'Kerry','2023-08-06');

上述命令执行完成后,首先会在本地完成分区目录的写入。接着向 /blocks 节点写入该数据分区的 block_id:

wrote block with ID '202308_8139788293933794752_9955392769311530712'

此外,如果设置了 insert_quorum 参数(默认为 0),并且 insert_quorum >=2,则只有当写入副本个数大于等于 insert_quorum 时,写入才算成功。

  1. 第一个副本实例推送 Log 日志

在上述步骤完成后,执行了 insert 的副本会向 /log 节点推送操作日志。假设第一个日志,日志编号是 /log/log-0000000000,LogEntry 的核心属性为:

/log/log-0000000000
    source replica: node01
    block_id: 202308_8139788293933794752_9955392769311530712
    type: get
    partition_name: 202308_0_0_0

从日志内容看,操作类型是 get 下载,下载分区是 202308_0_0_0,其余所有副本都会基于 Log 日志以相同顺序执行命令。

  1. 其他副本拉去 Log 日志

基于 zk 的监听机制,每个副本都一直监听 /log 节点变化,这里假设 node02 监听到了日志。当 node01 推送了 /log/log-0000000000 之后,node02 便触发了日志的拉取任务并更新 log_pointer,将其指向最新的日志下标(/replicas/{replica_name}/log_pointer):

/replicas/node02/log_pointer: 0

在拉取了 LogEntry 之后,它并不会直接执行,而是将其转为任务对象放入队列( /replicas/{replica_name}/queue):

/replicas/node02/queue/
Pulling 1 entries to queue: log-0000000000 - log-0000000000

考虑到在同一时段,会连续收到多个 LogEntry,所以使用队列的形式消化任务。也因此,拉取的 LogEntry 是一个区间。

  1. 其他副本向第一个副本发起下载请求

node02 基于 /queue 队列执行任务,当看到 type 为 get 的时候, ReplicatedMergeTree 明白已经在其他副本中成功写入了数据分区,而自己需要同步这些数据。因此需要选择一个远端的副本作为数据的下载来源,远端副本的选择算法如下:

  1. 从 replicas 节点拿到所有的副本节点
  2. 遍历副本,选取拥有最大的 log_pointer, 且 /queue 子节点数量最少的副本。

    1. log_pointer 下标最大,意味着该副本执行的日志最多,数据更加完整。
    2. /queue 最小意味着该副本目前的任务执行负担较小。
  3. 如果第一次请求失败,会再次请求,默认请求五次,由 max_fetch_partition_retries_count 参数控制。

因此,如果 node02 是 node01 之后第一个监听到 /log 日志的,选取发起下载请求的副本是 node01。但当 node02 也写入成功后,node03 选择的远端副本可能还是 node01,但也可能是 node02。

  1. 副本相应数据下载

node01 收到调用请求后,会根据参数做出响应,将本地分区 202308_0_0_0 基于 DataPartsExchange 的服务响应发送回 node02

  1. 下载数据并完成本地写入

node02 副本在收到 node01 的分区数据后,首先将其写入临时目录:

temp_fetch_202308_0_0_0

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

renaming temporary part temp_fetch_202308_0_0_0 to 202308_0_0_0

至此,整个写入流程完成。

总结

在 insert 的写入流程中,zk 不会进行任何实质性的数据传输和存储。本着谁执行谁负责的原则。

本例子中,node01 首先在本地写入了分区数据,之后也由它发送 Log 日志,通知其他副本下载数据。如果设置了 insert_quorum,则还会由该副本监控写入副本的数量。

其他副本在接受到 Log 日志后,会选择一个最适合的远端副本,点对点的下载分区数据。

4.4. Merge 分区合并

先讲一下什么是分区与分区合并。

  1. 分区

分区是表的分区,是解决大数据存储的常见解决方案。具体的 DDL 操作关键词是 PARTITION BY,指的是一个表按照某一列数据(比如日期)进行分区,对应到最终的结果就是不同分区的数据会写入不同的文件中。

有时候我们查询只关心表中的一部分数据,建表时引入 partition 概念,可以按照对应的分区字段,找出对应的文件进行查询展示,防止查询中会扫描整个表内容,消耗很多时间做没必要的工作。

但不是 ck 中所有的表引擎都可以使用这项特性,目前只有合并树(MergeTree) 家族系列的表引擎才支持数据分区。

强调一下,分区和分片可不同。分片是横向扩展到不同节点上,而分区是横向扩展在某个节点表路径的不同文件中。

  1. 分区合并

MergeTree 的分区目录和传统意义上其他数据库有所不同。MergeTree 的分区目录并不是在数据表被创建之后就存在的,而是在数据写入过程中被创建的。也就是说如果一张数据表没有任何数据,那么也不会有任何分区目录存在。MergeTree 的分区目录伴随着每一批数据的写入(一次 INSERT 语句),MergeTree 都会生成一批新的分区目录。即便不同批次写入的数据属于相同分区,也会生成不同的分区目录。

也就是说,任何一个批次的数据写入都会产生一个临时分区,不会纳入任何一个已有的分区。在之后的某个时刻(写入后的 10 ~ 15 分钟,也可以手动执行 optimize 查询语句),ClickHouse 会通过后台任务再将属于相同分区的多个目录合并成一个新的目录。已经存在的旧分区目录并不会立即被删除,而是在之后的某个时刻通过后台任务被删除(默认 8 分钟)。

所以分区合并的操作,是伴随着每次数据写入后的某段时间的。

接下来引入正题,讲一下分区合并的核心流程。

  1. 创建远程连接,与主副本通信

无论 Merge 操作是从哪个副本上发起的,其合并计划都会交由主副本来制定。

假设在 node01 桑拿执行了 optimize,强制出发了 merge 合并。而 node02 是主副本,此时,node01 通过 replicas 找到了主副本 node02,并尝试建立连接。

  1. 主副本接受通信

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

  1. 主副本制定 merge 计划,并推送 Log 日志

有主副本 node02 制定 merge 计划,并判断哪些分区需要被合并。然后将合并计划转换为 Log 日志对象推送到 Log 日志,以通知所有副本开始合并,日志核心内容如下:

/log/log-0000000001
   source replica: node02
   block_id:
   type: merge
   202308_0_0_0
   202308_1_1_0
   into
   202308_0_1_1

从日志内容看,操作类型为 Merge 合并,将 202308_0_0_0、202308_1_1_0 合并进 202308_0_1_1。

与此同时,主副本会锁住执行线程,对日志的接受情况进行监听,监听行为由 replication_alter_partitions_sync 参数控制,默认值 1。

  • 参数为 0 时:不做任何等待
  • 参数为 1 时:只等待主副本自身完成
  • 参数为 2 时:会等待所有副本拉取完成
  1. 各副本分别拉取 Log 日志

各副本(包括主副本)实例将分别监听 /log/log-0000000001 日志的推送,分别拉取日志到本地,并推送到各自的 /queue 任务队列。

注意,这里包括主副本自身,也是在这个阶段拉取日志执行。而不像 insert,是执行完成后才推送日志。

  1. 各副本分别在本地执行 merge

各副本(包括主副本)基于各自 /queue 队列开始执行任务。至此合并流程结束。

总结

可以看到,在 Merge 合并的流程中,zk 也不会执行任何实质性的数据传输。

无论分区合并操作在哪个副本触发,都转发到主副本,来负责合并计划的制定、消息日志的推送、对日志情况的监控。

4.5. mutation 流程

mutation 操作是指执行 alter delete,或者 alter update 操作时。

和 merge 一样,但和 insert 不同,无论 mutation 操作从哪个副本发起,首先都会转发给主副本响应。

还和之前一样,node02 作为主副本。

  1. 推送 mutation 日志

当在 node01 节点尝试通过 delete 来删除数据时(update 的效果一样),执行命令如下:

alter table t_shard delete where id = 1

执行之后,该副本会进行两项措施:

  • 创建 mutation id:

    created mutation with ID 0000000000
  • 将 mutation 操作转换成 mutationEntry 日志,并推送到 /mutations/0000000000。mutationEntry 的核心属性如下:

    /mutations/0000000000
      source replica: node01
      mutation_id: 1
      partition_id: 202308
      commands: delete where id = 1

    可知,mutation 的操作日志是经由 /muattions 节点分发各副本的。

  1. 所有副本各自监听 mutation 日志

所有副本都会监听 /mutations 节点,都能感知到是否有新日志加入。但不是所有副本都会做出响应,它们会判断自己是否是主副本,只有主副本才会响应 muattion 日志

  1. 主副本响应 mutation 日志,并推送 Log 日志

node02 是主副本,会将 mutation 日志转换为 LogEntry 日志并推送到 /log 节点,以通知各副本执行。Log 日志信息如下:

/log/log-0000000002
  source replica: node02
  block_id:
  type: mutate
  202308_0_1_1
  to
  202308_0_1_1_1

通过日志来看,操作类型是 mutate,这次需要将 202308_0_1_1 分区修改为 202308_0_1_1_1(202308_0_1_1 + "_" + mutation_id)。

  1. 各副本分别拉取Log日志

各副本(包括主副本)分别监听了 /log/log-0000000002 的日志推送,拉取到本地,并推送至各自的 /queue 任务队列中。

  1. 各副本分别在本地执行 mutation

各副本基于各自的 /queue 队列执行任务。至此整个 mutation 流程结束。

总结

同样在整个流程中,zk 也不会执行任何实质性的数据传输。

首先收到 mutation 请求的副本,会经过 /mutations 推送给所有副本,但只有主副本做出响应。主副本随之将 mutation日志转成log日志,经过 /log 分发给所有副本,所有副本监听到后执行。

4.5. alter流程

当对 ReplicatedMergeTree 执行 alter操作,进行元数据修改时,例如:增加、删除表字段,会进行当前流程。

与之前的流程相比,alter的流程会简单很多,不会涉及到 /log 日志的分发。

  1. 修改共享元数据

在 node01 节点尝试增加一个列字段时,执行如下:

alter table t_shard add column code String

执行后 node01 会修改 zk 内的共享元数据节点:

/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.

与此同时,当前 node01 节点还会负责监听所有副本的修改完成情况:

Waiting for node02 to apply changes
Waiting for node03 to apply changes
  1. 监听共享元数据变更并各自执行本地修改

node02、node03 两个副本分别监听共享元数据的变更。之后它们会对本地的元数据版本号与共享版本号对比。这里发现低于共享版本号,于是本地执行更新。

  1. 确认所有副本完成修改

node01 确认所有副本均修改完成。

总结

这里本着谁执行谁负责的原则,也不要求一定要主副本发起操作,任何副本都可以。但发起流程的副本,需要负责共享元数据的修改,以及监控各个副本的修改进度。

5. 分布式DDL与zk

前面讲过同分片下不同副本之间的zk的协调,在第三章节我们讲过分布式DDL,是针对集群上所有分片和副本的,是如果基于zk实现的呢?

分布式DDL,除了前面说过的create,还包括drop、rename、alter等,特点是加入了集群配置,即DDL语法后面加上了 on cluster cluster_name,如:

create/drop/rename/alter table on cluster cluster_name

ck会根据集群的配置信息,分别去各节点执行DDL语句。

5.1. zk结构

默认情况下,分布式DDL在zk内使用的跟路径为: /clickhouse/task_queue/ddl
该路径由 config.xml 内的 distributed_ddl 配置指定:

<distributed_ddl>
    <path>/clickhouse/task_queue/ddl</path>
</distributed_ddl>

此路径下还有一些其他监听节点,包括 /query-[seq],同副本中的 /log、/mutation,DDL操作日志也是使用zk的持久顺序节点,每条指令以query- 为前缀,后面序号递增,如:query-0000000000、query-0000000001等。

在每条query-[seq]日志下面,还有两个状态节点:

  • /query-[seq]/active:用于状态监控,任务执行过程中,该节点会临时保存当前集群状态为active的节点。
  • /query-[seq]/finished:用于检查任务完成情况。每当一个host节点执行完成了,旧写入记录下来。如下面表示node01、node02已完成:

    /query-0000000000/finished
    node01:9000 : 0
    node02:9000 : 0

    5.2. 执行流程

还是拿之前的例子:

CREATE TABLE t_shard ON CLUSTER oreder_cluster
(
    `id` UInt8,
    `name` String,
    `date` DateTime
)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/t_shard', '{replica}')
PARTITION BY toYYYYMM(date)
ORDER BY id

该DDL语句在node01节点执行。

  1. 推送DDL日志

首先在node01执行该语句,本着谁执行谁负责原则。node01 也负责创建 DDLLogEntry 日志,并将日志推送到zk,日志为 /query-0000000001。同时也由 node01 负责监控执行进度。

  1. 拉取日志执行

node01 ~ node06 所有节点都各自监听 /ddl//query-0000000001,于是都拉取到本地。它们首先会判断各自的host是否保护在 DDLLogEntry 的hosts列表中。如果不在则忽略,如果在则执行,执行完成后将状态写入 finished 节点。

  1. 确认完成进度

在步骤一完成后,客户端会阻塞等待180秒,以期望所有hosts执行完成。如果等待时间大于180秒,则转入后台现场等待。(由 distributed_ddl_task_timeout参数指定)

6. Distributed

前面讲过ck集群中的分片,当数据写入时,不同的数据如何写入不同的分片节点呢,这节讲到了 Distributed。

6.1. 概念与使用

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

Distributed分布式引擎语法:

Distributed(cluster_name, database_name, table_name[, sharding_key])

对以上语法解释:

  • cluster_name:集群名称,与集群配置文件metrika.xml中的自定义名称相对应。
  • database_name:数据库名称。
  • table_name:表名称。
  • sharding_key:可选的,用于分片的key值,在数据写入的过程中,分布式表会依据分片key的规则,将数据分布到各个节点的本地表。

注意:创建分布式表是读时检查的机制,也就是说对创建分布式表和本地表的顺序并没有强制要求。假设本地表还没创建,或者不一致,但创建分布式表依然成功,只有用的时候才会报错。

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

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

因此,假设我们要基于前面的例子创建分布式表,要分别执行两个创建表的sql:
创建本地表 t_shard_local

CREATE TABLE t_shard_local ON CLUSTER order_cluster
(
    `id` UInt8,
    `name` String,
    `date` DateTime
)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/t_shard', '{replica}')
PARTITION BY toYYYYMM(date)
ORDER BY id;

创建分布式表t_shard_all

CREATE TABLE t_shard_all ON CLUSTER oreder_cluster
(
    `id` UInt8,
    `name` String,
    `date` DateTime
)
ENGINE = Distributed(oreder_cluster, default,t_shard_local,rand());

分片的规则,这里使用随机数的方式。其实还可以通过权重等方式,这里就不延伸了。

针对 Distribute 表的查询后续可分为两种:

  • 针对于分布式表insert、select之类的查询,会通过分布式的方式作用到各个分片的local本地表中。
  • 但针对分布式表元数据的操作,如:create、drop、rename、alter等,只会修改分布式表本身,不会作用到本地表。

如要彻底删除一张分布式表,需要一起执行:

drop table t_shard_all on cluster order_cluster;
drop table t_shard_local on cluster order_cluster;

6.2. 分片单副本写入原理

先假设一个集群只有2个分片,每个分片也仅有1个副本,即一共只有2个节点(node01、node02),没有副本复制的场景。我们看看 Distributed 的写入流程是怎样的。然后再考虑有副本复制的情况。

假设有5条数据 10、20、30、40、50 一起写入 t_shard_all 分布式表,此时接收到请求的节点是 node01。

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

在node01节点上,向分布式表 t_shard_all 写入5条数据时,假设按照分片键规则,分片1的节点 node01 写入 10、20 这2条数据,向分片2的节点 node02 写入 30、40、50 这3条数据。

那么首先 node01 直接将自己分片上的数据 10、20 写入节点本地的 t_shard_local 表中。

  1. 其他分片数据,写入数据文件

由于 30、40、50 的数据不属于 node01 的分片,会在分布式表 t_shard_all 存储目录下,找到分片2点节点目录(这里是 node02),在下面将数据写入临时bin文件,文件命名:

/t_shard_all/default@node02:9000/1.bin
  1. 向远端分片节点发送数据

Distributed 会有另外一个任务负责监听 /t_shard_all 目录下的文件变化,当这个任务发现 node02 节点下有临时数据文件,就会将目录数据发送给远端的 node02 节点:

t_shard_all.Distributed.DirectoryMonitor:
Started processing /t_shard_all/default@node02:9000/1.bin

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

  1. 远端分片接受数据并写入本地

node02 分片节点确认与 node01 建立连接,并接收来自 node01 发送的数据,将其写入当前节点本地的 t_shard_local 表中。

  1. 由第一个分片节点确认完成

由node01 确认所有的数据已经发送完毕。

总结

在某一个节点写入分布式表数据时,该节点将属于自己分片的数据写入本地表。将其他节点的数据写入分布式表的临时节点,再由其他独立线程将数据文件发送给对应的远端节点。

同步写 与 异步写
  • 异步写:第一个节点(node01)上,在 Distributed 表将分片1的数据写入本地表 t_shard_local 后,Insert 任务就返回写入成功的信息。
  • 同步写:除了写入本地表,还会等待所有分片都写入完成才成功。

使用哪种方式由 insert_distributed_sync 参数控制,默认为 false,即异步写。
如果设为 true,还可以进一步通过 insert_distributed_timeout 参数控制同步等待超时时间。

6.3. 分片多副本写入原理

前面是考虑极端情况,每个分片只有1个副本。回到 order_cluster,每个分片由3个副本,一共6个节点。那么当 node01 节点收到写入分布式表数据的请求时,有两个问题:

  • 分片2上有3个副本(node04、node05、node06),该将数据远程传输给哪个节点呢?
  • node01 直接将自己分片的数据写入自身节点的本地表,那 node02、node03 节点本地表的数据该如何写入呢?

针对这个问题,有两个方案:

方案一:Distributed 发送给每个副本

在上面第二个步骤, node01 将分片1的数据写入自身本地表之后,在分布式表存储目录 /t_shard_all 下每个副本节点都写入对应的数据文件。

如:在 /default@node02:9000、/default@node03:9000 目录写入 分片1的数据。
default@node04:9000、default@node05:9000、default@node06:9000 写入分片2的数据。每个目录下的数据都将由Distributed 的独立线程发送给对应的节点。

在这种实现方式下,即使本地表不使用ReplicatedMergeTree表引擎,也能实现数据副本的功能。缺点一目了然,node01 上的 Distributed 任务有点繁重,需要负责所有副本数据的同步。

方案二:由 ReplicatedMergeTree 负责每个分片各自副本的复制

如果在集群的shard配置中增加 internal_replication 参数并将其设置为true(默认为false),那么 Distributed 表在该分片中只会选择一个“合适”的副本节点并对其写入数据。

此时,如果使用 ReplicatedMergeTree 作为本地表的引擎,则在该分片内,多个副本之间的数据复制会交由 ReplicatedMergeTree 自己处理,不再由 Distributed 负责,从而为其减负。

Distributed 表在分片中只会选择一个“合适”的副本,怎么挑呢?挑选的算法大致如下:

首先在 ck 的服务节点中,拥有一个全局及数据器 errors_count。当服务出现任何异常时,该计数器累加1。接着,当一个分片内拥有多个副本时,选择 errors_count 错误最少的那个。


KerryWu
641 声望159 粉丝

保持饥饿