文章目录
- 一、开篇:一个具体的集群和查询
-
- [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 什么时候代价最大?)
- [5.1 `LIMIT 10` 是最后才执行的吗?](#5.1
- 六、优化建议
- 七、总结
在上一篇文章中,我们讨论了分片、副本和无中心架构的概念。很多读者反馈:理论懂了,但"跨分片查询"到底是怎么执行的?为什么
GROUP BY、ORDER BY、LIMIT会有额外的代价?本文将通过一个完整的执行过程,带你走一遍从应用程序发送 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 一站式从入门到实战》系列文章。
在上一篇文章中,我们讨论了分片、副本和无中心架构的概念。很多读者反馈:理论懂了,但"跨分片查询"到底是怎么执行的?为什么