PostgreSQL 的并行聚合长期采用 Partial/Finalize 路径:多个 worker 各自构建局部哈希表,再由 leader 重新合并聚合状态。这个设计简单、稳定,也符合 PostgreSQL 的多进程执行模型,但当输入行数很多、分组键复杂、最终分组数量接近输入规模时,Finalize 阶段可能重新做大量哈希和比较工作。
一种看似直接的改进是:让所有 worker 共享一张哈希表,直接把聚合结果写进去。相关研究表明,在线程模型中,这种方案可能接近甚至超过按组重分区的方案。但把它放进 PostgreSQL 后,真正的问题并不只有 LWLock 竞争。进程模型、动态共享内存、by-reference 聚合状态、表达式执行路径和数据倾斜,都会改变结论。
为什么当前并行聚合可能变慢
可以用一个典型查询观察问题:
SELECT
key_a,
key_b,
key_c,
key_d,
count(*) AS row_count,
sum(amount) AS total_amount
FROM fact_events
GROUP BY key_a, key_b, key_c, key_d;
当输入有数百万行、输出有数十万组时,Partial 聚合并没有显著压缩数据。每个 worker 产生大量局部状态,Gather 之后,Finalize 节点还要再次对分组键执行哈希、比较,并重新计算聚合。
如果分组键包含多个变长字段,哈希本身也可能很贵。以十三个变长字段作为分组键时,worker 会计算一次哈希,Finalize 阶段又可能对相同字段重复处理。并行进程的启动、调度和进程间数据传递,则进一步增加了固定成本。
实际排查时,不要只看扫描节点。可以这样获取计划和运行时统计:
SET max_parallel_workers_per_gather = 8;
SET work_mem = '512MB';
SET track_io_timing = on;
EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS, SUMMARY)
SELECT key_a, key_b, key_c, key_d,
count(*) AS row_count,
sum(amount) AS total_amount
FROM fact_events
GROUP BY key_a, key_b, key_c, key_d;
重点查看 Partial HashAggregate、Gather、Finalize HashAggregate 的耗时,以及 worker 之间是否存在明显的不均衡。扫描只花几百毫秒,并不代表整个并行计划有效;Finalize 阶段可能才是主要瓶颈。
共享哈希表的吸引力与边界
并行聚合大致有几种组织方式:
- 按分组键把输入行路由到不同 worker,每个 worker 独立完成聚合。
- 各 worker 先做 Partial 聚合,再执行 Finalize 合并。
- 所有 worker 更新一张共享哈希表。
- 各 worker 先局部聚合,再把已分组的状态重新路由后合并。
- 局部累积和共享表结合,必要时批量刷新。
按组路由的优点是每个组只由一个 worker 处理,不需要 Finalize 阶段重新合并。但在 PostgreSQL 的独立进程模型中,数据倾斜很难处理:某个热门分组可能几乎全部落到同一个 worker,其他 worker 提前结束,整体性能仍由最慢的 worker 决定。
共享表则可以避免 Finalize 阶段,并降低重复存储局部哈希表所需的内存。对于分布均匀、分组数量大、聚合状态简单的负载,它具有明显吸引力。
但共享表必须处理并发的“查找并更新”操作。Parallel Hash Join 可以把构建和读取分成不同阶段,因此共享哈希表的并发要求相对简单。聚合不同:查找结果可能是插入新组,也可能是更新旧组,两者发生在同一条执行路径上。
从“锁下查找”到更多隐藏成本
研究中常见的优化是把“查找分组”和“更新状态”分开,用 ticketing 或类似机制减少查找过程持有重锁的时间。这个方向可以缓解最大问题之一,但在 PostgreSQL 中并不能自动消除其他成本。
1. PostgreSQL 使用进程,而不是共享指针的线程
PostgreSQL worker 是独立进程。共享聚合状态必须位于 Dynamic Shared Memory Area 中,不能直接使用普通指针、palloc、MemoryContext 或 repalloc。聚合函数原本可以在普通内存中改变中间状态的大小,但共享内存中的状态不能直接沿用这套契约。
因此,by-reference 状态需要额外的复制和生命周期管理。例如某些字符串、数组或内部状态在 transition function 中扩容时,需要先从共享区域复制到本地内存,调用函数后再复制回共享区域,或者重新分配 DSA 对象。这些复制操作会削弱共享表的收益。
2. 内存记账不再天然精确
对于固定大小的 by-value 状态,可以按共享内存块进行分配和记账。一个 worker 申请一块固定大小的 chunk,在其中连续放置多个记录,最后批量释放。
可变大小的 by-reference 状态则不同:它可能单独增长、缩小和释放,不能简单套用只增不减的 chunk allocator。一个可行的原型做法是让 worker 在本地累计内存变化,达到一定阈值后再发布到共享计数器,但这会带来估算误差,也会影响何时进入 spilling 模式。
3. 表达式执行可能被锁路径切开
普通聚合可以通过 ExecBuildAggTrans() 把过滤、参数计算和 transition 调用组织成一个解释器或 JIT 编译的执行程序。共享表路径中,参数通常需要在拿锁前计算,状态更新则要在拿锁后完成,原来的完整 microprogram 不容易直接复用。
结果是,一部分任意表达式可能落在临界区附近甚至锁内执行。即使是内置类型,某些比较或类型处理也可能访问系统目录或缓冲区,从而把一个看似很短的锁区变成不可预测的工作。
这说明“查找移出锁”只是优化的一部分。锁本身有成本,锁保护的代码长度、表达式执行方式以及内存分配路径,同样决定最终吞吐量。
原型实现中哪些组件可以复用
共享并行聚合并不意味着所有基础设施都要重新设计。可以复用的部分包括:
SharedTuplestore以及已有的 spilling 机制。- Dynamic Shared Memory 和 DSA。
- 临时文件管理。
- 并行节点的初始化、重初始化和关闭流程。
- 类似 Parallel Hash Join 的 chunked allocation 思路。
- wait event 和 LWLock tranche,用于区分不同类型的等待。
不过,共享聚合的内存边界与 Hash Join 不同。Hash Join 的内存规模更多与元组和桶相关;聚合的内存主要由分组数量决定,而分组数量不一定与输入行数成正比。因此,不能简单固定一个最大 batch 大小。
更合理的做法是:共享表处理当前 batch 时,一旦触及内存上限,已有分组继续原地更新,新分组写入子分区。之后可以借助 HyperLogLog 一类的基数估计,判断下一次重分区是否真的能显著减少分组数量。
性能结果告诉了我们什么
在分布均匀的测试中,共享并行聚合相较当前 PostgreSQL 的串行或 Partial/Finalize 方案可以获得明显加速,固定大小状态通常优于需要复制的 by-reference 状态。测试中还观察到,多聚合列并不会必然破坏收益,在一定范围内,单组聚合项从少量增加到十几个,性能甚至可能继续改善。
数据倾斜会迅速改变结果。可以用下面的 SQL 生成一个简单的热点键分布,用于改造测试脚本或验证统计信息是否能够反映倾斜:
CREATE TEMP TABLE skewed_events AS
SELECT
CASE
WHEN i <= 200000 THEN 0
ELSE i % 1000000
END AS group_id,
i::bigint AS amount
FROM generate_series(1, 20000000) AS s(i);
ANALYZE skewed_events;
EXPLAIN (ANALYZE, BUFFERS, SUMMARY)
SELECT group_id, count(*), sum(amount)
FROM skewed_events
GROUP BY group_id;
这个例子让一个分组获得约 1% 的行。把 200000 改为 2000000 或 10000000,可以模拟更强的热点。测试时应在同一台机器、相同内存设置和相同 worker 数量下比较耗时,不要混合不同硬件平台的绝对时间。
经验上,热点分组占比很低时,共享表更容易获益;当单个热点占据很大比例时,锁竞争和串行状态更新会吞掉并行收益,甚至让方案严重退化。因此,优化器需要利用 MCV 统计信息估计最大值频率,并据此选择策略和 worker 数量,而不是把共享表当作所有聚合的默认路径。
更现实的落地方向:限制适用范围
如果目标是把原型推进到可维护的内核实现,一个务实方向是先限制为固定宽度、by-value、单次原子更新即可完成的聚合状态,例如整数或浮点数的 count、sum。这类状态可以避开复杂的 DSA 对象生命周期,也有机会完全移除 LWLock。
这种限制还可能恢复高效的表达式解释器路径,因为状态更新不再需要保护任意复杂的可变对象。代价是 numeric、数组、字符串以及带 Internal 状态的聚合暂时无法覆盖,功能范围明显缩小。
共享表还可能适用于没有并行聚合支持的其他算子,例如 SetOp,以及没有聚合函数的 SELECT DISTINCT。在这些场景中,状态更新更简单,by-reference transition state 和复杂表达式锁区的问题可能不会以同样的强度出现。Memoize 也有类似吸引力,但其 LRU 淘汰机制会引入额外的并发设计工作。
给 PostgreSQL 工程师的检查清单
采用或评估共享并行聚合时,可以按这几个问题逐项确认:
- 分组键的 MCV 频率是否足够低,是否存在明显 heavy hitter?
- Partial/Finalize 阶段是否真的在重复处理大量分组键?
- 聚合状态是固定大小的 by-value,还是需要复制和扩容的 by-reference?
- 表达式、过滤条件和 transition function 是否会在锁区附近执行复杂工作?
- 共享表的内存计数是否能及时触发 spilling?
- NUMA 节点之间的共享访问是否会成为新的瓶颈?
- 优化器是否有足够统计信息来选择策略和 worker 数量?
- 是否需要同时保留串行、分区式和共享式三种路径?
共享全局哈希表并非被简单地证明“可行”或“不可行”。在均匀分布和简单状态下,它能减少 Finalize 工作、降低内存占用,并带来可观加速;在 PostgreSQL 的多进程架构中,它又必须支付共享内存管理、锁、表达式执行和状态复制的综合成本。更可能成功的路线不是强行让一种策略覆盖所有聚合,而是建立基于数据倾斜、状态类型、内存压力和算子特征的成本模型,并优先优化那些能够真正从共享状态中获益的窄场景。