ClickHouse 跨分片查询深度解析:一条 SQL 的完整执行之旅

文章目录

  • 一、开篇:一个具体的集群和查询
    • [1.1 集群配置](#1.1 集群配置)
    • [1.2 执行的查询](#1.2 执行的查询)
  • 二、完整执行流程图
  • 三、各阶段详细拆解
    • [3.1 阶段1:查询到达](#3.1 阶段1:查询到达)
    • [3.2 阶段2:SQL 解析与分发](#3.2 阶段2:SQL 解析与分发)
    • [3.3 阶段3:子查询发送](#3.3 阶段3:子查询发送)
    • [3.4 阶段4:各分片本地执行](#3.4 阶段4:各分片本地执行)
    • [3.5 阶段5:部分结果返回](#3.5 阶段5:部分结果返回)
    • [3.6 阶段6:协调节点合并](#3.6 阶段6:协调节点合并)
    • [3.7 阶段7:最终结果返回](#3.7 阶段7:最终结果返回)
  • 四、代价分析
    • [4.1 核心代价](#4.1 核心代价)
    • [4.2 与单机对比](#4.2 与单机对比)
  • 五、关键问题详解
    • [5.1 `LIMIT 10` 是最后才执行的吗?](#5.1 LIMIT 10 是最后才执行的吗?)
    • [5.2 ClickHouse 的优化:分布式 LIMIT](#5.2 ClickHouse 的优化:分布式 LIMIT)
    • [5.3 什么时候代价最大?](#5.3 什么时候代价最大?)
  • 六、优化建议
  • 七、总结

在上一篇文章中,我们讨论了分片、副本和无中心架构的概念。很多读者反馈:理论懂了,但"跨分片查询"到底是怎么执行的?为什么 GROUP BYORDER BYLIMIT 会有额外的代价?本文将通过一个完整的执行过程,带你走一遍从应用程序发送 SQL 到获取最终结果的每一步。


一、开篇:一个具体的集群和查询

1.1 集群配置

假设有一个 ClickHouse 集群,配置如下:

配置项
分片数 2
副本数 1(为简化理解,先忽略副本)
分片键 user_id % 2
节点 节点A(分片1),节点B(分片2)

数据分布

分片 节点 数据
分片1 节点A user_id 为偶数的订单(1亿行)
分片2 节点B user_id 为奇数的订单(1亿行)

1.2 执行的查询

sql 复制代码
SELECT user_id, COUNT(*) AS order_count
FROM distributed_orders
GROUP BY user_id
ORDER BY order_count DESC
LIMIT 10;

目标:找出订单数最多的前 10 个用户。


二、完整执行流程图

节点B (分片2, user_id奇数) 节点A (分片1, user_id偶数) 协调节点 (接收到查询的节点) 应用程序 节点B (分片2, user_id奇数) 节点A (分片1, user_id偶数) 协调节点 (接收到查询的节点) 应用程序 2. 解析SQL,识别分布式表 确定需要查询的分片 5. 节点A执行本地查询 扫描分片1数据 按user_id聚合 6. 节点B执行本地查询 扫描分片2数据 按user_id聚合 9. 协调节点合并两个结果集 重新GROUP BY和ORDER BY 取TOP 10 1. 发送SQL查询 3. 发送子查询 SELECT user_id, COUNT(*) ... GROUP BY user_id 4. 发送相同子查询 7. 返回部分结果 (user_id偶数: 各用户的订单数) 8. 返回部分结果 (user_id奇数: 各用户的订单数) 10. 返回最终10条结果


三、各阶段详细拆解

3.1 阶段1:查询到达

应用程序连接到 ClickHouse 的任意一个节点。假设连接到了节点A。

关键 :节点A既是数据节点(存分片1的数据),又是本次查询的协调节点

python 复制代码
# Python 示例
import clickhouse_connect

client = clickhouse_connect.get_client(
    host='node-a',   # 连接到节点A
    port=8123,
    user='default',
    password=''
)

result = client.query("""
    SELECT user_id, COUNT(*) AS order_count
    FROM distributed_orders
    GROUP BY user_id
    ORDER BY order_count DESC
    LIMIT 10
""")

3.2 阶段2:SQL 解析与分发

协调节点(节点A)做以下判断:

步骤 说明
1 发现查询的是分布式表distributed_orders
2 查看分布式表定义:ENGINE = Distributed(cluster, db, local_orders, user_id % 2)
3 确定需要查询的分片:所有分片 (因为 GROUP BY user_id 需要全局数据)
4 为每个分片生成子查询(与原始 SQL 基本相同)
sql 复制代码
-- 发给节点A的子查询
SELECT user_id, COUNT(*) FROM local_orders GROUP BY user_id

-- 发给节点B的子查询(完全相同)
SELECT user_id, COUNT(*) FROM local_orders GROUP BY user_id

3.3 阶段3:子查询发送

协调节点向每个分片的其中一个副本发送子查询。

注意

  • 协调节点不会给自己发子查询,它自己就是节点A,会直接执行本地查询
  • 副本选择策略由 load_balancing 参数控制(random、in_order、first_or_random 等)

3.4 阶段4:各分片本地执行

节点A(user_id 偶数) 执行:

sql 复制代码
SELECT user_id, COUNT(*) FROM local_orders GROUP BY user_id

结果(示例):

user_id order_count
100002 156
100004 142
100006 138
... ...

节点B(user_id 奇数) 执行相同的查询:

user_id order_count
100001 162
100003 148
100005 135
... ...

关键点 :如果数据量很大(如本例 1 亿行),每个节点返回的是聚合后的结果(每个 user_id 一行),而不是原始数据。假设用户 ID 从 1 到 1 亿,每个分片约有 5000 万行结果。

3.5 阶段5:部分结果返回

各分片节点将本地聚合结果发送回协调节点。

网络传输量

  • 节点A → 协调节点:约 5000 万行
  • 节点B → 协调节点:约 5000 万行

3.6 阶段6:协调节点合并

协调节点现在有两个部分结果集(各约 5000 万行)。

合并步骤

步骤 操作 说明
1 合并 GROUP BY 将两个结果集按 user_id 合并。由于 user_id 不重复(偶数 vs 奇数),直接拼接即可
2 重新 ORDER BY order_count 对所有行排序(1 亿行排序)
3 取 LIMIT 10 取前 10 行

协调节点内部执行的合并逻辑(伪代码)

sql 复制代码
SELECT user_id, SUM(order_count) AS total_count
FROM (
    SELECT user_id, order_count FROM nodeA_results
    UNION ALL
    SELECT user_id, order_count FROM nodeB_results
) AS combined
GROUP BY user_id
ORDER BY total_count DESC
LIMIT 10;

最终结果

user_id order_count
100001 162
100002 156
100003 148
100004 142
100005 135
... ...

3.7 阶段7:最终结果返回

协调节点将最终的 10 行结果返回给应用程序。


四、代价分析

4.1 核心代价

代价类型 具体表现 本例中的数据
网络传输 各分片将结果传给协调节点 2 × 5000 万行
协调节点内存 需要暂存所有分片的结果 约 1 亿行临时数据
合并排序 对全部数据进行全局排序 1 亿行排序
短板等待 协调节点必须等待最慢的分片 如果节点B慢,整体就慢

4.2 与单机对比

操作 单机(无分片) 跨分片(2节点)
扫描数据 全表扫描(1亿行) 两节点并行扫描(各5000万行)✅ 更快
聚合 GROUP BY 一次聚合 两节点分别聚合,协调节点再合并
排序 ORDER BY 一次排序 两节点分别排序,协调节点再归并
网络开销 两次网络传输(发子查询 + 收结果)
等待时间 单节点执行时间 受最慢节点影响

五、关键问题详解

5.1 LIMIT 10 是最后才执行的吗?

是的LIMIT 10 是在所有分片的结果都返回、合并、排序之后才执行的。

为什么不能先在各个分片上 LIMIT 10

因为全局的 TOP 10 不一定是每个分片的 TOP 10。

反例 :假设 ORDER BY create_time 取最早的 10 条记录,这 10 条可能全在节点A。如果每个节点只返回自己的 TOP 10,节点B 返回的 10 条根本不是全局 TOP 10,数据就错了。

5.2 ClickHouse 的优化:分布式 LIMIT

ClickHouse 做了一个巧妙的优化,不会真的把所有数据拉到协调节点再排序。

优化后的执行流程

步骤 说明
1 协调节点向每个分片发送:ORDER BY ... LIMIT N(N 比 10 大,如 1000)
2 每个分片本地排序,取本地 TOP 1000
3 协调节点收集各分片的 TOP 1000(共 2000 行)
4 协调节点对这 2000 行全局排序,取最终 TOP 10

为什么 N 不能直接等于 10?因为全局 TOP 10 可能不在某个分片的本地 TOP 10 中。N 越大越准确,ClickHouse 会根据数据分布动态调整 N。

5.3 什么时候代价最大?

查询类型 示例 是否跨分片 代价
点查 WHERE user_id = 12345 否(分片键精准命中一个分片) 极小
范围查 WHERE user_id BETWEEN 1000 AND 2000 可能跨分片 中等
聚合查 GROUP BY user_id 是(需要全局聚合)
全表排序 ORDER BY create_time LIMIT 10 是(需要全局排序) 很大

六、优化建议

优化手段 原理 效果
合理设计分片键 让常用查询只命中一个分片 避免跨分片查询
使用两阶段聚合 各节点先聚合 → 协调节点再聚合 减少网络传输
GLOBAL IN 替代 IN 将子查询结果分发到所有节点 避免多次网络往返
调整分布式 LIMIT 参数 控制各分片返回的数据量 平衡准确性和性能

七、总结

你关心的问题 答案
跨分片查询的完整流程是什么? 协调节点分发子查询 → 各分片本地执行 → 返回部分结果 → 协调节点合并 → 返回最终结果
代价主要来自哪里? 网络传输、协调节点内存、全局排序、等待最慢分片
LIMIT 是什么时候执行的? 最终合并排序之后,但 ClickHouse 会优化为"各分片多返回一些"
什么查询代价最小? 点查(分片键精准命中一个分片)
什么查询代价最大? 需要全局 GROUP BY + ORDER BY + LIMIT 的查询

如需深入了解 ClickHouse 的分片策略、分布式表原理、查询优化等内容,请持续关注本专栏《ClickHouse 一站式从入门到实战》系列文章。

相关推荐
海南java第二人3 天前
ClickHouse 基础概念面试通关指南:列式存储、TraceId与高频考点全解析
clickhouse·面试
海南java第二人3 天前
ClickHouse 自然语言统一查询:让数据对话成为现实
网络·数据库·clickhouse
海南java第二人4 天前
ClickHouse 部署模式完全指南:从单机到分布式集群的生产级选型
分布式·clickhouse
Altruiste7 天前
minikube 搭clickhouse 集群
clickhouse·kubernetes
zandy10117 天前
HENGSHI SENSE加速引擎架构深度解析:MPP列存与ClickHouse物化视图实战
clickhouse·架构·企业级bi·mpp列存
*勇往直前*7 天前
unbutu安装clickhouse,并且远程连接,使用教程,原理
clickhouse
StarRocks_labs9 天前
KaptureCX 大规模实时分析架构演进:基于 RisingWave 与 StarRocks 的最佳实践
starrocks·sql·clickhouse·ai赋能·kapture
l1t10 天前
DeepSeek总结的pg_clickhouse v0.3.0的新特性
clickhouse·postgresql