一、数据副本
1.1 概述
只有使用了ReplicatedMergeTree复制表系列引擎,才能应用副本的能力;或者换言之,即使用ReplicatedMergeTree的数据表就是副本。

ReplicatedMergeTree时MergeTree的派生引擎,它在MergeTree的基础上加了分布式协同的能力,如图10-5所示。

在MergeTree中,一个数据分区由开始创建到全部完成,会经历两类存储区域:
1. 内存:数据首先会被写入内存缓冲区;
2. 本地磁盘:数据接着会被写入到tmp临时目录分区,待全部完成后再将临时目录重命名为正式分区。
ReplicatedMergeTree在上述基础之上增加了Zookeeper的部分,它会进一步在Zookeeper内创建一系列的监听节点,并以此实现多个实例之间的通信。在整个过程中,Zookeeper并不会涉及表数据的传输。
1.2 副本的特点
作为数据副本的主要实现载体,ReplicatedMergeTree在设计上有一些显著特点。
- 依赖Zookeeper:在执行INSERT和ALTER查询的时候,ReplicatedMergeTree需要借助Zookeeper的分布式协同能力,以实现多个副本之间的同步。但是在查询副本的时候,并不需要借助Zookeeper。
- 表级别的副本:副本是表级别定义的,所以每张表的副本配置都可以按照它的实际需求进行个性化定义,包括副本的数量,以及副本在集群内的分布位置等。
- 多主架构:可以在任意一个副本上执行INSERT和ALTER查询,它们的效果是相同的。这些操作会借助Zookeeper的协同能力被分发至每个副本以本地形式执行。
- Block数据块:在执行INSERT命令写入数据时,会依据max_insert_block_size的大小(默认1048576行)将数据切分成若干个Block数据块。所以Block数据块是数据写入的基本单元,并且具有写入的原子性和唯一性。
- 原子性:在数据写入时,一个Block块那的数据要么全部写入成功,要么全部失败。
- 唯一性:在写入一个Block数据块的时候,会按照当前Block数据块的顺序、数据行和数据大小等指标,计算Hash信息摘要并记录。在此之后,如果某个待写入的Block数据块与之前已被写入的Block数据块拥有相同的Hash摘要(Block数据块内数据顺序、数据大小和数据行均相同),则该Block数据块会被忽略。这项设计可以预防由异常原因引起的Block数据块重复写入的问题。
1.3 RepilcatedMergeTree原理解析
在ReplicatedMergeTree的核心逻辑中,大量运用了Zookeeper的能力,以实现多个ReplicatedMergeTree副本实例之间的协同,包括主副本选举、副本状态感知、操作日志分发、任务队列和BlockID去重判断等。在执行INSERT数据写入、MERGE分区、MUTATION操作(数据修改)、ALTER操作的时候,都会涉及与Zookeeper的通信。但是在通信过程中,并不会涉及任何表数据的传输,在查询数据的时候也不会访问Zookeeper,所以不必担心Zookeeper的承载能力。
首先,拟定一个演示场景,即使用ReplicatedMergeTree实现一张拥有1分片、1副本的数据表,并以次来贯穿整个讲解过程(对于大于1个副本的场景,流程以次类推)。
接着,通过对ReplicatedMergeTree分别执行INSERT、MERGE、MUTATION和ALTER操作,以此来讲解相应的工作原理。
1.3.1 INSERT的核心执行流程
当需要和ReplicatedMergeTree中执行INSERT查询以写入数据时,即会进入INSERT核心流程,其整体示意如图10-6所示。

-
创建第一个副本实例
假设首先从CH5节点开始,对CH5节点执行下面的语句后,会创建第一个副本实例:
CREATE TABLE replicated_sales_1( id String, price Float64, create_time DateTime ) ENGINE = ReplicatedMergeTree('/clickhouse/tables/01/replicated_sales_1','ch5.nauu.com') PARTITION BY toYYYMM(create_time) ORDER BY id在创建过程中,ReplicatedMergeTree会进行一些初始化操作,例如:
- 根据zk_path初始化所有的Zookeeper节点。
- 在/replicas/节点下注册自己的副本实例ch5.nauu.com。
- 启动监听节点,监听/log日志节点。
- 参与副本选举,选举出主副本,选举的方式是向/leader_election/插入子节点,第一个插入成功的副本就是主副本。
-
创建第二个副本实例
接着,在CH6节点下执行下面的语句,创建第二个副本实例。表结构和zk_path需要与第一个副本相同,而replica_name则需要设置成CH6的域名:
CREATE TABLE replicated_sales_1( //相同结构 ) ENGINE = ReplicatedMergeTree('/clickhouse/tables/01/1replicated_sales_1', 'ch6.nauu.com') //相同结构在创建过程中,第二个ReplicatedMergeTree同样会进行一些初始化操作,例如:
- 在/replicas/节点下注册自己的副本实例ch6.nauu.com。
- 启动监听任务,监听/log日志节点。
- 参与副本选举,选举出主副本。在这个例子中,CH5副本成为主副本。
-
向第一个副本实例写入数据
现在尝试向第一个副本CH5写入数据,执行INSERT命令,当INSERT命令执行之后,首先会在本地完成分区目录的写入,接着向/blocks节点写入该数据分区的block_id,该block_id将作为后续去重操作的判断依据。此外,如果设置了insert_quorum参数(默认为0),并且insert_quorum >= 2,则CH5会进一步监控已完成写入操作的副本个数,只有当写入副本个数大于或等于insert_quorum时,整个写入操作才算成功。
-
由第一个副本实例推送Log日志
在3步骤完成之后,会继续由执行了INSERT的副本向/log节点推送操作日志。在这个例子中,会由第一个副本CH5担此重任。日志的编号是/log/log-0000000000,而LogEntry的核心属性如下:
/log/log-0000000000 source replica: ch5.nauu.com block_id: 201905_... type:get partition_name :201905_0_0_0_0从日志内容中可以看出,操作类型为get下载,而需要下载的分区是201905_0_0_0。其余所有副本都会基于Log日志以相同的顺序执行命令。
-
第二个副本实例拉取Log日志
CH6副本会一直监听/log节点变化,当CH5推送了/log/log-0000000000之后,CH6便会触发日志的拉取任务并更新log_pointer,将其指向最新日志下标:
/replicas/ch6.nauu.com/log_pointer : 0在拉取了LogEntry之后,它并不会直接执行,而是将其转为任务对象放至队列:
/replicas/ch6.nauu.com/queue/ Pulling 1 entries to queue: log-00000000000 - log-00000000000000这是因为在复杂的情况下,考虑到在同一时段内,会连续收到许多个LogEntry,所以使用队列的形式消化任务是一种更为合理的设计。
-
第二个副本实例向其他副本发起下载请求
CH6基于 /queue 队列开始执行任务。当看到type类型为get的时候,ReplicatedMergeTree就会明白此时在远端的其他副本中已经成功写入了数据分区,而自己需要同步这些数据。
CH6上的第二个副本实例会开始选择一个远端的其他副本作为数据的下载来源,然后发起下载请求。远端副本的选择算法大致是这样的:
(1)从/replicas节点拿到所有的副本节点;
(2)遍历这些副本,选取拥有最大的log_pointer下标,并且/queue子节点数量最少的那个。log_pointer下标最大,意味着该副本之行的日志最多,数据应该更加完成;而/queue最小,则意味着该副本目前的任务执行副大较小。
在本例中,算法选择的是远端副本时CH5,于是CH6副本向CH5副本发起来HTTP请求,希望下载分区201905_0_0_0。
-
第一个副本实例响应数据下载
CH5的DataPartsExchange端口服务接收到调用请求,在得知对方来意之后,根据参数作出响应,将本地分区201905_0_0_0基于DataPartsExchange的服务响应发送回CH6
-
第二个副本实例下载数据并完成本地写入
CH6副本在收到CH5的分区数据后,首先将其写至临时目录,待全部数据接收完成之后,重新命名该目录,至此,整个写入流程结束。
1.3.2 MERGE的核心执行流程
当ReplicatedMergeTree触发分区合并动作时,即会进入这个部分的流程,它的核心流程如图10-7所示。
无论Merge操作从哪个副本发起,其合并计划都会交由主副本来制定。当生成合并计划后,会将合并计划推送到/log节点,各实例监听log节点拿到合并计划各自在本地进行合并。

1.3.3 MUTATION的核心执行流程
与MERGE类似,无论MUTATION操作是从哪个副本发起,首先都会由主副本进行响应。

1.3.4 ALTER的核心执行流程
当对ReplicatedMergeTree执行ALTER操作进行元数据修改的时候,即会进入ALTER部分的逻辑,例如增加、删除表字段等。ALTER操作并不一定要由主副本完成,而是收到操作指令的节点会更新/metadata, /columns的版本信息,其他节点监听这个版本号和本地版本好做对比,如果需要更新就同步更新。

二、数据分片
2.1 概述
ClickHouse中的每个服务节点都可称为一个shard(分片)。从理论上来讲,假设有N(N>=1)张数据表A,分布在N个ClickHouse服务节点,而这些数据表彼此之间没有重复数据,那么就可以说数据表A拥有N个分片。然而如果在工程实践中,如果只有这些分片表,那么整个Sharding(分片)方案基本是不可用的。对于一个完整的方案来说,还需要考虑数据在写入时,如何被均匀地写至各个shard,以及数据在查询时,如何路由到每个shard,并组合成结果集。所以,ClickHouse的数据分片需要结合Distribute表引擎一同使用。如图10-10所示。
Distributed表引擎自身不存储任何数据,它只作为分布式表的一层透明代理,在集群内部自动开展数据的写入、分发、查询、路由等工作。

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

从实体表层面来看,一张分片表由两部分组成:
- 本地表:通常以_local为后缀进行命名。本地表是承接数据的载体,可以使用非Distributed的任意表引擎,一张本地表对应了一个数据分片。
- 分布式表:通常以_all为后缀进行命名。分布式表只能使用Distributed表引擎,它与本地表形成一对多的映射关系,日后将通过分布式表代理操作多张本地表。
2.2.1 查询的分类
Distributed表的查询操作可以分为如下几类:
-
会作用于本地表的查询:对于INSERT和SELECT操作,Distributed将会以分布式的方式作用于local本地表。
-
只会影响Distributed自身,不会作用于本地表的查询:Distributed支持部分元数据操作,包括CREATE、DROP、RENAME和ALTER,其中ALTER并不包括分区的操作。这些查询只会修改Distributed表自身,并不会修改local本地表。例如要彻底删除一张分布式表,则需要分别删除分布式表和本地表,示例如下。
--删除分布式表 DROP TABLE test_shard_2_all ON CLUSTER sharding_simpple --删除本地表 DROP TABLE test_shard_2_local ON CLUSTER sharding_simple -
不支持的查询:Distributed表不支持任何MUTATION类型的操作,包括ALTER DELETE和ALTER UPDATE。
2.2.1 分片规则
2.2.1.1 分片权重(weight)
在集群的配置中,有一项weight(分片权重)的设置:
<sharding_simple><!--自定义集群名称-->
<shard><!--分片-->
<weight>10</weight><!--分片权重-->
</shard>
<shard>
<weight>20</weight>
</shard>
...
weight默认为1,虽然可以将它设置成任意整数,但官方建议应该尽可能设置成较小的值。分片权重会影响数据在分片中的倾斜程度,一个分片权重值越大,那么它被写入的数据就会越多。
2.2.1.2 slot(槽)
slot可以理解成许多小的水槽,如果把数据比做是水的话,那么数据之水会顺着这些水槽流进每个数据分片。slot的数量等于所有分片的权重之和,假设集群sharding_simple有两个Shard分片,第一个分片的weight为10,第二个分片的weight为20,那么slot的数量则等于30。**slot按照权重元素的取值区间,与对应的分片形成映射关系。**在这个示例中,如果slot值落在[0, 10)区间,则对应第一个分片;如果slot值落在[10, 30)区间,则对应第二个分片。
2.2.1.3 选择函数
选择函数用于判断一行待写入的数据应该被写入哪个分片,整个判断过程大致分成两个步骤:
-
找出slot的取值,其计算公式如下:
slot = shard_value % sum_weight- 基于slot值找对对应的数据分片。
整个过程的示意如图10-14所示。

2.2.2 分布式写入的核心流程
2.2.2.1 借助外部计算系统写入
在向集群内的分片写入数据时,第一种方式是借助外部计算系统,事先将数据均匀分片,再借由计算系统直接将数据写入ClickHouse集群的各个本地表,如图10-15所示。这种方案通常拥有更好的写入性能,因为分片数据是被并行点对点写入的。

2.2.2.2 ClickHouse自身写入
在对Distributed表执行INSERT操作的时候,会进入数据写入分片的逻辑,它的核心流程如图10-16所示。

可以看到,在整个过程中,Distributed表负责所有分片的写入工作。本着谁执行谁负责的原则,在这个示例中,由CH5节点的分布式表负责切分数据,并向其他分片节点发送数据。
这种方式的对执行节点的性能压力很大。
2.2.3 分布式查询的核心流程
与数据写入有所不同,在面向集群查询数据的时候,只能通过Distributed表引擎实现。当Distributed表接收到SELECT查询的时候,它会以次查询每个分片的数据,再合并汇总返回。
2.2.3.1 多副本的路由规则
在查询数据的时候,如果集群中的一个shard,拥有多个replica,那么Distributed表引擎需要面临副本选择的问题。它会使用负载均衡算法从众多replica中选择一个,而具体使用何种负载均衡算法,则由load_balancing参数控制。
2.2.3.2 多分片查询的核心流程
分布式查询与分布式写入类似,同样本着谁执行谁负责的原则,它会由接收SELECT查询的Distributed表,并负责串联起整个过程。首先它会将针对分布式表的SQL语句,按照分片数量将查询拆分成若干个针对本地表的子查询,然后向各个分片发起查询,最后再汇总各个分片的返回结果。
例如,如果对分布式表按如下方式发起查询:
select * from distributed_table
那么它会将其转为如下形式之后,再发送到远端分片节点来执行:
select * from local_table
以sharding_sample集群的test_shard_2_all为例,假设在CH5节点对分布式表发起查询:
select count(*) from test_shard_2_all
那么,Distributed表引器会将查询计划转换为多个分片的UNION联合查询,如图10-18所示。

整个执行计划从上至下大致分成两个步骤:
-
查询各个分片数据
在图10-18的执行计划中,One和Remote步骤是并行执行的,它们分别负责了本地和远端分片的查询动作。其中,在One步骤会将SQL转换为对本地表的查询:
select count() from default.test_shard_2_local而在Remote步骤中,会建立与CH6节点的连接,并向其发起远程查询:
Connection (ch6.nauu.com:9000): Connecting. Database: ...CH6节点在接收到来自CH5的查询请求后,开始在本地执行。同样,SQL会转换成对本地表的查询:
executeQuery: (from CH5:45992, initial_query_id: 4831b93Bb-5ae6-4b18-bac9-el0cc9614353) WITH toUInt32(2) AS _shard_num SELECT COUNT() FROM default.test_shard_2_local -
合并返回结果
多个分片数据均查询返回后,按如下方法在CH5节点将它们合并:
Read 2 blocks of partially aggregated data, total 2 rovws Aggregator: Converting aggregated data to blocks
2.2.3.3 使用Global优化分布式子查询
如果在分布式查询中使用子查询,可能会面临两难的局面。下面来看一个示例。假如有这样一张分布式表test_query_all,它拥有两个分片,而表内的数据如下所示:

此时我们会面临两难选择:IN查询的子句应该使用本地表还是分布式表?
-
使用本地表的问题
如果在IN查询中使用本地表,例如下面的语句:
SELECT uniq(id) FROM test_query_all WHERE repo = 100 AND id IN (SELECT id FROM test_query_local WHERE repo = 200) 结果: uniq(id) 0那么会发现返回的结果是错误的,原因是分布式表在接收到查询之后,会将上述SQL替换成本地表的形式,再发送到每个分片进行执行:
SELECT uniq(id) FROM test_query_local WHERE repo = 100 AND id IN (SELECT id FROM test_query_local WHERE repo =200)注意,IN查询中使用的是本地表
由于在单个分片上只保留了部分的数据,所以该SQL语句没有匹配到任何数据,如图10-19所示。单独在分片1或分片2内均无法找到repo同时等于100和200的数据。

-
使用分布式表的问题
为了解决返回结果错误的问题,现在尝试在IN查询子句中使用分布式表:
SELECT uniq(id) FROM test_query_all WHERE repo = 100 AND id IN (SELECT id FROM test_query_all WHERE repo = 200) 结果: uniq(id)- 1这次返回了正确的查询结果,但是通过进一步观察执行日志会发现,该查询的请求被放大了两倍。
这是由于在IN查询的子句中,同样也使用了分布式表查询:
SELECT id FROM test_query_all WHERE repo = 200所以在CH6节点收到这条SQL后,它将再次向其他分片发起远程查询,如图10-20所示。

由此可以得出结论,在IN查询子句使用分布式表的时候,查询请求会被放大N的平方倍,其中N等于集群内分 片节点的数量,假设集群内有10个分片节点,则在一次查询的过程中,会最终导致100次的查询请求,这显然 是不可接受的。
-
使用GLOBAL优化查询
为了解决查询放大的问题,可以使用GLOBAL IN或JOIN进行优化。现在对刚才的SQL进行改造,为其增加GLOBAL修饰符:
SELECT uniq(id) FROM test_query_all WHERE repo = 100 AND id GLOBAL IN (SELECT id FROM test_query_all WHEREE repo = 200)再次分析查询的核心流程,如图10-21所示,整个过程大致分为5个步骤:
- 将IN子句单独提出,发起了一次分布式查询
- 将分布式表转local本地表后,分别在本地和远端分片执行查询
- 将IN子句查询的结果进行汇总,并放入一张临时的内存表进行保存
- 将内存表发送至远端分片节点
- 将分布式表转为本地表后,开始执行完整的SQL语句,IN子句直接使用临时内存表的数据
至此,整个核心流程结束。可以看到,在使用GLOBAL修饰符之后,ClickHouse使用内存表临时保存了IN子句查询的结果,并将其发送到远端分片节点,以此达到了数据共享的目的,从而避免了查询放大的问题。由于数据会在网络间分发,所以特别需要注意临时表的大小,IN或者JOIN子句返回的数据不宜过大。如果表内存在重复数据,也可以事先在子句SQL中增加去重逻辑。

6832

被折叠的 条评论
为什么被折叠?



