推出 ClickHouse Cloud 中的多阶段分布式查询执行

本文字数:10395;估计阅读时间:26分钟

作者:Alexander Gololobov

TL;DR

ClickHouse Cloud 的多阶段分布式执行 (Multi-stage distributed execution) 为单查询跨多节点扩展提供了新途径。它在不同执行阶段之间重新分区中间数据,从而消除了大规模连接和高基数聚合中的关键瓶颈。

TPC-H 的早期测试结果表明,对于连接密集型查询,性能提升高达 3.4 倍,同时聚合扩展能力接近线性:在 8 个节点上的运行速度比单个节点快 7.4 倍。

跨多节点扩展单查询

ClickHouse 始终能够将单个查询扩展到多个节点。在无共享部署 (shared-nothing deployments) 中,用户通常通过物理分片 (physical sharding) 和 Distributed 表引擎来实现这一点。而在 ClickHouse Cloud 中,并行副本 (parallel replicas) 则为共享存储 (shared storage) 带来了查询内扩展 (intra-query scaling) 能力。

尽管这些机制对于许多分析型查询表现良好,但它们并非现代 PB 级工作负载 (PB-scale workloads) 的最终解决方案。虽然它们能够将工作分发到多个节点,但无法在不同执行阶段 (execution stages) 之间自由地重新分区中间结果。这限制了 ClickHouse 在高基数聚合 (high-cardinality aggregations),尤其是大规模连接 (large joins) 方面的扩展能力。

多阶段分布式查询执行 (Multi-stage distributed query execution) 是向前迈出的重要一步。它为 ClickHouse Cloud 提供了一种新的方式,能够在所有可用节点的 CPU 和内存上并行化单个查询,同时避免了以往执行模型的瓶颈。

在本文中,我们将介绍 ClickHouse 查询执行模型 (query execution model) 的新扩展,并详细讲解其工作原理。我们以多表连接 (multi-table join) 作为示例,因为连接是扩展难度最大的分析型工作负载 (analytical workloads) 之一。但该机制的应用范围远不止于此:它是 ClickHouse Cloud 中分布式查询执行的一个全新基础。

在我们深入探讨新机制 (new mechanics) 之前,让我们先回顾一下之前的方案,并分析为什么它们不足以应对现代 PB 级工作负载。

为什么现有分布式执行模式不足

现有的分布式执行模式虽然有用,但对于 PB 级工作负载而言,其弹性不足。

在无共享的开源部署中,ClickHouse 通过在节点间物理 分片 数据,并通过 Distributed 表 查询这些分片来扩展查询。每个节点处理其本地数据片段,随后由请求方合并结果。

这种方法虽然可行,但其容量与分片布局 (shard layout) 紧密耦合。

瓶颈:容量受限于分片布局

增加计算资源并不能自动加快单个查询的速度。大型表必须首先重新分布到更多的分片上。

跨物理分片表进行大型 JOIN 操作时,会暴露出第二个限制。只有当匹配的行位于同一台机器上时,JOIN 操作才能正常工作。在使用 Distributed JOIN 时,每个节点会维护其本地的左表数据,从其他节点获取缺失的右表分片,构建完整的右表哈希表,然后将本地 JOIN 结果返回给请求方。

GLOBAL JOIN 通过只计算一次右表数据并将其广播到每个节点,从而减少了多对多的网络往返。

然而,核心问题依然存在:大型右表数据仍然需要在整个集群中进行复制。

瓶颈:大型右表数据被复制到各处

Distributed JOIN 和 GLOBAL JOIN 处理网络流量的方式不同,但两者都要求每个分片与完整的右表数据进行 JOIN 操作。

ClickHouse Cloud 通过采用 shared storage 消除了物理分片问题。任何节点都可以访问相同的表数据,并且 parallel replicas 使得多个节点能够参与单个查询。节点可以即时添加或移除,无需进行数据复制或重新分发。

这使得 ClickHouse Cloud 中的 intra-query scaling 变得更具弹性。然而,parallel replicas 仍存在结构性限制:它们可以在副本之间分摊工作,但无法在执行阶段之间自由地重新分区中间数据。

这主要体现在以下两个方面。

首先,JOIN 操作。在单个节点上,ClickHouse 可以 并行执行其默认哈希 JOIN 策略的两端:它会根据 JOIN 键将行分区到多个哈希表中,因此构建 (build) 和探测 (probe) 阶段的工作都可以在 CPU 核心上并行运行。在使用并行副本 (parallel replicas) 时,每个节点内部的情况也同样适用。

但问题在于更高层面。在多个节点之间,如果要将构建侧 (build side) 本身进行划分,则需要一个 shuffle 阶段,该阶段在节点间根据 JOIN 键重新分区两个输入数据。但并行副本并不具备这种机制。次优方案是在 主索引剪枝 (primary-index pruning) 后,将左侧的读取范围进行分发。这使得探测侧 (probe side) 的工作可以在节点间并行进行,但这些范围并未按照 JOIN 键进行分区。左侧范围中的某一行可能与右侧表中任意位置的行匹配,因此,每个节点在探测其本地数据分片之前,仍然需要完整的右侧表数据来构建其本地哈希表。

瓶颈:构建侧无法横向扩展

左侧的探测工作在节点间分摊,但构建侧却未被分摊。每个节点仍需从完整的右侧表数据构建相同的哈希表,这导致构建步骤在集群中被重复执行,而非并行分摊。

其次是聚合操作 (Aggregations)。节点可以并行地在本地进行扫描和聚合操作。但如果缺少按照 GROUP BY 键进行的 shuffle 操作,ClickHouse 无法保证所有具有相同 GROUP BY 键的行最终都会汇集到同一个节点上。

瓶颈:最终聚合仍是单节点

部分聚合结果必须由一个协调器节点进行合并。对于高基数 (high-cardinality) 的 GROUP BY 查询,最终的合并操作将受限于单个节点的 CPU 和内存资源,而非整个集群的资源。

这两个问题都有相同的根本原因:执行阶段之间缺少通用的中间数据重分区机制。而这正是多阶段分布式执行 (multi-stage distributed execution) 所要解决并提供的能力。

引入多阶段分布式执行

多阶段分布式执行补充了缺失的关键原语 (primitive):它使得 ClickHouse Cloud 能够在查询执行期间在节点之间移动中间数据。

ClickHouse 不再将查询作为一次性分布式扇出(distributed fan-out)和最终合并(final merge)来执行,而是将查询计划(query plan)拆分为在 worker 节点上并行运行的多个阶段。在这些阶段之间,交换操作符(exchange operator)负责将中间结果转换为下一个阶段所需的格式。

例如,数据可以按照连接键(join key)进行混洗(shuffle),从而确保每个 worker 节点都能接收到两个连接输入中匹配的数据片段。数据也可以按照 GROUP BY 键进行混洗,这样每个 worker 节点就能拥有完整的群组数据。小规模输入可以广播给所有 worker 节点,而最终结果则可以由协调器(coordinator)进行汇聚。

消除旧有瓶颈:数据可在不同阶段间自由流转

大型连接(join)不再要求每个节点都构建完整的右侧哈希表。高基数(high-cardinality)聚合也不再需要单个协调器(coordinator)来合并所有部分分组。

核心抽象是交换操作符(exchange operator),它是并行查询执行中一个众所周知的构建模块,最初由 Volcano 系统引入,并被 Teradata、Greenplum 等 MPP (Massively Parallel Processing) 数据库以及 SQL Server 等系统广泛采用。

交换操作符在查询计划的不同阶段之间重新分配数据。多阶段分布式执行主要使用以下三种交换类型:

  1. GatherExchange (N-to-1):将 worker 节点的输出发送给协调器(coordinator),通常位于查询计划的顶层,用于生成最终结果。

  2. ShuffleExchange (M-to-N):根据某个键(例如连接键或 GROUP BY 键)对行数据进行重新分区。这种机制确保了每个 worker 节点都能获得下一个操作所需的一个完整且不重叠的数据片段。

  3. BroadcastExchange (1-to-N):将小规模输入数据复制到每个 worker 节点。当连接操作中的一侧数据量足够小,可以经济高效地复制时,这种方式非常有用。

此外,还有 ScatterExchange,它将行数据随机地分散到不同的 worker 节点。

这些是在抽象层面上的机制。要理解它们为何如此重要,最简单的方法是跟踪一个查询在其各个阶段的执行过程。

一个分析型 JOIN 查询如何避开旧有瓶颈

我们用一个类似 TPC-H 的查询把问题具体化。这个查询会同时触发上一节提到的两个瓶颈:一个很大的连接端不应该被复制到每个工作节点,而聚合也不应该退化为单节点的最终合并。

该查询计算每个国家的总出货收入:它将 lineitem 表与 supplier 表连接,再将结果与小型 nation 表连接,然后按 n_name 分组,并按 revenue 排序。

复制代码
SELECT n_name, sum(l_extendedprice) AS revenue
FROM lineitem
JOIN supplier ON l_suppkey = s_suppkey
JOIN nation ON s_nationkey = n_nationkey
WHERE l_shipdate >= '1994-01-01' AND l_shipdate < '1995-01-01'
GROUP BY n_name
ORDER BY revenue DESC;

通过 EXPLAIN 命令查看的分布式执行计划包含一个 BroadcastExchange、两个 ShuffleExchange 和一个 GatherExchange:

复制代码
┌─explain──────────────────────────────────────────────────┐
│ Output: n_name, sum(l_extendedprice)                     │
│                                                          │
│ GatherExchange (sorted by (sum(l_extendedprice) DESC))   │
│ └──Sorting (Sorting for ORDER BY)                        │
│    └──Aggregating                                        │
│       └──ShuffleExchange (by hash([n_name]))             │
│          └──JoinLogical                                  │
│             ├──ShuffleExchange (by hash([l_suppkey]))    │
│             │  └──ReadFromMergeTree (sf100.lineitem)     │
│             └──ShuffleExchange (by hash([s_suppkey]))    │
│                └──JoinLogical                            │
│                   ├──ReadFromMergeTree (sf100.supplier)  │
│                   └──BroadcastExchange                   │
│                      └──ReadFromMergeTree (sf100.nation) │
└──────────────────────────────────────────────────────────┘

自下而上地看,该计划首先构建小型 supplier ⋈ nation 连接:nation 表被广播,supplier 表被读取,每个 worker 生成一个经过扩展的 supplier ⋈ nation 数据侧。然后,该扩展侧会根据 s_suppkey 进行重新分区,同时 lineitem 表被读取并根据 l_suppkey 进行重新分区,从而使匹配的行能够分配到同一个 worker 上。接着,连接后的行会根据 n_name 进行混洗以完成聚合,最终排序的结果由协调器收集。

接下来,我们逐步分析这些步骤。

步骤 1:将 supplier 表与 nation 表连接

ClickHouse 首先将小型的 nation 表广播到每个 worker,并构建一个本地的小型 nation 哈希表。

每个 worker 接着读取其对应的 supplier 数据分片...

...并探查(probe)该本地哈希表。

结果是一个经过扩展的 supplier ⋈ nation 数据侧。

此时尚未进行数据混洗(shuffle):每个 worker 仍然保留其原始 supplier 数据分片中的行。

步骤 2:将 lineitem 行与扩展后的 supplier 行共置(Co-locate)

接下来,ClickHouse 准备进行更大型的连接操作:lineitem ⋈ (suppliernation)。

Workers 首先读取 lineitem 的数据分片...

...然后连接的两侧都根据 supplier 键进行重新分区:lineitem 表依据 l_suppkey,而扩展后的 supplier ⋈ nation 行则依据 s_suppkey

经过混洗后,每个 worker 都拥有一个不相交的 supplier 键桶,其中包含来自两侧的匹配行,并且步骤 1 中经过扩展的 supplier ⋈ nation 行成为构建侧(build side)。

移除先前瓶颈:无需完整构建侧副本

此前,每个节点都需要在内存中存储连接(join)的完整右侧。经过数据混洗(shuffle)后,每个工作节点(worker node)只拥有一个不相交的 supplier-key bucket,因此只需构建其负责部分的哈希表(hash table)。

步骤 3:在每个 supplier-key bucket 内进行本地连接

数据混洗后,每个工作节点都会拥有一个 supplier-key bucket。该 bucket 中包含了连接所需的两侧数据:匹配的 lineitem 行和经过连接处理的 supplier ⋈ nation 行。

现在,每个工作节点都可以通过探测其桶内局部哈希表(hash table)来执行本地连接。这意味着无需任何工作节点存储完整的构建侧(build side)数据,也无需为此次连接进行额外的网络数据交换。

步骤 4:根据 GROUP BY 键进行数据混洗,以执行最终聚合

连接的输出仍然是按 supplier key 分区的,而非按 n_name 分区。因此,同一个国家(nation)可能会出现在多个工作节点上。ClickHouse 会根据 GROUP BYn_name 重新混洗已连接的行,从而确保每个工作节点都拥有完整的数据组(group),并能够独立计算 sum(l_extendedprice)

移除先前瓶颈:无需单节点最终聚合合并

此前,各个节点可以在本地生成部分数据组,但同一个 GROUP BY 键可能会出现在多个节点上,因此需要一个协调器(coordinator)来合并所有这些部分状态。在按 GROUP BY 键进行数据混洗后,每个工作节点都拥有完整的数据组,并可以在本地完成其键的最终聚合计算。

在本例中,n_name 只有 25 个不同的值,因此最终的合并操作会很小。但对于高基数(high-cardinality)的 GROUP BY 操作,通过分组键(grouping key)进行数据混洗,可以有效避免单协调器合并的瓶颈。我们将在文章末尾再次探讨这种规划上的权衡。

步骤 5:本地排序并收集最终结果

每个工作节点根据收入对其聚合后的结果进行排序。GatherExchange(第 3 行所示)负责在协调器处汇集所有工作节点已排序的结果,从而生成最终输出。

避免了之前的瓶颈:协调器 (coordinator) 只收集最终行

协调器 (coordinator) 仍然接收查询结果,但那些开销较大的工作已在各个工作节点 (worker) 上完成。它收集已排序、已聚合的行;它不负责合并大型部分组或构建大型连接哈希表。

上述示例展示了逻辑数据流向:数据在这里进行混洗 (shuffle),在那里进行广播 (broadcast),最后进行汇聚 (gather)。而在底层,ClickHouse Cloud 需要一个传输层 (transport layer),能够高效地在工作节点之间移动这些中间数据块 (intermediate blocks)。

数据如何在阶段之间移动?

数据交换 (exchanges) 有两种数据传输方式。

默认方式是流式交换 (streaming exchange) 。工作节点使用自定义二进制协议 (custom binary protocol) 通过 TCP 直接将数据块发送给其他工作节点。数据一旦生成即开始传输:一个读取 lineitem 的工作节点可以立即开始将数据块发送到 ShuffleExchange,而接收工作节点无需等待全部输入,即可开始消费这些数据。换句话说,数据交换是管道化 (pipelined) 的,而非"先一次性写入所有数据,再一次性读取所有数据"的模式。

第二种模式是持久化交换 (persisted exchange)。ClickHouse 不会直接在工作节点之间发送中间数据块,而是将它们写入共享对象存储 (shared object storage)。这对于未来的故障恢复 (fault recovery) 以及当查询超出集群内存限制时,将中间结果溢写 (spilling) 到存储非常有用。

流式交换 (streaming exchange) 针对快速交互式查询 (interactive queries) 进行了优化,并作为默认设置。如果一个工作节点发生故障,查询将失败,客户端会重试。对于此类工作负载,重新运行查询通常比对每一次数据交换进行检查点 (checkpointing) 操作的成本更低。

为何 ClickHouse Cloud 能实现这一点

多阶段分布式执行 (Multi-stage distributed execution) 依赖于工作节点的可互换性 (interchangeable)。一个阶段只有在工作节点能够访问其所需数据和元数据 (metadata) 的情况下,才能在该工作节点上运行。

共享存储使得工作节点可互换

ClickHouse Cloud 已具备这一基础。表数据存储在共享对象存储 (shared object storage) 中,并且每个节点都能访问读取数据所需的元数据。因此,协调器可以在集群中动态分配各个阶段:任何工作节点都可以扫描表数据、接收经过混洗的行、构建其负责的哈希表部分、聚合其分配的分组,或对其本地结果进行排序。

数据混洗 (Shuffle) 提升了内存利用率

这也提升了内存利用率。当 ShuffleExchange 将一个大型连接任务分区到 8 个工作节点时,每个工作节点大约接收 1/8 的行数据,并构建约 1/8 的哈希表。这意味着原本在单个节点上需要 16 GiB 内存的连接,现在可以在 8 个节点上,每个工作节点仅使用约 2 GiB 内存。

共享存储可避免部分广播

共享存储还为未来的优化创造了可能性。对于小型表,工作节点甚至无需通过网络接收广播;它可以直接从对象存储读取表数据,并将其保留在本地 SSD 缓存中以供后续读取。这种方式对于 nationsupplier 等维度表特别有用,因为本地缓存读取的成本可能低于通过交换层广播表的成本。

迈向无状态工作节点

我们的长期目标是实现完全无状态的工作节点:这些节点能够按需启动,承接查询任务,从共享存储中读取所需数据,并在任务完成后自动消失。整个过程无需固定的所有权或手动数据放置。多阶段分布式执行正是迈向这一模式的关键一步。

单节点查询表现如何?

ClickHouse 的单节点执行路径保持不变。列式 MergeTree 存储、向量化执行和高效的流水线并行仍然是其查询性能的基石。

多阶段分布式执行是一种额外的、可选的执行路径,专为那些能从多节点扩展中获益的查询而设计。它扩展了 ClickHouse 的执行模型,但并不会取代原有的单节点引擎。

多阶段分布式查询执行的 TPC-H 基准测试结果

TPC-H 是业界标准的分析查询处理基准测试。它包含 22 个查询,涵盖 8 张表,从简单的扫描到复杂的多表连接,旨在模拟真实的决策支持工作负载。

我们以 100 倍规模因子(约 100GB 数据)运行了此基准测试,各表行数如下:

• lineitem (600M 行)

• orders (150M)

• partsupp (80M)

• part (20M)

• supplier (1M)

• nation (25)

我们在 ClickHouse Cloud 预发布环境的机器上运行了此基准测试,机器配置为 ARM (Graviton) 架构,8 核,32 GB 内存。我们使用的是 ClickHouse 的 SharedMergeTree 存储引擎和服务器版本 26.2.1.261。

下表展示了在 1 个节点(作为基线)和采用多阶段分布式查询执行的 8 个节点上运行各项查询的结果。我们对每个查询运行三次,并取其中表现最佳的时间。

查询 1 节点 8 节点 加速比 备注
Q01 14.36s 1.94s 7.4x 全表扫描 + 聚合,近线性扩展
Q02 1.33s 2.31s 0.6x 运行时过滤器尚未完全支持
Q03 3.67s 1.27s 2.9x 3 表连接
Q04 3.13s 0.74s 4.2x EXISTS 子查询作为连接
Q05 6.16s 2.31s 2.7x 6 表连接
Q06 0.65s 0.16s 4.1x 单表扫描
Q07 3.21s 1.24s 2.6x 6 表连接
Q08 5.61s 2.65s 2.1x 8 表连接
Q09 15.42s 4.60s 3.4x 6 表连接
Q10 5.90s 2.39s 2.5x 4 表连接
Q11 1.04s 0.58s 1.8x 3 表连接
Q12 2.45s 0.81s 3.0x 2 表连接
Q13 5.18s 1.56s 3.3x 2 表连接,两级聚合
Q14 0.49s 0.21s 2.3x 2 表连接
Q15 0.07s 0.07s 1.0x 已足够快
Q16 1.12s 0.58s 1.9x 3 表连接
Q17 5.99s 2.88s 2.1x 2 表连接 + 子查询
Q18 16.07s 16.32s 1.0x EXISTS 子查询未被基于规则的规划器分布式执行
Q19 8.09s 1.78s 4.5x 2 表连接
Q20 1.54s 1.10s 1.4x 4 表连接
Q21 14.83s 8.77s 1.7x 包含 EXISTS/NOT EXISTS 的 4 表连接
Q22 1.31s 0.38s 3.4x 2 表连接
总计 117.6s 54.7s 2.1x

Q02 为何变慢?

分布式模式下尚未完全支持某些单节点优化,例如运行时过滤器 (runtime filters,特别是 Bloom filter 在连接操作中的谓词下推)。Q02 的性能因此出现下降。

哪些场景扩展性良好?

扫描密集型查询实现了近线性扩展。

Q01 (6 亿行数据的全表扫描 + 聚合) 在 8 节点上实现了 7.4 倍的加速。

这类任务几乎完全是读取和聚合操作,其工作量可以均匀地分配到各个工作节点上,同时保持最小的数据交换开销。

多表连接查询也实现了良好的扩展性 (2-5 倍)。

Q19 (4.5x), Q04 (4.2x), Q06 (4.1x), Q09 (3.4x), Q22 (3.4x), Q13 (3.3x), Q12 (3.0x), Q03 (2.9x)。

对于这些查询,虽然存在显著的数据混洗开销(因为每一次数据交换都涉及数据序列化、网络传输和反序列化),但与并行化的连接计算相比,其占比相对较小。

哪些方面可以采用更智能的执行计划?

基于规则的策略对大多数查询都适用,但仍有部分执行计划并非最优。

对于 Q08,系统对连接操作的两侧都进行了数据混洗,但其中一侧在过滤后仅有 134K 行数据。如果采用广播 (broadcast) 方式,则可以避免对 6 亿行数据进行不必要的重新混洗。

Q18 的 EXISTS 子查询限制了并行性。像 supplier (1M 行) 这样的小表会通过网络进行混洗,尽管每个工作进程都能直接从共享对象存储中读取它们。

这些限制并非执行引擎的根本性问题。引擎能够执行任何给定的计划,关键在于为其提供哪种计划。

后续展望

我们正在开发一个用于多阶段分布式查询执行 (Multi-stage distributed query execution) 的基于成本的优化器 (Cost-based optimizer),预计这将进一步提升查询性能。

其中一个重要方向是自动选择合适的聚合策略。某些查询通过按 GROUP BY 键进行数据混洗,从而确保每个工作进程负责完整的组,这种方式能带来更好的性能;而另一些查询则更适合采用局部部分聚合 (local partial aggregation) 策略,随后进行最终合并 (final merge)。基于成本的优化器能够根据基数 (cardinality)、数据大小和集群拓扑结构在这两种策略之间做出最佳选择。

敬请期待后续文章。

ClickHouse 是面向 AI 时代打造的高性能实时分析数据库,能够以极致性能处理海量数据分析任务。凭借高并发、低延迟和云原生架构,ClickHouse 广泛应用于可观测性、数据仓库、实时分析及 AI 数据基础设施等场景。我们致力于帮助企业在公有云平台上构建安全、弹性且高性价比的实时分析与 AI 数据平台,加速释放数据价值,推动智能化创新与数字化转型。目前,Trip.com、DiDi、Meta、Sony、Netflix、Deutsche Bank、Sierra、Cloudflare 等全球领先企业均在使用 ClickHouse 支撑其关键业务和数据分析平台。

相关推荐
BD_Marathon10 小时前
spark.sql报错
大数据·分布式·spark
长不胖的路人甲19 小时前
RabbitMQ 死信队列 DLQ
分布式·rabbitmq
可乐ea19 小时前
【Redis八股|第8篇】Redis 分布式锁原理与 Redisson 使用
数据库·redis·分布式·面试题·redis八股
逸Y 仙X21 小时前
Spark部署
大数据·分布式·spark
BD_Marathon1 天前
什么是Spark
大数据·分布式·spark
xian_wwq1 天前
【学习笔记】分布式推理:TP / PP / EP / CP 并行策略详解(20/35)
笔记·分布式·学习·ai
记忆停留w1 天前
Celery+Redis 分布式异步任务队列工程落地业务逻辑
大数据·人工智能·redis·分布式·缓存·架构·wpf
LitchiCheng1 天前
DGX Spark部署ComfyUI(一键安装脚本)
大数据·分布式·spark
千逐681 天前
鸿蒙实战:ArkData 数据持久化管理全景解析——从 Preferences 到分布式数据同步
分布式·华为·harmonyos