代码改变世界

ClickHouse中的Hash Join, Parallel Hash Join, Grace Hash Join

2023-08-07 17:39  abce  阅读(424)  评论(0编辑  收藏  举报

查询管道(Query Pipeline)
ClickHouse是为快速而设计。ClickHouse中的查询以高度并行的方式进行处理,尽可能地使用当前服务器上的所有必要资源,在许多情况下,硬件的利用率达到理论极限。服务器的CPU和内存越多,并行执行查询的性能就越高。

查询管道决定了每个查询执行阶段的并行度。

下图显示了ClickHouse查询管道如何在拥有4个CPU内核的服务器上处理查询:

查询的表数据在4个独立并行的数据流stage之间动态分配,这些数据流按块顺序将数据传送到ClickHouse。由于服务器有4 个CPU,查询管道中的大多数查询处理阶段都由4个线程并行执行。

使用的线程数量取决于max_threads设置,默认设置为ClickHouse在其运行的机器上看到的CPU内核数。

对于所有查询(包括连接),查询管道确保以高度并行和可扩展的方式处理表数据。

 

连接算法
为确保最大限度地利用资源,ClickHouse开发了6种不同的join算法。这些算法决定了计划和执行连接查询的方式。可以对ClickHouse进行配置,以便在运行时自适应地选择和动态更改要使用的连接算法。这取决于资源的可用性和使用情况。不过,ClickHouse也允许用户自己指定所需的连接算法。下表根据相对内存消耗和执行时间描述了这些算法:

 

本文将详细介绍并比较上图中基于内存哈希表的三种ClickHouse连接算法:
·Hash join
·Parallel hash join
·Grace hash join

将在这篇文章中探讨哈希连接算法如何快速和最通用。右测表为大型表时,并行哈希连接算法速度更快,但需要更多内存。哈希连接和并行哈希连接都受内存限制。而Grace hash join算法是一种不占用内存的版本,它能将数据暂时溢出到磁盘上。Grace hash join不需要对数据进行任何排序,因此克服了其他将数据溢出到磁盘的连接算法(如(partial) merge连接算法)所面临的一些性能挑战。

 

测试数据和资源准备

查看行数和未压缩数据量大小

SELECT
    table,
    formatReadableQuantity(sum(rows)) AS rows,
    formatReadableSize(sum(data_uncompressed_bytes)) AS data_uncompressed
FROM system.parts
WHERE (database = 'imdb_large') AND active
GROUP BY table
ORDER BY table ASC;

┌─table──┬─rows───────────┬─data_uncompressed─┐
│ actors │ 1.00 million   │ 21.81 MiB         │
│ roles  │ 100.00 million │ 2.63 GiB          │
└────────┴────────────────┴───────────────────┘

为了保持可视化演示的简洁和可读性,我们人为地限制了ClickHouse查询管道中使用的并行程度,设置为max_threads=2。

不过,在所有示例查询运行中,我们都使用默认设置max_threads。

如上所述,默认情况下,max_threads设置为ClickHouse所运行机器上看到的CPU数。这些示例使用的是ClickHouse云服务,其中一个节点有30个CPU:

SELECT getSetting('max_threads');

┌─getSetting('max_threads')─┐
│                        30 │
└───────────────────────────┘

 

Hash join
内存中的哈希表每秒可处理2.5亿个随机的请求(如果CPU缓存满足,则可处理超过10亿个请求)。这种极快的查找能力使内存哈希表成为ClickHouse在不可能或无法利用表排序时实现连接的自然通用选择。

哈希连接算法是ClickHouse中最通用的连接实现方法。下面将说明集成到ClickHouse查询管道中的哈希连接算法:

可以看到:
1.右表中的所有数据(max_threads=2,因此由2个线程并行)流式传输到内存中,然后ClickHouse将这些数据填入内存中的哈希表。
2.将左表中的数据流(max_threads=2,因此由2个线程并行)和1通过查找哈希表来连接。

请注意,由于ClickHouse为右表在内存中创建哈希表,因此将较小的表放在JOIN的右侧会更节省内存。我们将在下面进行演示。

还要注意的是,哈希表是ClickHouse中的一个关键数据结构。根据每个特定查询,特别是连接查询,根据连接键列类型和连接严格程度,ClickHouse会自动从30多种变体中选择一种。

 

hash连接算法支持的join类型

支持所有连接类型和严格度设置。此外,目前只有hash连接支持在ON子句中支持多个连接键的OR结合操作。

示例:

小表在右侧

SELECT *
FROM roles AS r
JOIN actors AS a ON r.actor_id = a.id
FORMAT `Null`
SETTINGS join_algorithm = 'hash';

0 rows in set. Elapsed: 0.817 sec. Processed 101.00 million rows, 3.67 GB (123.57 million rows/s., 4.49 GB/s.)

小表在左侧

SELECT *
FROM actors AS a
JOIN roles AS r ON a.id = r.actor_id
FORMAT `Null`
SETTINGS join_algorithm = 'hash';

0 rows in set. Elapsed: 5.063 sec. Processed 101.00 million rows, 3.67 GB (19.95 million rows/s., 724.03 MB/s.)

我们可以查询query_log系统表,以查看最近两次查询运行的运行时间统计信息:

SELECT
    query,
    formatReadableTimeDelta(query_duration_ms / 1000) AS query_duration,
    formatReadableSize(memory_usage) AS memory_usage,
    formatReadableQuantity(read_rows) AS read_rows,
    formatReadableSize(read_bytes) AS read_data
FROM clusterAllReplicas(default, system.query_log)
WHERE (type = 'QueryFinish') AND hasAll(tables, ['imdb_large.actors', 'imdb_large.roles'])
ORDER BY initial_query_start_time DESC
LIMIT 2
FORMAT Vertical;

Row 1:
──────
query:          SELECT *
                FROM actors AS a
                JOIN roles AS r ON a.id = r.actor_id
                FORMAT `Null`
                SETTINGS join_algorithm = 'hash'
query_duration: 5 seconds
memory_usage:   8.95 GiB
read_rows:      101.00 million
read_data:      3.41 GiB

Row 2:
──────
query:          SELECT *
                FROM roles AS r
                JOIN actors AS a ON r.actor_id = a.id
                FORMAT `Null`
                SETTINGS join_algorithm = 'hash'
query_duration: 0 seconds
memory_usage:   716.44 MiB
read_rows:      101.00 million
read_data:      3.41 GiB

不出所料,将小表actors作为右表,连接查询所消耗的内存明显少于将大表放在右侧的连接查询。

需要注意的是,在两次查询运行中,所显示的8.95GiB和716.44 MiB的峰值内存使用量,都比未压缩的右连接表大,右连接表未压缩大小分别是2.63GiB和21.81MiB。原因是哈希表的大小是根据连接键列的类型以及特定内部哈希表缓冲区大小的倍数进行初始选择和动态增加的。memory_usage指标计算的是为哈希表预留的总内存,尽管该内存可能不会被完全填满。

在执行这两个查询时,ClickHouse读取的总行(和数据)数量相同:roles表中的1亿行+actors表中的100万行。但是,右表较大的连接查询速度要慢五倍。这是因为在将右侧表的行插入哈希表时,默认的哈希连接不是线程安全的。因此,哈希表的填充阶段在单线程中运行。我们可以通过查询实际的查询管道来仔细检查这一点。

 

查询管道

 我们可以使用ClickHouse客户端命令行来分析clickhouse查询管道执行hash连接查询。

./clickhouse client --host ekyyw56ard.us-west-2.aws.clickhouse.cloud --secure --port 9440 --password  --database=imdb_large --query "
EXPLAIN pipeline graph=1, compact=0
SELECT *
FROM actors a
JOIN roles r ON a.id = r.actor_id
SETTINGS max_threads = 2, join_algorithm = 'hash';" | dot -Tpdf > pipeline.pdf

对管道用数字做了注释,略微简化了主要阶段的名称,并添加了两个连接表,以便使和上面的那张图保持一致:

可以看到,查询管道①从两个并行流阶段(因为max_threads设置为2)开始,用于从右侧表中流式传输数据,然后是一个填充阶段,用于填充哈希表。另外两个并行流阶段②和两个并行连接阶段③用于对来自左侧表的数据进行流式处理和连接。

如上所述,在将右侧表的行插入哈希表时,默认的哈希连接算法不是线程安全的。因此,在上述流水线中使用了一个调整大小阶段,将从右侧表流式传输数据的两个线程减少到单线程填充阶段。这会成为查询运行时间的瓶颈。如果右侧表的数据量很大--参见我们上面的两次查询运行,其中连接右侧大的roles表的查询速度要慢五倍。

不过,自ClickHouse 22.7版以来,通过使用并行哈希算法,从右侧表建立哈希表的过程可以大大加快大型表的运行速度。

 

 

并行哈希连接

并行哈希连接算法是哈希连接算法的一种变体,它将输入数据拆分开来,同时建立多个哈希表,以加快连接速度,但代价是增加内存开销。下面我们将简要介绍该算法的查询管道:

①右侧表中的所有数据都以流式方式(由2个线程并行执行,因为max_threads=2)传输到内存中。数据是按块分流的。通过对每一行的连接键应用哈希函数,将每个流数据块中的行分到2个桶(max_threads = 2)。我们在上图中用橙色和蓝色勾勒了这一过程。并行时,使用单线程为每个桶生成一个内存哈希表。请注意,将行分割到成桶的哈希函数与内部哈希表中使用的哈希函数不同。

从左表流式传输数据(由于max_threads=2,所以由2个线程并行传输),并将步骤①中的"桶哈希函数"应用于每一行的连接键,以确定对应的哈希表,然后通过查找相应的哈希表来连接各行。

请注意,max_threads设置决定了并发哈希表的数量。

支持的连接类型:

支持INNER和LEFT连接类型以及除ASOF以外的所有严格性设置。

示例
现在,我们将比较哈希算法和并行哈希算法对同一查询的运行时间和峰值内存消耗。

哈希连接,右侧为较大的表:

SELECT *
FROM actors AS a
JOIN roles AS r ON a.id = r.actor_id
FORMAT `Null`
SETTINGS join_algorithm = 'hash';

0 rows in set. Elapsed: 5.385 sec. Processed 101.00 million rows, 3.67 GB (18.76 million rows/s., 680.77 MB/s.)

 

平行哈希连接,右侧为较大的表:

SELECT *
FROM actors AS a
JOIN roles AS r ON a.id = r.actor_id
FORMAT `Null`
SETTINGS join_algorithm = 'parallel_hash';

0 rows in set. Elapsed: 2.639 sec. Processed 101.00 million rows, 3.67 GB (38.28 million rows/s., 1.39 GB/s.)

 

检查两个查询的运行时间:

SELECT
    query,
    formatReadableTimeDelta(query_duration_ms / 1000) AS query_duration,
    formatReadableSize(memory_usage) AS memory_usage,
    formatReadableQuantity(read_rows) AS read_rows,
    formatReadableSize(read_bytes) AS read_data
FROM clusterAllReplicas(default, system.query_log)
WHERE (type = 'QueryFinish') AND hasAll(tables, ['imdb_large.actors', 'imdb_large.roles'])
ORDER BY initial_query_start_time DESC
LIMIT 2
FORMAT Vertical;

Row 1:
──────
query:          SELECT *
                FROM actors AS a
                JOIN roles AS r ON a.id = r.actor_id
                FORMAT `Null`
                SETTINGS join_algorithm = 'parallel_hash'
query_duration: 2 seconds
memory_usage:   18.29 GiB
read_rows:      101.00 million
read_data:      3.41 GiB

Row 2:
──────
query:          SELECT *
                FROM actors AS a
                JOIN roles AS r ON a.id = r.actor_id
                FORMAT `Null`
                SETTINGS join_algorithm = 'hash'
query_duration: 5 seconds
memory_usage:   8.86 GiB
read_rows:      101.00 million
read_data:      3.41 GiB

并行哈希连接的速度比标准哈希连接快大约100%,但内存消耗峰值却是标准哈希连接的两倍多,尽管两种查询所读取的行数和数据量以及右侧表的大小相同。

内存消耗大得多的原因是,查询是在一个有30个CPU内核的节点上运行的,因此最大线程数设置为30。这意味着,正如我们将在下文演示的那样,使用了30个并发哈希表。如前所述,每个哈希表的大小最初是根据连接键列的类型和特定内部哈希表缓冲区大小的倍数来选择和动态增加的。哈希表很可能没有完全填满,但内存使用指标会计算为哈希表预留的总内存。

查询管道
我们提到,max_threads设置决定了并发哈希表的数量。
首先,我们将最大线程数设置为2,看看并行哈希连接查询的查询管道:

./clickhouse client --host ekyyw56ard.us-west-2.aws.clickhouse.cloud --secure --port 9440 --password  --database=imdb_large --query "
EXPLAIN pipeline graph=1, compact=0
SELECT *
FROM actors a
JOIN roles r ON a.id = r.actor_id
SETTINGS max_threads = 2, join_algorithm = 'parallel_hash';" | dot -Tpdf > pipeline.pdf


同样,在图中加上了数字,并做了简化:

可以看到,有两个并发填充用于并行向两个哈希表中填充来自右侧表的数据。此外,两个并发连接阶段用于连接(以哈希表查找的形式)来自左侧表的数据。

请注意,上述查询管道中的调整大小(resize)阶段用于定义所有填充阶段和所有连接阶段之间的显式连接:所有连接阶段应等待所有填充阶段结束。

接下来,我们将最大线程数设置为4。

./clickhouse client --host ekyyw56ard.us-west-2.aws.clickhouse.cloud --secure --port 9440 --password  --database=imdb_large --query "
EXPLAIN pipeline graph=1, compact=0
SELECT *
FROM actors a
JOIN roles r ON a.id = r.actor_id
SETTINGS max_threads = 4, join_algorithm = 'parallel_hash';" | dot -Tpdf > pipeline.pdf

现在,四个并发填充用于并行向四个哈希表填充来自右侧表的数据。四个并发连接阶段用于连接左侧表中的数据。

测量结果表明,速度提升与并行程度几乎呈线性关系。

 

Grace hash join

哈希算法和并行哈希连接算法速度都很快,但受内存限制。如果右侧表无法放入主内存,ClickHouse就会引发OOM异常。在这种情况下,ClickHouse用户可以牺牲性能,使用合并算法(或部分合并算法),在合并之前将表的数据(或部分数据)排序到外部存储中。

幸运的是,ClickHouse 22.12引入了另一种名为"grace hash"的连接算法,该算法不受限与内存,而是基于哈希表,因此不需要对数据进行任何排序。这克服了(部分)合并算法在性能上的一些挑战。

该算法采用两阶段方法来合并数据。为了适应查询管道,实现方法与经典算法描述略有不同。下图显示了第一阶段:

① 右侧表中的所有数据以块为单位(由 2 个线程并行操作,因为max_threads = 2)以流的方式传输到内存中。通过对每条记录的连接键应用哈希函数,将每个流块中的记录分成 3 个桶(因为 grace_hash_join_initial_buckets = 3)。我们在上图中用橙色、蓝色和绿色勾勒了这一过程。第一个(橙色)数据桶中的记录会填充到内存中的哈希表中。右表中其他两个桶(绿色和蓝色)会通过将它们保存到临时存储中来延迟。

请注意,如果内存中的哈希表增长超过了内存限制(由 max_bytes_in_join 设置),ClickHouse 会动态地增加数据桶的数量,并为每条记录重新计算分配的数据桶。任何不属于当前数据桶的记录都会被刷新并重新分配。

还要注意的是,ClickHouse会将grace_hash_join_itial_buckets的设置值向上舍入到最接近的2的幂次。因此,3会被四舍五入为4,并使用4个初始桶。为了便于阅读,在图中使用了3个桶,与4个桶的内部工作原理没有本质区别。

②左表中的数据由2个线程(max_threads = 2)并行地以流的方式处理,与步骤①中同样的函数应用于每一行的连接键,以确定对应的桶。与第一个数据桶相对应的行会被③连接(因为相应的哈希表在内存中)。其他桶的连接会通过将其保存到临时存储区而延迟。

哈希函数对将步骤①和②中的键,分配到相同值的桶,因此可以有效地分区数据并通过分解来解决问题。

在第二阶段,ClickHouse处理磁盘上的剩余数据桶。剩余的数据桶将按顺序处理。下面两张图对此进行了简要说明。第一张图显示了蓝色数据桶如何首先得到处理。第二张图显示了对最后一个绿色邮筒的处理过程。

①ClickHouse根据右侧表数据为每个数据桶建立哈希表。

同样,如果ClickHouse内存不足,它会动态增加数据桶的数量。

②一旦根据右侧表桶建立了哈希表,ClickHouse就会从相应的左侧表桶传输数据,③完成这对数据的连接。

请注意,在这一阶段,可能会有一些行属于当前表桶之外的另一个表桶,这是因为在动态增加表桶数量之前,这些行已被保存到临时存储区。在这种情况下,ClickHouse会将它们保存到新的实际数据桶中,并进一步处理它们。

对所有剩余的数据桶重复这一过程。

 

支持的join类型

内连接、左外连接。ASOF不支持。

示例:

现在,我们将比较哈希算法和并行哈希算法对同一查询的运行时间和峰值内存消耗。

右侧表非常大的哈希连接:

SELECT *
FROM actors AS a
JOIN roles AS r ON a.id = r.actor_id
FORMAT `Null`
SETTINGS join_algorithm = 'hash';

0 rows in set. Elapsed: 5.038 sec. Processed 101.00 million rows, 3.67 GB (20.05 million rows/s., 727.61 MB/s.)

右侧表非常大的Grace hash连接:

SELECT *
FROM actors AS a
JOIN roles AS r ON a.id = r.actor_id
FORMAT `Null`
SETTINGS join_algorithm = 'grace_hash', grace_hash_join_initial_buckets = 3;

0 rows in set. Elapsed: 13.117 sec. Processed 101.00 million rows, 3.67 GB (7.70 million rows/s., 279.48 MB/s.)

检查一下上面两个运行统计信息:

SELECT
    query,
    formatReadableTimeDelta(query_duration_ms / 1000) AS query_duration,
    formatReadableSize(memory_usage) AS memory_usage,
    formatReadableQuantity(read_rows) AS read_rows,
    formatReadableSize(read_bytes) AS read_data
FROM clusterAllReplicas(default, system.query_log)
WHERE (type = 'QueryFinish') AND hasAll(tables, ['imdb_large.actors', 'imdb_large.roles'])
ORDER BY initial_query_start_time DESC
LIMIT 2
FORMAT Vertical;

Row 1:
──────
query:          SELECT *
                FROM actors AS a
                JOIN roles AS r ON a.id = r.actor_id
                FORMAT `Null`
                SETTINGS join_algorithm = 'grace_hash', grace_hash_join_initial_buckets = 3
query_duration: 13 seconds
memory_usage:   3.72 GiB
read_rows:      101.00 million
read_data:      3.41 GiB

Row 2:
──────
query:          SELECT *
                FROM actors AS a
                JOIN roles AS r ON a.id = r.actor_id
                FORMAT `Null`
                SETTINGS join_algorithm = 'hash'
query_duration: 5 seconds
memory_usage:   8.96 GiB
read_rows:      101.00 million
read_data:      3.41 GiB

不出所料,哈希连接的速度更快。不过,grace hash连接消耗的主内存只有前者峰值的一半。
通过增加grace_hash_join_itial_buckets设置,可以进一步减少哈希连接的主内存消耗。我们通过重新运行grace_hash_join_itial_buckets设置为8的查询来证明这一点:

SELECT *
FROM actors AS a
JOIN roles AS r ON a.id = r.actor_id
FORMAT `Null`
SETTINGS join_algorithm = 'grace_hash', grace_hash_join_initial_buckets = 8;

0 rows in set. Elapsed: 16.366 sec. Processed 101.00 million rows, 3.67 GB (6.17 million rows/s., 224.00 MB/s.)

检查一下运行的统计信息:

SELECT
    query,
    formatReadableTimeDelta(query_duration_ms / 1000) AS query_duration,
    formatReadableSize(memory_usage) AS memory_usage,
    formatReadableQuantity(read_rows) AS read_rows,
    formatReadableSize(read_bytes) AS read_data
FROM clusterAllReplicas(default, system.query_log)
WHERE (type = 'QueryFinish') AND hasAll(tables, ['imdb_large.actors', 'imdb_large.roles'])
ORDER BY initial_query_start_time DESC
LIMIT 2
FORMAT Vertical;

Row 1:
──────
query:          SELECT *
                FROM actors AS a
                JOIN roles AS r ON a.id = r.actor_id
                FORMAT `Null`
                SETTINGS join_algorithm = 'grace_hash', grace_hash_join_initial_buckets = 8
query_duration: 16 seconds
memory_usage:   2.10 GiB
read_rows:      101.00 million
read_data:      3.41 GiB

Row 2:
──────
query:          SELECT *
                FROM actors AS a
                JOIN roles AS r ON a.id = r.actor_id
                FORMAT `Null`
                SETTINGS join_algorithm = 'grace_hash', grace_hash_join_initial_buckets = 3
query_duration: 13 seconds
memory_usage:   3.72 GiB
read_rows:      101.00 million
read_data:      3.41 GiB

运行8个初始桶的grace hash join比运行3个初始桶少消耗大约70%的主内存。在牺牲更多执行时间的前提下,内存消耗量可以通过增加数据桶数量以线性方式减少。

请注意,正如前面提到的和下面演示的那样,ClickHouse总是将grace_hash_join_itial_buckets的设置值向上舍入到最接近的2的幂次。因此,将grace_hash_join_itial_buckets设置为3时运行的查询实际上使用了4个初始桶。

 

查询管道
我们对最大线程数设置为2且grace_hash_join_itial_buckets设置为3的grace hash join查询的查询管道进行分析:

./clickhouse client --host ekyyw56ard.us-west-2.aws.clickhouse.cloud --secure --port 9440 --password  --database=imdb_large --query "
EXPLAIN pipeline graph=1, compact=0
SELECT *
FROM actors AS a
JOIN roles AS r ON a.id = r.actor_id
FORMAT `Null`
SETTINGS max_threads = 2, join_algorithm = 'grace_hash', grace_hash_join_initial_buckets = 3';" | dot -Tpdf > pipeline.pdf

我们看到,①使用两个并行数据流(max_threads=2),将右侧表中的数据流到内存中。我们还看到两个并行填充用于填充内存中的哈希表。另外两个并行阶段②和两个并行连接阶段③用于从左侧表中流式连接数据。延迟阶段表示某些连接阶段被推迟。

不过,我们无法看到查询管道中的桶数量,因为桶的创建是动态的,取决于内存压力,ClickHouse会根据需要动态增加桶的数量。所有数据桶都是在延迟......转换阶段处理的。

为了分析创建和处理的数据桶数量,我们需要通过要求ClickHouse向ClickHouse命令行客户端发送执行期间的跟踪级日志,查看查询的实际执行情况。

我们执行grace hash join查询时,max_threads设置为2,grace_hash_join_initial_buckets值为3(注意 send_logs_level='trace'设置):

./clickhouse client --host ea3kq2u4fm.eu-west-1.aws.clickhouse.cloud --secure --password  --database=imdb_large --send_logs_level='trace' --query "
SELECT *
FROM actors AS a
JOIN roles AS r ON a.id = r.actor_id
FORMAT Null
SETTINGS max_threads = 2, join_algorithm = 'grace_hash', grace_hash_join_initial_buckets = 3;"

    ...
... GraceHashJoin: Initialize 4 buckets
... GraceHashJoin: Joining file bucket 0
    ...
... imdb_large.actors ...: Reading approx. 1000000 rows with 2 streams
    ...
... imdb_large.roles ...: Reading approx. 100000000 rows with 2 streams
    ...
... GraceHashJoin: Joining file bucket 1
... GraceHashJoin: Loaded bucket 1 with 250000(/25000823) rows
    ...
... GraceHashJoin: Joining file bucket 2
... GraceHashJoin: Loaded bucket 2 with 250000(/24996460) rows
    ...
... GraceHashJoin: Joining file bucket 3
... GraceHashJoin: Loaded bucket 3 with 250000(/25000742) rows
    ...
... GraceHashJoin: Finished loading all 4 buckets
    ...

现在我们可以看到创建了4 个(而不是3个)初始桶。我们还可以看到每个表如何使用2个并行流阶段来读取表中的行。两个表的第一个对应桶(上述跟踪日志信息中的桶 0)会被立即连接。

其他3个桶被写入磁盘,随后按顺序加载以进行连接。我们看到,两个表中的100万行和1亿行被平均分配--每个桶分别有25万行和~2500万行。

为了进行比较,我们执行了grace hash join查询,最大线程设置为4,grace_hash_join_itial_buckets值为8:

./clickhouse client --host ea3kq2u4fm.eu-west-1.aws.clickhouse.cloud --secure --password  --database=imdb_large --send_logs_level='trace' --query "
SELECT *
FROM actors AS a
JOIN roles AS r ON a.id = r.actor_id
FORMAT Null
SETTINGS max_threads = 4, join_algorithm = 'grace_hash', grace_hash_join_initial_buckets = 8;"

    ...
... GraceHashJoin: Initialize 8 buckets
... GraceHashJoin: Joining file bucket 0
    ...
... imdb_large.actors ...: Reading approx. 1000000 rows with 4 streams
    ...
... imdb_large.roles ...: Reading approx. 100000000 rows with 4 streams
    ...
...  GraceHashJoin: Joining file bucket 1
...  GraceHashJoin: Loaded bucket 1 with 125000(/12502068) rows
    ...
...  GraceHashJoin: Joining file bucket 2
...  GraceHashJoin: Loaded bucket 2 with 125000(/12498406) rows
    ...
...  GraceHashJoin: Joining file bucket 3
...  GraceHashJoin: Loaded bucket 3 with 125000(/12502699) rows
    ...
...  GraceHashJoin: Joining file bucket 4
...  GraceHashJoin: Loaded bucket 4 with 125000(/12498074) rows
    ...
...  GraceHashJoin: Joining file bucket 5
...  GraceHashJoin: Loaded bucket 5 with 125000(/12498755) rows
    ...
...  GraceHashJoin: Joining file bucket 6
...  GraceHashJoin: Loaded bucket 6 with 125000(/12498054) rows
    ...
...  GraceHashJoin: Joining file bucket 7
...  GraceHashJoin: Loaded bucket 7 with 125000(/12498043) rows
    ...
...  GraceHashJoin: Finished loading all 8 buckets
    ...

可以看到,初始创建了8个桶,每个表使用了4个并行流处理来读取表中的行。

 

总结
本文描述并比较了ClickHouse中基于内存哈希表的3种连接算法。

哈希连接算法速度快,是最通用的算法,支持所有连接类型和严格性设置,但内存哈希表的创建是单线程的,如果右侧表非常大,就会成为瓶颈。

并行哈希连接算法通过同时创建多个哈希表,在右侧表很大的情况下速度更快,但需要更多内存。

Grace哈希连接算法是一种非内存限制版本,它将输入数据分割成多个数据桶,其中一些数据桶先存到到磁盘,然后在内存中按顺序处理。

下图总结了本文章中所有连接查询运行的内存消耗和执行时间(max_threads设置为30,右侧表为大表):