数据量爬到几十 TB 这个量级之后,最容易被拖死的往往不是扫描,也不是聚合,而是两个大表 Join 中间那一次数据重分布。我之前处理过一个场景:一张十二亿行的订单明细,要跟一张三亿行的用户维度表做关联,两张表都不算大,扫描加起来也就几十秒,但整个 Query 跑了六分多钟,Profile 拉开一看,HashJoin 节点的 BytesSent 高得离谱,绝大部分时间都耗在把两张表按 Join Key 重新洗一遍数据上。后来把这两张表改成按同一个键、同样的桶数建表,落到同一批机器上,Join 阶段的网络传输几乎归零,同一条 SQL 降到了四十多秒。
这就是Colocation Join要解决的问题:在建表阶段就把"将来要 Join 的数据"提前摆到同一个位置,让 Join 退化成每个桶在本地做一次内存级匹配,跳过 Shuffle 这一整段开销。它不是什么新奇的优化器黑魔法,本质是一种用建模约束换取运行时性能的交易——代价是你得在建表时就把分桶规则定死,而且后面改起来相当麻烦。
这篇内容适合两类人看:一类是被大表 Join 的 Shuffle 折磨过、想找一条确定性优化路径的数仓开发;另一类是刚接手 StarRocks 集群、看到表属性里写着colocate_with却不太清楚它到底影响什么的人。下面我按"原理—建模—验证—踩坑"的顺序,把这套东西从里到外讲一遍,包括那些文档里不会写、只有真在生产上跑过才会遇到的细节。
1. Colocation Join 到底省掉了哪一段开销
1.1 先算清楚一次普通 Hash Join 的账
要理解 Colocation 的价值,得先把普通 Join 的数据流向拆开看。StarRocks 里两个表做等值 Join,优化器一般会在三种分发方式里选:Broadcast、Shuffle(Partitioned)、Bucket Shuffle。Broadcast 是把小表整个复制到每个节点,适合小表驱动大表;Shuffle 是把两张表都按 Join Key 的哈希值重新分发,保证相同 Key 落到同一个节点;Bucket Shuffle 是中间的折中方案,当 Join Key 包含左表的分桶列时,只把右表按左表的分桶方式搬一次。
真正要命的是 Shuffle。它不是免费的,代价可以拆成四块:数据从本地读出来后的序列化、跨节点网络传输、对端反序列化、写进 Hash Table 之前还要做一次分区缓冲(很多情况下会落盘)。这四块里网络是最贵的一环,也是最不可控的一环。
我拿一个具体数字算给你看。假设左表参与 Join 的列压缩后每行 20 字节,参与 Join 的行数是 100 亿行,那这一侧需要搬运的原始数据就是 200GB。集群 10 个 BE,万兆网卡理论带宽 1.25GB/s,实际跑起来能到 600MB/s 就算健康了。200GB 摊到 10 台机器,平均每台要收发 20GB,纯网络时间就是 30 秒往上。而这还是理想情况——真实场景里 Shuffle 往往和别的算子的网络流量抢带宽,一旦触发落盘,时间还要翻倍。右表即使小一些,加起来也是实打实的几十秒甚至几分钟。
注意:这里算的是"参与 Join 的列",不是整表。很多人用整表大小估算 Shuffle 成本,结果严重高估。真正决定 Shuffle 量的是 Join Key 加被选中的输出列,宽表只取两三个列的时候,Shuffle 量可能只有表大小的十分之一。
Colocation Join 干的事情,就是把这 200GB 的跨节点搬运直接抹掉。因为它保证了相同 Join Key 的数据从建表那一刻起就躺在同一个 BE 的同一个桶里,运行时每个桶各自在本地做 Join,网络传输那一栏直接写零。
1.2 核心约定:数据先摆好,再谈 Join
用个生活化的比喻。假设你要把两摞卡片按编号配对,一摞是订单,一摞是用户。常规做法是把两摞卡片全部打乱,按编号尾号重新分到十张桌子上,然后每张桌子各自配对——这个"重新分"的过程就是 Shuffle。Colocation 的做法是:一开始就规定好,编号尾号是 0 的订单和编号尾号是 0 的用户都放 1 号桌,尾号 1 的都放 2 号桌……建表时按这个规则存,查询时每张桌子自己配自己的,谁也不用动。
落到 StarRocks 的实现上,这个"桌子"就是Colocation Group。一组表共享同一套分桶布局,具体约束有四条,缺一不可:
| 约束项 | 具体要求 | 违反后果 |
|---|---|---|
| 所属组 | 参与 Join 的表必须在同一个 colocate group | 优化器直接放弃 colocate 计划 |
| 分桶列 | 列的数量、类型、顺序必须完全一致 | 建表报错或加入组失败 |
| 分桶数量 | 组内所有表必须相同 | 建表报错 |
| 副本数 | 组内所有表必须相同 | 建表报错 |
还有一条运行时约束容易被忽略:Join 条件里必须包含所有分桶列的等值条件。也就是说,如果表按user_id分桶,那你的 SQL 里必须写ON a.user_id = b.user_id,写成a.user_id = b.uid是可以的(列名不需要一样),但如果你只写了a.order_id = b.order_id,那分桶列没被覆盖,colocate 就不成立。
这四条约束带来的直接后果是:Colocation 是建模阶段的决策,不是查询阶段的开关。你没法对一张已经上线的表临时开启它,只能重建或者改分桶规则。这一点在选型时必须提前想清楚。
1.3 三种 Join 分发方式的取舍边界
很多人一上来就想把所有表都做成 colocate,这是典型的用力过猛。三种方式各有各的适用区间,我整理了一张对照表:
| 分发方式 | 触发条件 | 网络开销 | 适用场景 | 主要限制 |
|---|---|---|---|---|
| Broadcast | 一侧数据量小(默认阈值 100MB 左右) | 小表全量复制到每个节点 | 大表 Join 小维表 | 小表稍大就雪崩,节点数越多越亏 |
| Shuffle | 默认兜底方案 | 两表都按 Key 重分布 | 两个大表 Join,无建模约束 | 开销最大,易落盘 |
| Bucket Shuffle | Join Key 含左表分桶列 | 只搬右表,且按左表分桶 | 左表大、右表中等 | 只优化一侧,仍有搬运 |
| Colocate | 同组 + 分桶列全覆盖 | 接近零 | 两张(或多张)大表稳定关联 | 建模约束强,改动成本高 |
从表里能看出来,Colocation 的位置很明确:它是给"稳定、高频、两张大表"这种关联模式准备的。如果你的 Join 是偶发的、临时的、或者其中一侧永远很小,那 Broadcast 和 Bucket Shuffle 就够用了,强行 colocate 反而给自己套上枷锁。
我个人判断要不要上 Colocation,一般看三个信号:这条 Join 是不是每天跑几十上百次的核心链路;两侧数据量是不是都超过了单节点内存能扛的广播阈值;Join Key 是不是长期稳定不会变。三个都满足才值得动建模。
2. 建模阶段就要定下来的三个参数
2.1 Colocation Group 怎么划分才不别扭
Group 的划分粒度是个需要想清楚的问题。划分太细,每个组只有两张表,后面新表想加进来发现分桶规则对不上,只能重建;划分太粗,把一堆不相干的表塞进同一个组,分桶键被迫妥协,谁都跑不快。
我的经验是按业务分析主线划组,而不是按单条 SQL 划组。举个例子:用户行为分析这条线上,订单明细、支付流水、用户画像、用户标签这几张表,天然都是围绕user_id做关联的,把它们放进同一个组cg_user_analysis,分桶列统一用user_id,桶数统一,这样任意两张表之间的关联都能吃到 colocate。反过来,商品维度的分析线单独建一个组cg_item_analysis,按item_id分桶。
这里有个反直觉的点:同一个组内的表不一定都要互相 Join。组的本质是"共享一套物理布局",只要它们大概率会两两关联,放一起就是合理的。真正要避免的是把 Join Key 完全不同的表硬塞进一个组——那种情况下你只能牺牲其中一部分表的查询效率来迁就分桶列。
具体建表时的写法是在 PROPERTIES 里指定colocate_with:
CREATE TABLE dwd_order_detail_c ( dt DATE NOT NULL COMMENT '分区日期', order_id BIGINT NOT NULL COMMENT '订单ID', user_id BIGINT NOT NULL COMMENT '用户ID', amount DECIMAL(18,2) NOT NULL COMMENT '金额' ) DUPLICATE KEY(dt, order_id, user_id) PARTITION BY RANGE(dt) ( PARTITION p202401 VALUES [('2024-01-01'), ('2024-02-01')), PARTITION p202402 VALUES [('2024-02-01'), ('2024-03-01')) ) DISTRIBUTED BY HASH(user_id) BUCKETS 48 PROPERTIES ( "replication_num" = "3", "colocate_with" = "cg_user_analysis" );注意这里的 key 定义:DUPLICATE KEY(dt, order_id, user_id),而分桶列是user_id。StarRocks 有一条硬性规则——分桶列必须是 Key 列的子集。这是建表阶段最常见的报错来源,很多人想把分桶列设成某个非 Key 列,直接被拒。所以在设计表的 Key 时,就得把将来可能要分桶的列提前放进 Key 里。
2.2 分桶键的选择:均匀性和关联性必须同时满足
分桶键的选择有个双重标准,缺一个都会出问题。
第一重是分布均匀。分桶键的基数要足够大,且取值不能有严重倾斜。如果按一个只有几十种取值的状态字段分桶,那 48 个桶里大半是空的,数据全挤在少数几个桶,colocate 也就失去了意义——本地 Join 变成了几个节点打满、其余节点闲着。像user_id、order_id这种高基数字段是安全选择,而city、status、channel这类低基数字段要坚决避开。
第二重是关联覆盖。分桶键必须是你核心 Join 条件里会用到的列。这一点比第一点更容易翻车。我见过一个案例:表按order_id分桶,设计者觉得订单号基数高、分布均匀,很完美;结果真正的核心查询是order_detail JOIN user_profile ON user_id,分桶列根本没被覆盖,colocate 完全不生效。所以选分桶键的正确顺序是:先列出这张表最频繁的三到五个 Join 条件,找出出现次数最多、且两侧都存在的高基数键,再验证它的数据分布,最后才定下来。
一个实用的检查方法是,建表之前先跑一条 SQL 看候选列的分布:
SELECT COUNT(DISTINCT user_id) AS distinct_cnt, MAX(cnt) / AVG(cnt) AS skew_ratio FROM ( SELECT user_id, COUNT(1) AS cnt FROM staging_order_detail GROUP BY user_id ) t;skew_ratio控制在 3 以内基本可以放心,超过 10 就要考虑加盐或者换键了。这个检查花不了两分钟,但能避免后面几个月的性能问题。
2.3 桶数和副本数:算清楚再填,改起来很痛
桶数的计算没有玄学,核心就一个公式:总数据量 ÷ 单桶目标大小 = 桶数,然后向上取整到 BE 节点数的整数倍。
StarRocks 官方给的单 tablet 数据量建议是压缩后 1GB 到 10GB,实操里我倾向于落在 3GB 到 5GB 这个区间。太小的桶会导致元数据膨胀、导入产生大量小文件;太大的桶则影响并行度和副本修复速度。
拿前面那个订单明细举例。日增 20 亿行,宽表压缩后单行约 100 字节,一天一个分区约 200GB。按单桶 4GB 算,需要 50 个桶;集群 8 个 BE,取 8 的整数倍就是 48 个桶。每个桶约 4.2GB,落在合理区间。
再看同组的用户维表。三亿行,压缩后约 60GB,48 个桶的话每桶 1.25GB,偏小但可以接受。这里就体现出组约束的刚性了:桶数必须跟着大表走,小表只能陪跑。如果小表数据量小到每桶只有几十 MB,说明这个组的分桶数对大表来说是合理的,但对小表确实浪费了一些并行度——这是 Colocation 无法回避的代价,需要你自己权衡收益。
副本数相对简单,一般跟着集群整体规范走,生产环境三副本。要提醒的是,副本数也必须在组内一致。如果某个组的表一开始建成了两副本,后面想改成三副本,得组内所有表一起改,而且这个操作会触发数据重分布。所以建组的时候就把规范定死,别留后患。
3. 从建表到验证:一次完整的落地过程
3.1 建组与入组的实际操作
Colocation Group 不需要单独创建,第一张带colocate_with属性的表建成功,组就自动存在了。往已有组里加表,只要把同样的属性写上、分桶规则对齐即可:
CREATE TABLE dim_user_profile_c ( user_id BIGINT NOT NULL COMMENT '用户ID', city VARCHAR(64) COMMENT '城市', user_level INT COMMENT '等级', reg_time DATETIME COMMENT '注册时间' ) DUPLICATE KEY(user_id) DISTRIBUTED BY HASH(user_id) BUCKETS 48 PROPERTIES ( "replication_num" = "3", "colocate_with" = "cg_user_analysis" );如果分桶数或副本数跟组内已有表对不上,这条 DDL 会直接报错,报错信息通常会提示组内期望的桶数和副本数是多少。这时候不用猜,按提示改就行。
建完之后第一时间确认组状态:
SHOW PROC '/colocation_group';这条命令会列出所有组的 GroupId、GroupName、包含的表、桶数、副本数、分桶列,以及一个非常关键的IsStable字段。IsStable = true才意味着当前所有桶的副本分布完全符合 colocate 要求,查询才有可能命中。如果是false,说明有副本在修复或者数据在均衡,这时候即使 SQL 写得完全正确,优化器也会退化成普通 Shuffle。
想看具体某个组的明细,可以再往下钻一层:
SHOW PROC '/colocation_group/<GroupId>';这里能看到每个 tablet 的分布情况,排查"为什么组不稳定"的时候很有用。
3.2 用 EXPLAIN 确认到底有没有命中
DDL 建成功、组状态正常,不代表你的 SQL 就一定走 colocate,还得验证执行计划。最直接的方式是 EXPLAIN:
EXPLAIN SELECT p.city, COUNT(DISTINCT d.order_id) AS order_cnt, SUM(d.amount) AS gmv FROM dwd_order_detail_c d JOIN dim_user_profile_c p ON d.user_id = p.user_id WHERE d.dt = '2024-01-15' GROUP BY p.city;在 EXPLAIN 结果里找 HASH JOIN 那个节点,重点看两个地方。一是节点属性里是否有标识 colocate 的字段,不同版本打印的字段名不太一样,3.x 的部分版本会直接输出colocate: true,另一些版本则体现在distribution mode或partitions相关属性上,以你手上这个版本的实际输出为准。二是看这个 Join 节点下面有没有额外的 EXCHANGE 子节点——如果 Join 的左右子树直接是 Scan 节点而没有中间的数据交换节点,那基本可以确认是本地 Join;如果中间夹着 EXCHANGE,那就是还在 Shuffle。
提示:拿不准的时候,把 EXPLAIN 和 EXPLAIN VERBOSE 都打一遍,对比两个 Join 节点下的子节点树结构。EXCHANGE 节点的有无比任何字段名都更直观。
还有一种确认方式是看 Join Key 的数据类型。colocate 要求两侧分桶列的类型一致或可无损比较,如果一边是BIGINT一边是VARCHAR,即使能隐式转换,优化器也可能放弃 colocate 计划。这种情况在跨系统同步过来的表上特别常见,建表时把类型对齐能省掉一堆麻烦。
3.3 用 Profile 量化收益,别只凭感觉
EXPLAIN 只能证明"计划走了 colocate",证明不了"真的变快了"。要量化收益,得看 Query Profile。跑完查询后拿到 Profile,找到 HashJoin 节点,关注BytesSent、NetworkTime这一类指标,改造前后对比一下,数字会非常直观。
我做过的一次对比记录如下:
| 指标 | 改造前(Shuffle) | 改造后(Colocate) | 变化 |
|---|---|---|---|
| Query 总耗时 | 6 分 12 秒 | 43 秒 | 下降 88% |
| HashJoin BytesSent | 约 180GB | 接近 0 | 基本消除 |
| 峰值内存占用 | 高,多次触发落盘 | 平稳 | 显著下降 |
| 落盘次数 | 十余次 | 0 | 消除 |
这里有个细节值得说:改造后总耗时下降的幅度(88%)比网络传输下降的幅度还大,原因是 Shuffle 阶段消除之后,原本因为内存压力触发的落盘也一起消失了。Shuffle 的隐性成本往往比显性的网络传输更高,因为它会连锁触发内存溢出和磁盘 IO,这部分开销在监控面板上很难单独归因。
顺便说一句,测收益一定要在相同的并发条件下测。空载环境下 Shuffle 可能只要十几秒,但生产上几十个查询并发的时候,网络和内存的争抢会让这个数字翻好几倍——而 colocate 恰恰是在高并发下收益最明显的方案,因为它的资源消耗本身就低。
3.4 组内变更:改一个,动全组
上线之后最常见的变更需求是扩容。集群从 8 个 BE 扩到 16 个,桶数是不是要跟着调?理论上桶数最好跟 BE 数量保持整数倍关系,但实际上桶数变更的代价非常高——需要组内所有表同步执行,而且会触发全量数据重分布,期间组状态会变成 Unstable,colocate 完全不生效。
所以我的建议是:建组时就把桶数按未来一两年的数据量规划好,宁可初期单桶小一点,也不要频繁改。真要扩容,优先考虑新增分区时单独指定桶数(StarRocks 支持在新增分区时覆盖默认桶数),而不是改整表的分布规则。
如果某张表确实需要退出组,用这条命令:
ALTER TABLE dwd_order_detail_c SET ("colocate_with" = "");退出之后这张表就变成普通表,组内其他表不受影响。反过来,如果一张已有表想加入组,同样用 SET 语法,但前提是它的分桶列、桶数、副本数都跟组对得上,否则会失败。
4. 踩坑实录:为什么我的 Join 没走 Colocate
4.1 六个最常见的失效原因
这套东西的原理不复杂,但实际命中率经常低得让人怀疑人生。我把遇到过的失效原因整理成了一张速查表:
| 现象 | 根因 | 排查动作 | 解决方式 |
|---|---|---|---|
| 组状态 IsStable = false | 副本修复中或数据均衡中 | SHOW PROC '/colocation_group' | 等待修复完成,或手动触发均衡 |
| EXPLAIN 里出现 EXCHANGE | Join 条件未覆盖全部分桶列 | 检查 ON 条件里的等值列 | 补齐分桶列的等值条件 |
| 只有部分查询命中 | 分区裁剪后分桶列丢失 | 对比不同 WHERE 条件的计划 | 确保分桶列在所有分支都被引用 |
| 表结构修改后失效 | 加列或改列导致组属性不一致 | SHOW CREATE TABLE对比 | 重建表或重新对齐组属性 |
| 类型不匹配 | 两侧分桶列类型不同 | 检查DESC输出的列类型 | 统一类型,避免隐式转换 |
| 会话或全局开关关闭 | 相关优化被禁用 | SHOW VARIABLES LIKE '%colocate%' | 恢复对应开关默认值 |
其中第二条是最隐蔽的。举个真实遇到的例子:两张表都按user_id分桶,SQL 写的是ON a.user_id = b.user_id AND a.dt = b.dt,看起来完全正确,但 EXPLAIN 里始终有 EXCHANGE。后来发现是因为a.user_id在 CTE 里被重命名成了uid,外层的 Join 条件变成了a.uid = b.user_id,虽然逻辑等价,但优化器在判定分桶列覆盖时是按物理列走的,重命名导致匹配失败。这种问题光看 SQL 语义看不出来,必须看执行计划。
4.2 组状态不稳定带来的"幽灵问题"
IsStable = false这个状态值得单独拎出来说,因为它造成的现象特别迷惑人。你可能昨天测得好好的 colocate Join,今天突然变慢了,SQL 一个字都没改,表结构也没动,但计划就是退化了。这种情况大概率是后台在做副本修复或者数据均衡。
StarRocks 的副本修复是异步的,某些 BE 宕机恢复、磁盘扩容、或者手动迁移 tablet 之后,副本的物理位置会暂时偏离 colocate 布局。在这个窗口期内,即使逻辑上两张表还在同一个组,实际数据已经不在同一批机器上了,优化器为了保证正确性会退回 Shuffle。
排查方式就是看组状态和 BE 的健康状况。如果IsStable反复在 true 和 false 之间跳,说明集群可能有不稳定的节点在频繁上下线,这时候要优先解决集群稳定性问题,而不是去调 SQL。
注意:在副本修复期间,别急着调整表结构或者分桶规则。很多人看到 colocate 失效就以为是建模问题,动手改表,结果修复完成后发现原本的配置是对的,白白折腾一轮。
4.3 几个只有踩过才知道的细节
第一个细节关于分区表的对齐。两张表都做分区,但分区粒度不同,比如一张按天分区、一张按月分区,这种情况是可以 colocate 的,前提是分桶列都包含在各自的分区列里。实际执行时会做分区级的对齐,一个天分区去匹配对应的月分区。这个机制能用,但我建议尽量不要搞粒度不一致,因为分区裁剪的边界处理容易出意外,尤其是在跨月查询的时候。统一粒度是最省心的做法。
第二个细节关于小表广播的优先级。如果 Join 的一侧数据量小于广播阈值,优化器可能会优先选 Broadcast 而不是 Colocate,哪怕你完全符合 colocate 条件。这在测试环境特别容易误导人——测试环境数据量小,怎么测都是 Broadcast,上了生产才看到 colocate 生效。所以验证 colocate 一定要用接近生产的数据量,或者在测试环境临时把广播阈值调低。
第三个细节是多表关联时的传递性。三张表在同一个组、都按user_id分桶,A JOIN B JOIN C 是能全程 colocate 的,但这个链条要求每一段 Join 都覆盖分桶列。如果中间某个 Join 条件换成了别的键,链条就断了,后面那一段会退化。所以设计多表关联链路时,要保证主干 Join 始终围绕分桶键展开。
5. 上线之后的持续维护
5.1 巡检要做在故障前面
Colocation 最大的风险不是配置错,而是配置对了但悄悄失效。所以巡检要常态化,我一般会把两件事放进日常检查清单。
一是定期看所有组的状态。写个脚本,每天跑一次SHOW PROC '/colocation_group',把IsStable = false的组捞出来,人工确认是正常的副本修复还是异常。这个动作成本极低,但能提前发现很多问题。
二是监控关键查询的 Profile 指标。把核心 Join 链路的BytesSent做成趋势图,正常情况下应该长期贴近零,一旦出现明显抬升,说明 colocate 失效了,这时候再去查是组状态问题还是 SQL 变更导致的。
5.2 什么时候该主动退出 colocate
这一点很多人不愿意接受,但确实是实话:Colocation 不是越用越好,有些场景主动退出来反而更划算。
第一种情况是表的分桶键跟实际查询模式脱节了。业务演进之后,原本围绕user_id的分析逐渐转向item_id,那这张表留在原来的组里就是拖累——它既吃不到 colocate 的收益,还要背负组约束带来的分桶限制。这时候退组,改成自己需要的分桶方式更合理。
第二种情况是组内表的规模差异过大。一张几百 TB 的明细表和一张几 GB 的小表在同一个组,桶数必须按大表来定,小表被切成几十个几 MB 的小桶,元数据压力和导入开销都会上升。小表如果只是偶尔关联,用 Broadcast 反而更合适。
第三种情况是表变成了低频表。Colocation 的价值建立在"高频关联"上,如果一条链路一周才跑一次,省下来的那点 Shuffle 开销远不如建模自由度重要。
判断标准其实很简单,就一句:这张表的核心查询,是不是持续、高频地跟组内其他表按分桶列关联。是,就留着;不是,就退出来。别因为"当初费了很大劲才配好"就舍不得动,建模是要跟着业务走的。
我个人在实际操作中的体会是,Colocation Join 这类优化的真正难点从来不在技术本身,而在于它要求你把性能决策提前到建模阶段。它不像调一个会话参数那样可以随时回滚,一旦定了分桶规则,后面所有的变更都要付出数据重分布的成本。所以在动手之前,值得花半天时间把核心 Join 链路梳理一遍,把未来一年的数据增长量算清楚,把分桶键的分布先跑个 SQL 验证一下。这半天省下来的,可能是后面无数次重建表和半夜排查。
另外还有个小技巧分享:新组上线的时候,先不要急着把核心链路全切过去,挑一条中等重要性的查询做灰度,跑两周,观察组状态和 Profile 指标是否稳定,确认没问题了再迁移主链路。这样万一分桶规则设计有偏差,返工的成本要小得多。