并行聚合与 Parallel Append(Parallel Aggregation and Parallel Append)
聚合与多路合并是数据仓库场景里最耗 CPU 的两类操作。PostgreSQL 把前者拆成「每个 worker 先做局部聚合,再由 leader 汇总」的两阶段(实际输出上是三段节点),把后者交给 Parallel Append 把进程分散到多个子计划上同时推进。理解这两套机制,才能解释清楚「为什么分组数一多、并行聚合就不划算了」以及「为什么分区表的并行扫描能用上、索引却没并行」。
本节从计划结构入手,讲清楚并行聚合的三段节点、Parallel Append 与普通 Append 的分派差异、两者的适用边界,以及如何用 EXPLAIN 逐一验证。内容偏机制与计划形状,参数本身的默认值请回看上一节。
前置知识
阅读本节前,建议先了解:并行计划的操作与诊断
基础概念
并行聚合的三段结构
PostgreSQL 的并行聚合(parallel aggregation)不是「把聚合拆给多个进程各做一部分然后相加」这么简单——因为聚合有分组、有状态、有去重。官方的做法是分两个阶段,在计划输出上体现为三段节点:
Finalize Aggregate ← ③ leader 上执行:把各进程的局部结果再聚合一次
│
Gather / Gather Merge ← ② 汇合点:把 worker 的局部结果搬到 leader
│
Partial Aggregate ← ① 每个 worker 各执行一遍:只聚合自己看到的那部分行
│
Parallel Seq Scan ← 驱动扫描(必须是并行感知的)对应关系:
| 阶段 | 节点名 | 执行者 |
|---|---|---|
| 第一阶段 | Partial Aggregate | 每个 worker(以及参与干活的 leader)各一次 |
| 汇合 | Gather 或 Gather Merge | leader |
| 第二阶段 | Finalize Aggregate | leader |
如果聚合策略是哈希聚合或排序聚合,节点名会带上具体算法:
| 第一阶段节点 | 第二阶段节点 |
|---|---|
Partial HashAggregate | Finalize HashAggregate |
Partial GroupAggregate | Finalize GroupAggregate |
Partial Aggregate | Finalize Aggregate |
注意:leader 与 worker 不必使用同一种聚合节点。例如 worker 常用 Partial HashAggregate,而 leader 端可能选 Finalize GroupAggregate(因为它需要输出有序结果)。
为什么必须有 Partial 阶段
一个直觉上的错误做法是:让每个 worker 都完整执行 Aggregate,然后 leader 把结果拼起来。这行不通,原因有两个:
- 分组会重复。每个 worker 都见过「区域 A」,各自产出一行「区域 A 的汇总值」。直接拼接会得到多行区域 A。
- 计数类聚合不能简单相加(除非有 combine 函数)。
avg需要同时携带sum与count,array_agg需要拼接数组。
所以每个 worker 只能输出「局部状态」(partial state),由 leader 用 combine 函数把它们合并成最终值。这正是「Partial / Finalize」这对命名的由来。
一个最小可读的并行聚合计划
EXPLAIN (ANALYZE, VERBOSE)
SELECT region_id, count(*), sum(amount) FROM fact_orders GROUP BY region_id; Finalize GroupAggregate (cost=1000.00..120500.10 rows=240 width=20)
Group Key: region_id
-> Gather Merge (cost=1000.00..119960.10 rows=960 width=20)
Workers Planned: 4
-> Partial GroupAggregate (cost=0.00..119360.10 rows=240 width=20)
Group Key: region_id
-> Parallel Seq Scan on fact_orders (cost=0.00..109000.00 rows=5000000 width=12)读这个计划要注意:Gather Merge 上方没有 Sort——因为 Gather Merge 自己完成了归并排序,代价是每个 worker 的 Partial GroupAggregate 必须先输出有序的部分结果。
Append 与 Parallel Append 的分派差异
Append 是「把多个子计划的结果拼成一个结果集」的节点,最常见的来源是 UNION ALL 和分区表扫描。在并行计划里,它可以有两种形态:
普通 Append(在并行计划中) | Parallel Append | |
|---|---|---|
| 进程如何分配 | 所有进程按相同顺序依次执行子计划:一起做完第一个,再一起转到第二个 | 执行器把参与进程尽量均匀分散到各个子计划上,多个子计划同时被推进 |
| 子计划类型 | 只能是 partial 子计划 | 可以是 partial,也可以是 non-partial |
| non-partial 子计划 | 不允许 | 只由单个进程执行(多进程执行会产生重复结果) |
| 主要好处 | 结构简单 | 避免争用;避免为「永远不会执行到的子计划」支付启动成本 |
关键在于最后一行:Parallel Append 允许没有高效 partial 计划的场景也获得粗粒度并行。官方举的例子是:对分区表的查询只能用「不支持并行扫描的索引」,此时规划器会选 Parallel Append 包住若干个普通 Index Scan——每个索引扫描必须由单个进程执行完,但不同分区的扫描可以同时由不同进程做。
两类「并行」不要混淆
分区表场景下有两个名字相近、含义完全不同的优化,常被搞混:
| 名称 | 含义 | 控制参数 | 默认值 |
|---|---|---|---|
| 并行聚合 | 多个 worker 各做局部聚合,leader 汇总 | 由并行框架与聚合的并行安全性决定 | — |
| Partitionwise Aggregate | 把聚合下推到各个分区分别完成,避免合并后再聚合 | enable_partitionwise_aggregate | off |
| Partitionwise Join | 分区表按对应分区两两连接,避免全量连接 | enable_partitionwise_join | off |
Parallel Append | 把进程分散到各分区的子计划上同时扫描 | enable_parallel_append | on |
后两项默认关闭:它们属于「分区智能」优化,开启后计划可能更优,也可能因结构复杂而变差,必须实测。
语法与用法
观察聚合是否并行
-- 静态形状:是否有 Partial/Finalize 这两个前缀
EXPLAIN SELECT region_id, count(*) FROM fact_orders GROUP BY region_id;
-- 实际执行:每个进程各做了多少局部聚合
EXPLAIN (ANALYZE, VERBOSE)
SELECT region_id, count(*) FROM fact_orders GROUP BY region_id;判断口诀:看到 Partial 就说明这段在 worker 里跑,看到 Finalize 就说明这段在 leader 上跑。两个都没有、只有一个 Aggregate/HashAggregate,说明聚合是串行完成的。
观察 Parallel Append
-- 分区表的 UNION ALL 或跨分区查询
EXPLAIN (VERBOSE)
SELECT * FROM fact_orders_2026_q1
UNION ALL
SELECT * FROM fact_orders_2026_q2;在并行计划中,你会看到 Append(箭头前缀 -> 的多子节点)或 Parallel Append。区分方式就是节点名本身:
Append ← 普通 Append:多进程按相同顺序推进
-> Index Scan on fact_orders_2026_q1
-> Index Scan on fact_orders_2026_q2
Parallel Append ← Parallel Append:进程分散到各子计划
-> Parallel Seq Scan on fact_orders_2026_q1
-> Parallel Seq Scan on fact_orders_2026_q2开关某个形状做对照
-- 关掉 Parallel Append,观察是否退化为普通 Append 或串行
SET enable_parallel_append = off;
EXPLAIN (ANALYZE) SELECT count(*) FROM fact_orders WHERE created_at >= '2026-01-01';
RESET enable_parallel_append;
-- 关掉共享哈希表(Parallel Hash),观察 Parallel Hash Join 是否退化
SET enable_parallel_hash = off;
EXPLAIN (ANALYZE) SELECT ... ;
RESET enable_parallel_hash;试探分区智能是否有效
-- 分区智能默认关闭;临时打开做对照实验
SET enable_partitionwise_aggregate = on;
EXPLAIN (ANALYZE)
SELECT region_id, count(*) FROM fact_orders GROUP BY region_id;
RESET enable_partitionwise_aggregate;检查聚合函数的可并行性
-- 看聚合是否有 combine 函数(procombinefunc 不为 0 才可能并行)
SELECT p.oid::regprocedure AS agg,
p.proparallel,
p.procombinefunc::regproc AS combine,
p.proserialfunc::regproc AS serial
FROM pg_proc p
WHERE p.proname IN ('sum', 'avg', 'array_agg', 'string_agg', 'count')
AND p.prokind = 'a'
ORDER BY p.proname;用 JSON 输出做自动化比对
-- 便于脚本判断两次执行里 Partial/Finalize 节点是否存在
EXPLAIN (ANALYZE, VERBOSE, FORMAT JSON)
SELECT region_id, count(*) FROM fact_orders GROUP BY region_id;详细说明
并行聚合的完整前置条件清单
官方文档对「什么情况下不支持并行聚合」给了一份明确清单,逐条对照就能定位问题:
| 条件 | 说明 |
|---|---|
| 每个聚合必须并行安全 | 即 PARALLEL SAFE,不能是 RESTRICTED 或 UNSAFE |
| 每个聚合必须有 combine 函数 | CREATE AGGREGATE 里的 COMBINEFUNC;没有就无法在 leader 端合并 |
转移状态为 internal 时必须有序列化/反序列化函数 | SERIALFUNC / DESERIALFUNC,因为 internal 类型无法跨进程传递 |
聚合调用中不能含 DISTINCT | 如 count(DISTINCT x),会导致无法并行聚合 |
聚合调用中不能含 ORDER BY | 如 string_agg(x, ',' ORDER BY y) |
| 不能是有序集聚合 | ordered-set aggregate,如 percentile_cont |
查询不能涉及 GROUPING SETS | |
| 查询中所有连接也必须在并行部分内 | 只要有一个连接在并行范围外,整个并行聚合就不成立 |
最后一条特别容易被忽略:它意味着「并行聚合」不是独立成立的,必须与「并行扫描 + 并行连接」配套。如果某个连接因为内部表是 PARALLEL RESTRICTED 而被迫放到 leader 上,上面的聚合也就无法并行了。
分组数为什么要「远小于」输入行数
这是并行聚合最核心的取舍逻辑。Finalize Aggregate 运行在 leader 上,它的输入是所有 worker 输出的局部结果之和。
输入行数 N,分组数 G,worker 数 W
Gather 之后搬给 leader 的行数 ≈ G × W ← 每个 worker 都要输出它见过的全部 G 个组若 G 接近 N(比如按主键分组、或按高基数列分组),则 G × W 甚至可能大于 N——并行不仅没减少要处理的数据,还额外制造了 (W - 1) × G 行的传输和 leader 端的合并开销。
规划器知道这一点,所以在「分组数相对输入行数很大」时会倾向于不选并行聚合。这也是为什么 count(DISTINCT user_id) 这类查询更难并行——去重本质上要求全局视角。
-- 收敛比高:2000 万行 → 240 个组(适合并行)
SELECT region_id, count(*) FROM fact_orders GROUP BY region_id;
-- 收敛比低:2000 万行 → 约 2000 万个组(不适合并行)
SELECT order_id, count(*) FROM fact_orders GROUP BY order_id;Gather 与 Gather Merge 在聚合中的选择
两者对聚合的影响完全不同:
Gather | Gather Merge | |
|---|---|---|
| 输入要求 | 无(各 worker 结果可无序) | 每个 worker 的输出必须局部有序 |
| 输出 | 无序 | 有序(k 路归并) |
上层是否需要 Sort | 若查询要求有序则需要 | 通常不需要 |
| 常见搭配 | Partial HashAggregate | Partial GroupAggregate(其输入来自 Parallel Index Scan 或 worker 内 Sort) |
一个典型对比:
-- 用 Gather:leader 端还要排序
Finalize GroupAggregate
-> Sort ← 额外排序
Sort Key: region_id
-> Gather
-> Partial HashAggregate
-- 用 Gather Merge:省掉排序,但要求 worker 局部有序
Finalize GroupAggregate
-> Gather Merge
Merge Key: region_id
-> Partial GroupAggregate
Group Key: region_id
-> Parallel Index Scan ← btree 扫描天然按索引序返回Gather Merge 只有恰好一个子计划,这一点与 Gather 相同。
Parallel Hash 与并行聚合的关系
聚合之外,Hash Join 的构建端也能并行,机制是 Parallel Hash:
| 形态 | 行为 |
|---|---|
Hash Join(无 Parallel 前缀) | 每个进程各自完整构建一份哈希表,内存按进程数放大 |
Parallel Hash Join + Parallel Hash | 各进程通过动态共享内存(DSM)共同构建一份共享哈希表,内存不分倍放大 |
用 enable_parallel_hash 可以关掉后者。选择逻辑是:哈希表较大时共享更省内存;较小时各自构建反而省去 DSM 的同步开销。
Parallel Append 的调度细节
Parallel Append 的「尽量均匀分散」不是简单的取模分配,而是一个动态认领过程:
Parallel Append(4 个子计划,3 个工作进程)
├─ 子计划 A(partial)
├─ 子计划 B(partial)
├─ 子计划 C(non-partial) ← 只允许 1 个进程执行
└─ 子计划 D(partial)
调度结果示意:
worker 1 → 先领 A,做完领 D
worker 2 → 先领 B,做完若无剩余则退出
leader → 领 C(non-partial),同时按 parallel_leader_participation 参与两个要点:
- non-partial 子计划只会被一个进程执行,因为执行多次会产生重复行。
- 先启动的进程优先领取 non-partial 子计划,这样可以避免「进程都去做别的、最后没人做 non-partial」的尾巴。
为什么 Parallel Append 比普通 Append 更适合分区表
考虑「12 个月分区、每个分区都有索引、但该索引不支持并行扫描」的场景:
- 普通
Append:所有进程先一起把 1 月的索引扫完,再一起做 2 月……同一时刻只有 1 个索引在被扫描,实际只有「扫描内部」的并行度(而索引扫描本身又不支持并行),等于全程几乎串行。 Parallel Append:进程 1 扫 1 月、进程 2 扫 2 月、进程 3 扫 3 月……不同分区的索引扫描同时进行,得到的是粗粒度但真实的并行。
这就是官方所说的「即便没有高效的 partial 计划,也能实现粗粒度并行」。
Partitionwise Aggregate:另一种加速思路
Parallel Append 解决的是「扫描」的并行,而 Partitionwise Aggregate 解决的是「聚合」的下推:
-- 未开启 partitionwise aggregate:先 Append 全部行,再整体聚合
Append
-> Seq Scan on fact_orders_2026_q1
-> Seq Scan on fact_orders_2026_q2
-> HashAggregate ← 在汇总后的数据上分组
-- 开启后:每个分区各自聚合,再合并(Merge Append)
Append
-> HashAggregate
-> Seq Scan on fact_orders_2026_q1
-> HashAggregate
-> Seq Scan on fact_orders_2026_q2两者的收益来源不同:前者靠更多 CPU 并行,后者靠减少需要合并的行数(每个分区先收敛)。它们可以叠加使用,但都可能因计划变复杂而变慢——尤其是分区数很多时,计划节点数量会成倍增长。
Parallel Append 与 enable_partitionwise_* 的默认值差异
这三个参数容易记混,集中列出:
| 参数 | 默认值 | 作用范围 |
|---|---|---|
enable_parallel_append | on | 是否允许 Parallel Append 节点本身 |
enable_partitionwise_aggregate | off | 是否把聚合下推到各分区 |
enable_partitionwise_join | off | 是否按对应分区两两连接 |
也就是说:「把进程分散到各分区子计划」是默认开启的,而「把聚合/连接下推到分区」默认是关的。这两件事经常被一起提到,但开关状态恰好相反。
三段节点的 rows 估算怎么读
并行聚合计划里的 rows 估算最容易看错。记住三条规则:
Parallel Seq Scan上的rows是每个进程的估算行数,不是总行数。若表有 2000 万行、4 个 worker,这里大约显示 500 万。Gather上的rows是汇总后的行数(各进程 rows 之和)。Partial Aggregate上的rows是每个进程输出的分组数(通常等于总分组数 G);Finalize Aggregate上的rows才是最终结果行数。
Finalize Aggregate (rows=240) ← 最终 240 个分组
-> Gather (rows=960) ← 240 × 4 个进程的局部结果
Workers Planned: 4
-> Partial Aggregate (rows=240, loops=4) ← 每个进程各输出 240 行
-> Parallel Seq Scan (rows=5000000, loops=4) ← 每进程 500 万行并行聚合失败的典型表现
| 计划表现 | 可能原因 |
|---|---|
只有 HashAggregate,没有 Partial | 聚合不满足前置条件(DISTINCT、无 combine 函数、GROUPING SETS 等) |
有 Partial 但 Gather 上有 Sort 且耗时占比高 | 选了 Gather 而非 Gather Merge,leader 端排序成瓶颈 |
有 Partial,但只有一个进程执行(loops=1) | worker 未启动,退化为「leader 独自执行 partial plan」 |
Partial 的 rows 接近 Parallel Seq Scan 的 rows | 分组数接近输入行数,并行聚合基本无收益 |
Parallel Append 里非 partial 子计划耗时极长 | 缺少高效 partial 计划,粗粒度并行的短板 |
实战示例
场景一:并行聚合与 Gather Merge,利用索引序省掉排序
背景:fact_orders 有 2000 万行,region_id 上有 btree 索引。查询要按 region_id 分组并输出有序结果。初始版本没有索引可用时,聚合后需要一次全量排序。
第一步,先看没有合适索引时的计划:
SET enable_indexscan = off; -- 强制走顺序扫描,模拟「没有可用索引」
EXPLAIN (ANALYZE, VERBOSE, BUFFERS)
SELECT region_id, count(*), sum(amount)
FROM fact_orders
GROUP BY region_id
ORDER BY region_id;
RESET enable_indexscan; Finalize GroupAggregate (cost=1308400.00..1308410.00 rows=240 width=20) (actual time=2015.4..2016.1 rows=240 loops=1)
-- Finalize GroupAggregate:leader 端做最终分组,并保证输出有序
Group Key: region_id
Buffers: shared hit=1400 read=498020
-> Sort (cost=1308400.00..1308405.00 rows=960 width=20) (actual time=2015.2..2015.4 rows=960 loops=1)
-- 额外的一次排序:对 960 行(240 组 × 4 进程)做排序
Sort Key: region_id
Sort Method: quicksort Memory: 96kB
-> Gather (cost=1000.00..1307900.00 rows=960 width=20) (actual time=1850.1..1990.0 rows=960 loops=1)
-- 用 Gather 而非 Gather Merge:因为上游没有局部有序的输出
Workers Planned: 4
Workers Launched: 4
-> Partial HashAggregate (cost=0.00..1307800.00 rows=240 width=20) (...)
-- 哈希聚合:不保证输出有序,所以得上层排序
Group Key: region_id
-> Parallel Seq Scan on fact_orders (...) (actual time=1.5..1350.2 rows=5000000 loops=4)
Buffers: shared hit=1400 read=498020
Planning Time: 9.204 ms
Execution Time: 2016.512 ms关键证据:Gather 上方出现了独立的 Sort(约 25ms,且要处理 960 行),因为 Partial HashAggregate 的输出无序。
第二步,让扫描按索引序返回,使 Gather Merge 成为可能:
EXPLAIN (ANALYZE, VERBOSE, BUFFERS)
SELECT region_id, count(*), sum(amount)
FROM fact_orders
GROUP BY region_id
ORDER BY region_id; Finalize GroupAggregate (cost=1000.00..1185000.00 rows=240 width=20) (actual time=1880.2..1880.6 rows=240 loops=1)
-- Finalize GroupAggregate 仍在 leader 上,但这次上游已经是有序的
Group Key: region_id
Buffers: shared hit=1250 read=498020
-> Gather Merge (cost=1000.00..1184960.00 rows=960 width=20) (actual time=1750.1..1878.9 rows=960 loops=1)
-- Gather Merge 直接做 k 路归并:省掉了上一步那个 Sort 节点
Merge Key: region_id
-- Merge Key 而非 Sort Key:说明这里是归并而不是排序
Workers Planned: 4
Workers Launched: 4
-> Partial GroupAggregate (cost=0.00..1183960.00 rows=240 width=20) (...)
-- 改为 GroupAggregate:它的输出天然按 Group Key 有序
Group Key: region_id
-> Parallel Index Only Scan using idx_fact_orders_region on fact_orders (...)
-- Parallel Index Only Scan:btree 按索引序返回,保证每个 worker 局部有序
-- 索引包含 region_id 与 amount,无需回表(Index Only)
Heap Fetches: 0
Buffers: shared hit=1250 read=498020
Planning Time: 6.882 ms
Execution Time: 1880.744 ms对比结论:
| 项 | 哈希聚合 + Gather | 排序聚合 + Gather Merge |
|---|---|---|
| 聚合节点 | Partial HashAggregate | Partial GroupAggregate |
| 汇合节点 | Gather | Gather Merge |
| leader 端额外排序 | 有(约 25ms) | 无 |
| 扫描方式 | Parallel Seq Scan | Parallel Index Only Scan |
| 耗时 | 约 2017ms | 约 1881ms |
差距不大,但机制上的区别很清楚:Gather Merge 之所以能省掉排序,前提是扫描本身按排序键有序返回(Parallel Index Only Scan 满足,Parallel Seq Scan 不满足),因此 Partial GroupAggregate 才能保证局部有序。这也解释了为什么 GroupAggregate 比 HashAggregate 更依赖合适的索引。
场景二:Parallel Append 让分区表的索引扫描并行起来
背景:logs 按 created_at 做了 12 个月的范围分区,每个分区上都有 (device_id) 的 btree 索引。查询只命中其中几个分区,但每个 Index Scan 都不支持并行扫描。
第一步,确认分区与索引情况:
SELECT inhrelid::regclass AS partition,
pg_size_pretty(pg_relation_size(inhrelid)) AS size
FROM pg_inherits
WHERE inhparent = 'logs'::regclass
ORDER BY 1
LIMIT 5; partition | size
----------------------+---------
logs_2026_01 | 2048 MB
logs_2026_02 | 1984 MB
logs_2026_03 | 2112 MB
logs_2026_04 | 2048 MB
logs_2026_05 | 2000 MB第二步,关掉 Parallel Append 看「退化版」:
SET enable_parallel_append = off;
EXPLAIN (ANALYZE, VERBOSE)
SELECT count(*) FROM logs WHERE device_id = 42; Aggregate (cost=487000.00..487000.01 rows=1 width=8) (actual time=6210.5..6210.6 rows=1 loops=1)
-- 连 Partial Aggregate 都没有:完全串行
Buffers: shared hit=3200 read=312000
-> Append (cost=0.43..486500.00 rows=200000 width=0) (actual time=0.5..6100.2 rows=200000 loops=1)
-- 普通 Append:单个进程按顺序依次执行各子计划
-> Index Scan using logs_2026_01_device_id_idx on logs_2026_01 (...)
Index Cond: (device_id = 42)
-> Index Scan using logs_2026_02_device_id_idx on logs_2026_02 (...)
Index Cond: (device_id = 42)
-> Index Scan using logs_2026_03_device_id_idx on logs_2026_03 (...)
Index Cond: (device_id = 42)
-> Index Scan using logs_2026_04_device_id_idx on logs_2026_04 (...)
Index Cond: (device_id = 42)
Planning Time: 12.480 ms
Execution Time: 6210.512 ms关键证据:Append 直接挂在 Aggregate 下面,中间没有 Gather。四个索引扫描由同一个进程顺序完成,耗时 6210ms。
第三步,恢复默认设置后再看:
RESET enable_parallel_append; -- 恢复默认 on
EXPLAIN (ANALYZE, VERBOSE)
SELECT count(*) FROM logs WHERE device_id = 42; Finalize Aggregate (cost=1000.00..386500.00 rows=1 width=8) (actual time=2180.3..2180.4 rows=1 loops=1)
-- Finalize Aggregate:leader 汇总各进程的 partial 计数
Buffers: shared hit=3200 read=312000
-> Gather (cost=1000.00..386499.99 rows=4 width=8) (actual time=2160.1..2180.2 rows=4 loops=1)
-- rows=4:4 个进程各交回一份局部计数
Workers Planned: 4
Workers Launched: 4
-> Partial Aggregate (actual time=2040.2..2040.3 rows=1 loops=4)
-- 每个进程各做一次局部计数(count 可并行、有 combine 函数)
-> Parallel Append (cost=0.43..386400.00 rows=200000 width=0) (actual time=0.6..2050.4 rows=200000 loops=4)
-- Parallel Append:进程被分散到不同分区的子计划上
-> Index Scan using logs_2026_01_device_id_idx on logs_2026_01 (...) (actual time=... rows=... loops=1)
-- loops=1:该子计划只被一个进程执行(non-partial 子计划)
Index Cond: (device_id = 42)
-> Index Scan using logs_2026_02_device_id_idx on logs_2026_02 (...) (actual time=... rows=... loops=1)
Index Cond: (device_id = 42)
-> Index Scan using logs_2026_03_device_id_idx on logs_2026_03 (...) (actual time=... rows=... loops=1)
Index Cond: (device_id = 42)
-> Index Scan using logs_2026_04_device_id_idx on logs_2026_04 (...) (actual time=... rows=... loops=1)
Index Cond: (device_id = 42)
Planning Time: 14.110 ms
Execution Time: 2180.775 ms对比结论:
| 项 | 普通 Append(串行) | Parallel Append |
|---|---|---|
| 结构 | Aggregate → Append → 4×Index Scan | Finalize Aggregate → Gather → Parallel Append → 4×Index Scan |
| 同时推进的子计划 | 1 个 | 最多 4 个 |
子计划的 loops | 1(隐式) | 1(non-partial,单进程执行) |
| 耗时 | 6210ms | 2181ms |
这个例子的价值在于:Index Scan 本身完全不支持并行扫描,但 Parallel Append 仍然带来了约 2.8 倍的加速——因为并行发生在「不同分区的子计划之间」,而不是「单个扫描内部」。这正是官方所说的「粗粒度并行」。
注意事项
不要在含
DISTINCT的聚合上期待并行。count(DISTINCT x)这类聚合无法并行,规划器不会生成Partial Aggregate。sql-- 错误!这样写永远拿不到并行聚合 EXPLAIN SELECT count(DISTINCT user_id) FROM fact_orders;不要忽略「分组数接近输入行数」这一事实。
Finalize Aggregate的输入是分组数 × 进程数,此时并行反而更慢。sql-- 不推荐:按高基数列分组,并行收益基本为零 SELECT order_id, count(*) FROM fact_orders GROUP BY order_id;不要在有序集聚合上期待并行。
percentile_cont、mode等有序集聚合不支持并行聚合。sql-- 错误!ordered-set aggregate 不支持并行聚合 EXPLAIN SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY amount) FROM fact_orders;不要在
GROUPING SETS查询上期待并行聚合。 官方明确列出这一条限制。sql-- 错误!GROUPING SETS 无法并行聚合 EXPLAIN SELECT region_id, status, count(*) FROM fact_orders GROUP BY GROUPING SETS ((region_id), (status), ());不要自己写没有
COMBINEFUNC的聚合还指望它并行。 自定义聚合必须显式声明 combine 函数(必要时还有SERIALFUNC/DESERIALFUNC)。sql-- 不推荐:没有 COMBINEFUNC,无法并行聚合 CREATE AGGREGATE my_sum(numeric) ( SFUNC = numeric_add, STYPE = numeric -- 缺少 COMBINEFUNC = numeric_add );不要把 ordered aggregate 的
ORDER BY与Gather Merge搞混。 前者是聚合内部对输入排序(阻止并行),后者是汇合节点做归并(不阻止并行)。sql-- 不推荐:聚合内部 ORDER BY,直接堵死并行聚合 SELECT string_agg(name, ',' ORDER BY name) FROM customers;不要为「让 Parallel Append 生效」而盲目关闭索引扫描。
enable_indexscan = off是诊断手段,长期保留会让规划器失去索引能力。sql-- 错误!生产环境长期关闭索引扫描 ALTER SYSTEM SET enable_indexscan = off;不要以为
Parallel Append里的每个子计划都在并行。 non-partial 子计划只由单个进程执行,这就是它的固有短板。text不推荐的结构(短板明显): Parallel Append -> Index Scan on huge_partition ← 单个进程要扫完这个大分区 -> Index Scan on small_partition不要把 Partitionwise Aggregate 当成「免费的并行」。 它默认
off是有原因的:分区数多时计划节点会成倍膨胀,规划时间与内存都会上涨。sql-- 不推荐:分区有几百个时草率全局打开 ALTER SYSTEM SET enable_partitionwise_aggregate = on;不要只看有没有
Partial Aggregate就断定并行聚合生效了。 还要确认Gather下面真的有进程在执行——若Workers Launched = 0,Partial只是被 leader 独自执行了一遍。sql-- 不推荐:忽略 Workers Launched 就下结论 EXPLAIN SELECT region_id, count(*) FROM fact_orders GROUP BY region_id;不要在分区表上同时开启两个 partitionwise 开关而不做对照。 两者叠加时计划会显著复杂化,收益未必叠加。
sql-- 不推荐:一次性打开所有分区智能开关 SET enable_partitionwise_join = on; SET enable_partitionwise_aggregate = on;
最佳实践
先判断收敛比,再看有没有
Partial。 收敛比是「输入行数 / 分组数」,它远大于 1 才值得并行聚合。text适合:2000 万行 → 240 组(收敛比约 8 万) 不适合:2000 万行 → 1500 万组(收敛比约 1.3)优先让分组键有索引,以便走
Gather Merge省掉排序。 条件是扫描按分组键有序返回。sql-- 建立与分组键对应的索引,使并行索引扫描可用 CREATE INDEX ON fact_orders (region_id) INCLUDE (amount);聚合查询统一用
EXPLAIN (ANALYZE, VERBOSE)验证。 只看计划形状不够,必须确认 worker 数量与 per-worker 均衡。sqlEXPLAIN (ANALYZE, VERBOSE) SELECT region_id, count(*) FROM fact_orders GROUP BY region_id;用
enable_parallel_append做 A/B 对照,量化它的实际收益。 尤其当子计划是 non-partial 时,收益完全取决于分区之间的负载是否均衡。sqlSET enable_parallel_append = off; EXPLAIN (ANALYZE) SELECT count(*) FROM logs WHERE device_id = 42; -- 串行基线 RESET enable_parallel_append; EXPLAIN (ANALYZE) SELECT count(*) FROM logs WHERE device_id = 42; -- 并行版本对分区表,先看分区之间的大小是否均衡。 严重倾斜的分区会让
Parallel Append的耗时由最大的那个分区决定。sqlSELECT inhrelid::regclass AS partition, pg_size_pretty(pg_relation_size(inhrelid)) AS size FROM pg_inherits WHERE inhparent = 'logs'::regclass ORDER BY pg_relation_size(inhrelid) DESC LIMIT 5;自定义聚合要显式补齐
COMBINEFUNC。 状态类型是internal时,还必须提供SERIALFUNC与DESERIALFUNC,否则无法跨进程传递。sqlCREATE AGGREGATE my_sum(numeric) ( SFUNC = numeric_add, STYPE = numeric, COMBINEFUNC = numeric_add -- 并行聚合的必要条件 );把「不能用并行聚合」的写法整理成评审清单。 典型的有:
count(DISTINCT x)、string_agg(... ORDER BY ...)、percentile_cont、GROUPING SETS。sql-- 改写建议:先按天/按区域预聚合到汇总表,再查询汇总表 -- 而不是在大表上直接做 DISTINCT 或有序集聚合留意
Gather Merge的前提条件是否真的满足。 若 worker 输出不局部有序,规划器不会选它,此时要检查扫描是否能按排序键有序返回。textGather Merge 可用 → 扫描按 Merge Key 有序(如 Parallel Index Scan) Gather Merge 不可用 → 扫描无序(如 Parallel Seq Scan)对大型分区表,考虑用
Parallel Append+ 汇总表的组合。 粗粒度并行解决扫描,汇总表解决收敛比,两者互补。sql-- 每日汇总表:让报表查询在更小的数据量上并行 CREATE TABLE daily_sales (day date, region_id int, total numeric);分区智能开关要在测试环境先量化。 打开
enable_partitionwise_aggregate/enable_partitionwise_join后,同时测量规划时间与执行时间。sql\timing on SET enable_partitionwise_aggregate = on; EXPLAIN (ANALYZE) SELECT region_id, count(*) FROM fact_orders GROUP BY region_id;在监控里为聚合类查询单独标记。 报表聚合与 OLTP 点查对并行的诉求完全相反,混在同一指标里看不出问题。
sql-- 记录并行聚合的关键指标:Workers Launched 与 Gather 之后的行数 EXPLAIN (ANALYZE, VERBOSE, FORMAT JSON) SELECT region_id, count(*) FROM fact_orders GROUP BY region_id;把
Gather之后的「行数放大倍数」记为健康指标。 它等于分组数 × 进程数 / 最终行数,倍数越大说明 leader 端的合并负担越重。textFinalize Aggregate (rows=240) -> Gather (rows=960) ← 放大 4 倍,恰好等于 worker 数 若放大倍数远超 worker 数,说明分组键基数异常增大
下一节
继续学习:VACUUM 与膨胀概览,了解清理机制如何与查询性能相互影响。