92.【数据库】ClickHouse从入门到放弃-副本与分片- 分布式写入查询的核心流程

2,000 阅读14分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第28天,点击查看活动详情 

文档参考:《ClickHouse原理解析与应用实践(数据库技术丛书)(朱凯)》

86.【数据库】ClickHouse从入门到放弃-副本与分片- 数据分片 - 掘金 (juejin.cn)

91.【数据库】ClickHouse从入门到放弃-副本与分片- Distributed原理及分片规则 - 掘金 (juejin.cn)

分布式写入的核心流程

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

image.png

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

第二种思路是通过Distributed表引擎代理写入分片数据的,接下来开始介绍数据写入的核心流程。

为了便于理解整个过程,这里会将分片写入、副本复制拆分成两个部分进行讲解。在讲解过程中,会使用两个特殊的集群分别进行演示:第一个集群拥有2个分片和0个副本,通过这个示例向大家讲解分片写入的核心流程;第二个集群拥有1个分片和1个副本,通过这个示例向大家讲解副本复制的核心流程。

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

在对Distributed表执行INSERT查询的时候,会进入数据写入分片的执行逻辑,它的核心流程如图所示。

image.png

image.png

在这个流程中,继续使用集群sharding_simple的示例,该集群由2个分片和0个副本组成。整个流程从上至下按照时间顺序进行,其大致分成5个步骤。现在根据图10-16所示编号讲解整个过程。

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

首先在CH5节点,对分布式表test_shard_2_all执行INSERT查询,尝试写入10、30、200和55四行数据。执行之后分布式表主要会做两件事情:第一,根据分片规则划分数据,在这个示例中,30会归至分片1,而10、200和55则会归至分片2;第二,将属于当前分片的数据直接写入本地表test_shard_2_local。

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

将归至远端分片的数据以分区为单位,分别写入test_shard_2_all存储目录下的临时bin文件,数据文件的命名规则如下:

/database@host:port/[increase_num].bin

由于在这个示例中只有一个远端分片CH6,所以它的临时数据文件如下所示:

/test_shard_2_all/default@ch6.nauu.com:9000/1.bin

10、200和55三行数据会被写入上述这个临时数据文件。接着,会尝试与远端CH6分片建立连接:

Connection (ch6.nauu.com:9000): Connected to ClickHouse server

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

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

test_shard_2_all.Distributed.DirectoryMonitor:
Started processing /test_shard_2_all/default@ch6.nauu.com:9000/1.bin

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

4)第二个分片接收数据并写入本地

CH6分片节点确认建立与CH5的连接:

TCPHandlerFactory: TCP Request. Address: CH5:45912
TCPHandler: Connected ClickHouse server

在接收到来自CH5发送的数据后,将它们写入本地表:

executeQuery: (from CH5) INSERT INTO default.test_shard_2_local
--第一个分区
Reserving 1.00 MiB on disk 'default'
Renaming temporary part tmp_insert_10_1_1_0 to 10_1_1_0.
--第二个分区
Reserving 1.00 MiB on disk 'default'
Renaming temporary part tmp_insert_200_2_2_0 to 200_2_2_0.
--第三个分区
Reserving 1.00 MiB on disk 'default'
Renaming temporary part tmp_insert_55_3_3_0 to 55_3_3_0.

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

最后,还是由CH5分片确认所有的数据发送完毕:

Finished processing /test_shard_2_all/default@ch6.nauu.com:9000/1.bin

至此,整个流程结束。

可以看到,在整个过程中,Distributed表负责所有分片的写入工作。本着谁执行谁负责的原则,在这个示例中,由CH5节点的分布式表负责切分数据,并向所有其他分片节点发送数据。

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

2.副本复制数据的核心流程

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

image.png

1)通过Distributed复制数据

在这种实现方式下,即使本地表不使用ReplicatedMergeTree表引擎,也能实现数据副本的功能。Distributed会同时负责分片和副本的数据写入工作,而副本数据的写入流程与分片逻辑相同,详情参照: 88.【数据库】ClickHouse从入门到放弃-副本与分片- ReplicatedMergeTree原理解析 - 掘金 (juejin.cn)

现在用一个简单示例说明。首先让我们再重温一下集群sharding_simple_1的配置,它的配置如下:

<!-- 1个分片 1个副本-->
<sharding_simple_1>
    <shard>
        <replica>
            <host>ch5.nauu.com</host>
            <port>9000</port>
        </replica>
        <replica>
            <host>ch6.nauu.com</host>
            <port>9000</port>
        </replica>
    </shard>
</sharding_simple_1>

现在,尝试在这个集群内创建数据表,首先创建本地表:

CREATE TABLE test_sharding_simple1_local ON CLUSTER sharding_simple_1(
    id UInt64
)ENGINE = MergeTree()
ORDER BY id

接着创建Distributed分布式表:

CREATE TABLE test_sharding_simple1_all
(
    id UInt64
)ENGINE = Distributed(sharding_simple_1, default, test_sharding_simple1_local,rand())

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

细心的朋友应该能够发现,在这种实现方案下,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错误最少的那个。

加入internal_replication配置后示例如下所示:

<shard>
    <!-- 由ReplicatedMergeTree复制表自己负责数据分发 -->
    <internal_replication>true</internal_replication>
    <replica>
        <host>ch5.nauu.com</host>
        <port>9000</port>
    </replica>
    <replica>
        <host>ch6.nauu.com</host>
        <port>9000</port>
    </replica>
</shard>

关于Distributed表引擎如何将数据写入分片,请参见上面的流程;而关于Replicated-MergeTree表引擎如何复制分发数据,请参见88.【数据库】ClickHouse从入门到放弃-副本与分片- ReplicatedMergeTree原理解析 - 掘金 (juejin.cn)

分布式查询的核心流程

与数据写入有所不同,在面向集群查询数据的时候,只能通过Distributed表引擎实现。当Distributed表接收到SELECT查询的时候,它会依次查询每个分片的数据,再合并汇总返回。接下来将对数据查询时的重点逻辑进行介绍。

1.多副本的路由规则

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

load_balancing = random/nearest_hostname/in_order/first_or_random

有如下四种负载均衡算法:

1)random

random是默认的负载均衡算法,正如前文所述,在ClickHouse的服务节点中,拥有一个全局计数器errors_count,当服务发生任何异常时,该计数累积加1。而random算法会选择errors_count错误数量最少的replica,如果多个replica的errors_count计数相同,则在它们之中随机选择一个。

2)nearest_hostname

nearest_hostname可以看作random算法的变种,首先它会选择errors_count错误数量最少的replica,如果多个replica的errors_count计数相同,则选择集群配置中host名称与当前host最相似的一个。而相似的规则是以当前host名称为基准按字节逐位比较,找出不同字节数最少的一个,例如CH5-1-1和CH5-1-2.nauu.com有一个字节不同:

CH5-1-1
CH5-1-2.nauu.com

而CH5-1-1和CH5-2-2则有两个字节不同:

CH5-1-1
CH5-2-2

3)in_order

in_order同样可以看作random算法的变种,首先它会选择errors_count错误数量最少的replica,如果多个replica的errors_count计数相同,则按照集群配置中replica的定义顺序逐个选择。

4)first_or_random

first_or_random可以看作in_order算法的变种,首先它会选择errors_count错误数量最少的replica,如果多个replica的errors_count计数相同,它首先会选择集群配置中第一个定义的replica,如果该replica不可用,则进一步随机选择一个其他的replica。

2.多分片查询的核心流程

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

SELECT * FROM distributed_table

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

SELECT * FROM local_table

以sharding_simple集群的test_shard_2_all为例,假设在CH5节点对分布式表发起查询:

SELECT COUNT(*) FROM test_shard_2_all

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

image.png

整个执行计划从下至上大致分成两个步骤:

1)查询各个分片数据

在图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: 4831b93b-5ae6-4b18-bac9-e10cc9614353) WITH toUInt32(2) AS _shard_num 
SELECT COUNT() FROM default.test_shard_2_local

2)合并返回结果

多个分片数据均查询返回后,按如下方法在CH5节点将它们合并:

Read 2 blocks of partially aggregated data, total 2 rows.
Aggregator: Converting aggregated data to blocks
……

3.使用Global优化分布式子查询

如果在分布式查询中使用子查询,可能会面临两难的局面。下面来看一个示例。假设有这样一张分布式表test_query_all,它拥有两个分片,而表内的数据如下所示:

CH5节点test_query_local
┌─id─┬─repo─┐
│  1  │  100  │
│  2  │  100  │
│  3  │  100  │
└───┴─────┘
CH6节点test_query_local
┌─id─┬─repo─┐
│  3  │  200  │
│  4  │  200  │
└───┴─────┘

其中,id代表用户的编号,repo代表仓库的编号。如果现在有一项查询需求,要求找到同时拥有两个仓库的用户,应该如何实现?对于这类交集查询的需求,可以使用IN子查询,此时你会面临两难的选择:IN查询的子句应该使用本地表还是分布式表?(使用JOIN面临的情形与IN类似)。 1)使用本地表的问题

如果在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查询的子句使用的是本地表:

SELECT id FROM test_query_local WHERE repo = 200

由于在单个分片上只保存了部分的数据,所以该SQL语句没有匹配到任何数据,如图所示。

image.png

2)使用分布式表的问题

为了解决返回结果错误的问题,现在尝试在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之后,它将再次向其他分片发起远程查询,如图所示。

image.png 因此可以得出结论,在IN查询子句使用分布式表的时候,查询请求会被放大N的平方倍,其中N等于集群内分片节点的数量,假如集群内有10个分片节点,则在一次查询的过程中,会最终导致100次的查询请求,这显然是不可接受的。

3)使用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 WHERE repo = 200)

再次分析查询的核心过程,如图所示。

image.png

整个过程由上至下大致分成5个步骤:

(1)将IN子句单独提出,发起了一次分布式查询。

(2)将分布式表转local本地表后,分别在本地和远端分片执行查询。

(3)将IN子句查询的结果进行汇总,并放入一张临时的内存表进行保存。

(4)将内存表发送到远端分片节点。

(5)将分布式表转为本地表后,开始执行完整的SQL语句,IN子句直接使用临时内存表的数据。

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