在SQL查询引擎处理GROUP BY配合SUM、COUNT、MAX等聚合函数时,底层存在两种典型的执行路径:单阶段聚合与多阶段聚合。理解它们如何调度算子、怎样搬运数据,是分析慢查询和阅读执行计划的基础。

一、单阶段聚合的执行机制
单阶段聚合是最直观的实现方式。执行引擎在扫描完数据或者接收完上游输入后,直接在一个算子内维护聚合状态,并输出最终分组结果。以PostgreSQL风格的HashAggregate为例,它在内存中建立哈希表,键为分组列,值为累加状态,每来一行就更新对应条目,扫描结束即得到结果。
这种路径的优点是逻辑简单、延迟低,没有额外的数据重分布。但当查询涉及分布式节点时,如果先在各个分片本地扫描,再必须把全部原始明细发往一个汇总节点做聚合,网络与单点内存压力就会非常突出。下面的代码展示了一个简单单阶段聚合的伪逻辑:
// 单阶段聚合:接收所有行,本地一次性聚合
Map<String, Long> aggMap = new HashMap<>();
for (Row row : allRowsFromNetwork) {
String groupKey = row.getGroup();
long val = row.getValue();
aggMap.put(groupKey, aggMap.getOrDefault(groupKey, 0L) + val);
}
// 直接输出 aggMap 作为最终结果
从优缺点来看,单阶段聚合在单机小数据量时效率很高,但放到集群环境,若优化器没有做本地预聚合,就会出现“全量shuffle”。此时单个汇总节点容易成为瓶颈,且内存放不下时会落盘甚至报错。
二、多阶段聚合的执行机制
多阶段聚合通常分为本地聚合(Partial Agg)和全局聚合(Final Agg)两步。各个计算节点先对本地数据进行第一轮哈希聚合,大幅压缩行数,然后把缩小后的中间结果按分组键重分布到汇总节点,再做第二轮合并。Spark、Flink以及主流MPP数据库都采用这种思路。
在执行计划里,你常会看到类似HashAgg(局部)和MergeAgg或Final HashAgg的节点。局部阶段输出的是“部分求和”而非明细,例如三台机器分别对各自分片算出每个组的SUM,再发给汇总机相加。代码示例如下:
# 阶段一:本地预聚合
local_map = {}
for row in local_scan():
k = row.group_col
local_map[k] = local_map.get(k, 0) + row.val_col
# 按分组键 shuffle 到不同节点
send_to_partition(local_map)
# 阶段二:全局合并
global_map = {}
for partial in receive_partitions():
for k, v in partial.items():
global_map[k] = global_map.get(k, 0) + v
多阶段聚合显著降低了网络传输量,也避免了单点处理全部明细。代价是引入了额外的算子调度与一次数据重分布,当分组基数极低(例如只有个位数分组)时,本地聚合收益有限,多一轮调度反而略微增加开销。
三、执行路径的选择与优化器决策
数据库优化器会基于统计信息决定走单阶段还是多阶段。如果表很小、或分组键基数极小,优化器可能认为直接单阶段更划算;若表大且分布跨节点,通常插入本地预聚合。但统计信息不准时,就会选错路径,造成性能悬崖。
作为开发者,可以通过查看EXPLAIN观察是否存在“Partial”或“Local”聚合节点。如果大表聚合却只见单一天花板节点,可考虑改写SQL、加分布式 hint,或调大本地聚合内存。以下SQL用于观察计划:
EXPLAIN ANALYZE SELECT user_id, COUNT(*) AS cnt FROM orders GROUP BY user_id;
表中常见算子含义可用下表对照:
| 计划节点 | 所属阶段 | 作用 |
|---|---|---|
| HashAggregate | 可能单阶段或局部 | 基于哈希表做分组累加 |
| MergeAppend / Final Agg | 全局阶段 | 合并各节点部分结果 |
| Exchange | 中间重分布 | 按分组键网络重发 |
掌握这些差异后,在写跨节点大表报表SQL时,应尽量避免在聚合前做不必要的列展开,保证优化器能安全下推局部聚合,从而稳定利用多阶段路径。
四、实践中的避坑建议
一个常见误区是认为“GROUP BY一定会多阶段聚合”。实际上有些函数在语义上不可局部合并,比如求中位数、数组去重计数近似场景,引擎可能退化为单阶段搬运明细。此时需要确认函数是否支持partial聚合。
另外,在UDF中写自定义聚合时,若未实现merge接口,框架也只能走单阶段。下面伪代码展示了一个正确支持多阶段的UDF结构:
public class MySum implements Aggregator {
// 局部累加
public State add(State s, long v) {
s.sum += v;
return s;
}
// 全局合并两个局部状态
public State merge(State s1, State s2) {
s1.sum += s2.sum;
return s1;
}
}
总之,单阶段与多阶段聚合并非简单优劣关系,而是执行路径在资源与传输之间的权衡。结合执行计划与实际数据分布来审视,才能写出高效的聚合查询。