ClickHouse分布式查询优化:深入解析GLOBAL IN与GLOBAL JOIN的实战应用

📅 发布时间:2026/7/5 13:01:20 👁️ 浏览次数:
ClickHouse分布式查询优化:深入解析GLOBAL IN与GLOBAL JOIN的实战应用
1. 从一次“诡异”的查询结果说起大家好我是老张在数据仓库和实时分析领域摸爬滚打了十几年ClickHouse是我近几年用得最顺手的“大杀器”。它快是真的快但分布式环境下的那些“坑”也是真的需要经验去填。今天想和大家深入聊聊两个让很多新手甚至一些老手都容易栽跟头的关键词GLOBAL IN和GLOBAL JOIN。想象一下这个场景你设计了一个漂亮的ClickHouse集群数据按照某种规则分片Shard存储在不同的节点上性能杠杠的。你通过Distributed表通常命名为table_all进行查询一切看起来都很美好。直到有一天你写了一个带IN子查询的语句比如想找出“既满足A条件其ID又出现在B条件结果集里”的数据。在单机测试时结果完全正确可一到生产环境的分布式表上查询返回的数据要么是0要么少得离谱或者查询直接慢到让你怀疑人生。你反复检查业务逻辑确认数据肯定存在但查询就是不对。这时候你很可能就遇到了ClickHouse分布式查询中的经典陷阱——查询放大问题。而GLOBAL修饰符就是ClickHouse官方为你准备的“逃生绳”。但什么时候用、怎么用、用了之后又会带来什么新问题这里面门道可不少。我会结合自己踩过的坑和实战优化经验带你彻底搞懂它。2. 基石先弄明白Distributed表引擎在干什么在跳进GLOBAL IN/ JOIN的细节之前我们必须先统一认知当我们面对一个Distributed表查询时到底发生了什么很多问题理解不透根源就在于对这个过程一知半解。ClickHouse的Distributed表引擎本身不存储任何数据你可以把它理解为一个智能的“查询路由器”或者“代理”。它的核心工作流程可以拆解为三步我画个简单的图在脑子里大家跟着想SQL接收与改写当你向my_table_all发起SELECT ... WHERE ...查询时Distributed表引擎会解析你的SQL。关键的一步来了它会自动将SQL中FROM子句里的my_table_all分布式表名替换成每个分片节点上实际的本地表名比如my_table_local。并发远程查询接着当前接收查询的节点我们叫它协调节点或查询发起节点会根据集群配置并行地向所有包含数据的分片节点发起远程查询请求。这个请求里携带的SQL已经是改写后的、面向本地表的SQL。结果汇聚与返回各个分片节点执行本地查询将结果返回给协调节点。协调节点负责将所有分片的结果集进行合并比如UNION ALL然后返回给客户端。这个过程在大多数简单查询比如SELECT * FROM distributed_table WHERE date2023-10-01中工作得非常完美完全无感。但一旦查询中包含了IN或JOIN这类涉及“第二个数据集”的子句麻烦就开始了。因为“第二个数据集”的处理逻辑会直接影响到第一步的“SQL改写”策略。3. 深入坑底分布式IN查询的三种写法与陷阱光说理论有点干我们直接用一个我简化过的真实案例来演试。假设我们有一张用户行为表user_actions_local核心字段是user_id和action_type。数据在集群的两个分片Shard 1和Shard 2上。我们的业务需求是找出那些既执行过action_typelogin登录又执行过action_typepurchase购买的用户ID数量。逻辑上我们需要先找出所有购买过的用户ID集合然后看哪些登录用户的ID在这个集合里。3.1 第一种写法直接掉坑里新手最自然的写法可能是在IN子句里直接用本地表-- 错误示范 SELECT uniq(user_id) FROM user_actions_all WHERE action_type login AND user_id IN ( SELECT user_id FROM user_actions_local WHERE action_type purchase )执行这个查询返回的结果很可能是0。为什么我们来拆解一下Distributed表引擎的工作协调节点将主查询的user_actions_all改写分别向Shard 1和Shard 2发送查询。对于Shard 1它收到的查询语句被改写成SELECT uniq(user_id) FROM user_actions_local WHERE action_type login AND user_id IN (SELECT user_id FROM user_actions_local WHERE action_type purchase)注意IN子查询里的user_actions_local没有被改写它只在Shard 1本地的user_actions_local表中查找购买用户。Shard 2同理也只在自己的本地表中找购买用户。结果每个分片都只基于自己分片内的数据做关联完全看不到其他分片上的购买用户。如果恰好“登录”和“购买”行为跨了分片那么关联结果就是空的。协调节点汇总两个分片返回的0最终结果自然是0。这就是“查询不全”问题。IN/JOIN的子查询如果只针对本地表那么每个分片都活在“信息孤岛”里无法获得全局视图。3.2 第二种写法引发更大的坑吃一堑长一智我们知道了子查询也要用分布式表来获得全局数据。于是改成-- 有问题的写法 SELECT uniq(user_id) FROM user_actions_all WHERE action_type login AND user_id IN ( SELECT user_id FROM user_actions_all WHERE action_type purchase )这次IN子查询也面向user_actions_all了。执行一下可能能查出正确结果了比如返回 1。但是你有没有想过查询的成本我们来看看这个“完美”查询背后的“恐怖”故事——查询放大。协调节点准备向Shard 1和Shard 2发起主查询。对于发往Shard 1的查询它需要先执行IN子查询。由于子查询对象是user_actions_allShard 1上的Distributed表引擎会再次承担协调节点的角色它会向所有分片包括Shard 1自己和Shard 2发起远程查询获取全局的购买用户列表。Shard 2收到来自Shard 1的对于IN子查询的请求执行并返回结果给Shard 1。Shard 1终于拿到了完整的购买用户列表用它来过滤自己本地的登录用户得出结果返回给最开始的协调节点。同样的事情在Shard 2上完全重演一遍Shard 2作为主查询的执行分片也需要执行IN子查询于是它又向所有分片包括Shard 1和它自己发起一轮远程查询。我们来算笔账2个分片。每个分片执行主查询时其IN子查询会触发2次远程查询向每个分片一次。所以总的远程查询次数是2分片 * 2子查询触发的远程调用 4次。如果集群有N个分片那么这个查询引发的远程查询次数就是N²次分片数量稍微多一点比如10个就会产生100次远程调用网络开销和查询延迟将呈指数级增长。这就是查询放大是分布式查询的性能杀手。3.3 第三种写法GLOBAL IN登场ClickHouse提供了GLOBAL修饰符来专门解决这个“N²爆炸”问题。改造一下上面的语句-- 正确的优化写法 SELECT uniq(user_id) FROM user_actions_all WHERE action_type login AND user_id GLOBAL IN ( SELECT user_id FROM user_actions_all WHERE action_type purchase )只是加了一个GLOBAL关键字执行逻辑就发生了根本变化子查询单独执行并收集协调节点会首先且仅执行一次GLOBAL IN后面的子查询。这个查询是作为一次独立的分布式查询执行的它会从所有分片收集action_type purchase的user_id。创建临时表协调节点将上一步收集到的所有user_id去重后在内存如果数据量大可能溢出到磁盘中创建一个临时表。分发临时表协调节点将这个临时表的数据发送给每一个需要执行主查询的分片节点即Shard 1和Shard 2。每个分片节点上都有了这份完整的全局购买用户列表。并行本地关联各个分片节点收到临时表后主查询的IN操作就变成了纯粹的本地操作——用本地的登录用户ID去和内存里的这份临时表做关联。不再需要向其他分片发起任何远程查询。汇总结果各分片完成计算后将结果返回给协调节点进行汇总。这个过程将远程查询次数从N²降低到了N1次1次收集子查询结果 N次分发临时表和执行主查询。虽然需要额外的网络传输来分发临时表数据但在子查询结果集不是特别巨大的情况下其收益远远高于N²次的网络往返开销。这是我处理分布式IN查询时的首选方案。4. 分布式JOINGLOBAL JOIN的用武之地JOIN操作在分布式环境下会遇到和IN完全相同的问题甚至更复杂因为涉及两张或更多可能都被分片的大表。GLOBAL JOIN的思路与GLOBAL IN一脉相承。假设我们还有一张用户信息表user_info_all也是Distributed表我们想关联查询登录用户的详细信息。没有GLOBAL的JOIN查询放大-- 可能导致查询放大的JOIN SELECT a.user_id, u.user_name FROM user_actions_all AS a GLOBAL ALL INNER JOIN user_info_all AS u ON a.user_id u.user_id WHERE a.action_type login这个查询中user_info_all在JOIN的右侧。如果没有GLOBAL每个处理user_actions的分片都需要向所有分片查询user_info的数据再次引发N²问题。使用GLOBAL JOIN-- 使用GLOBAL JOIN避免放大 SELECT a.user_id, u.user_name FROM user_actions_all AS a GLOBAL ALL INNER JOIN user_info_all AS u ON a.user_id u.user_id WHERE a.action_type login加上GLOBAL后其执行流程如下协调节点单独执行JOIN右侧子查询SELECT ... FROM user_info_all收集完整的右表数据。在协调节点内存中创建右表数据的临时表。将该临时表分发到所有存有user_actions数据的分片节点。每个分片节点在本地将自己的user_actions数据与收到的右表临时数据进行JOIN。协调节点汇总所有分片的JOIN结果。这里有个非常重要的实践细节GLOBAL JOIN只适用于右表。也就是说GLOBAL修饰的是JOIN操作本身其效果是将右表数据“全局化”后分发到左表所在的分片进行本地关联。你不能写a GLOBAL JOIN b而是a JOIN GLOBAL b虽然语法上是a GLOBAL JOIN b但语义上是将b全局化。左表通常是你的事实表数据量大且分片右表是维度表或过滤后的结果集数据量相对较小。5. GLOBAL的代价与替代方案数据本地化看到这里你可能会觉得GLOBAL IN/ JOIN是分布式查询的银弹。但在我多年的实战中发现它并非万能甚至有明显的副作用需要权衡使用。GLOBAL的潜在代价内存与网络压力协调节点需要收集完整的右表或子查询结果集并在内存中构建临时表。如果这个结果集非常大例如上亿条协调节点的内存可能被撑爆导致查询失败。同时巨大的临时表数据通过网络分发给所有分片会带来显著的网络带宽消耗。单点瓶颈整个GLOBAL查询的启动和临时表分发都依赖于协调节点。如果协调节点负载过高或网络不佳会成为性能瓶颈。不适合流式处理GLOBAL操作是“阻塞式”的必须等待子查询完全执行完毕、数据收集齐、分发完成后各个分片才能开始工作。这对于需要低延迟的流式查询不太友好。更优的策略数据本地化Colocation当GLOBAL操作的代价过高时我们就需要考虑从根本上解决问题的方案让关联所需的数据在物理存储上就位于同一个分片。这就是数据本地化思想。具体来说就是确保需要相互JOIN或IN关联的表按照相同的键如user_id进行分片。这样对于同一个user_id它的行为数据和它的信息数据必然被存储在同一台物理节点上。查询时JOIN操作就完全退化成了本地JOIN彻底消除了网络开销和分布式复杂性。如何实现在创建Distributed表时通过cluster配置和分片键sharding_key来保证。例如user_actions表和user_info表都使用user_id作为分片键并属于同一个集群配置。这样相同user_id的记录会被散列到同一个分片编号上。-- 创建分布式表时指定分片键 CREATE TABLE user_actions_all ON CLUSTER my_cluster AS user_actions_local ENGINE Distributed(my_cluster, default, user_actions_local, cityHash64(user_id)); CREATE TABLE user_info_all ON CLUSTER my_cluster AS user_info_local ENGINE Distributed(my_cluster, default, user_info_local, cityHash64(user_id));数据本地化的优缺点优点查询性能极高是分布式JOIN的终极优化方案。完全避免了GLOBAL带来的内存、网络和单点问题。缺点灵活性降低数据一旦按某种键分片查询模式最好与之匹配。如果经常需要按其他维度进行跨分片关联优势就不复存在。数据重分布困难ClickHouse目前不提供在线数据重分片Reshard的能力。如果某个分片键的数据持续增长导致节点不均衡数据倾斜或者业务逻辑变更需要更改分片键处理起来会非常麻烦通常需要重建整个集群的数据成本很高。这是选择此方案前必须慎重评估的风险点。6. 实战决策指南如何选择正确的策略面对一个分布式IN或JOIN查询我通常会遵循以下决策路径这也是我多年实践总结出来的经验评估右表/子查询结果集大小如果结果集很小例如万级以下或不超过几百MB毫不犹豫地使用GLOBAL IN/GLOBAL JOIN。这是最简单、最直接的优化手段收益明显副作用可控。你可以通过EXPLAIN估算子查询结果或者在实际测试中观察内存使用。如果结果集很大千万级及以上GLOBAL操作可能导致协调节点OOM或网络拥堵。此时需要慎重。审视数据模型与查询模式如果关联是高频、核心的查询路径比如用户行为关联用户画像这种几乎每次分析都要用到的JOIN。那么强烈建议在设计之初就采用数据本地化方案。虽然初期设计复杂但这是“一劳永逸”的性能保障能为业务长期稳定运行打下基础。如果关联是低频、临时的查询或者关联键不固定那么使用GLOBAL更灵活。也可以考虑将大右表提前物化成广播表ENGINE Broadcast但广播表有同步延迟和维护成本。考虑最终的备用方案极端情况下如果数据无法本地化GLOBAL又扛不住可能需要退而求其次考虑业务上是否允许近似查询或预聚合。比如将需要关联的维度数据提前聚合到事实表中用空间换时间。或者评估是否能用应用层多次查询来代替一次复杂的分布式JOIN。虽然逻辑复杂但有时在可控的查询放大次数下反而比一个巨大的GLOBAL操作更稳定。在我经历的一个用户增长分析项目中核心查询需要将用户启动事件表与用户渠道属性表进行JOIN。最初使用了GLOBAL JOIN在渠道属性表膨胀到千万级后查询频繁超时。后来我们下决心改造将两张表按user_id进行了数据本地化重分布。改造过程确实花了些功夫但改造后核心查询的延迟从分钟级降到了秒级以内集群的整体稳定性也大幅提升。这个案例让我深刻体会到在分布式系统中前期合理的数据分布设计远比后期在查询语法上绞尽脑汁的优化来得重要。GLOBAL是一把好用的应急扳手但良好的数据模型设计才是保证引擎长久高效运转的基石。