Skip to content

并行聚合与 Parallel Append(Parallel Aggregation and Parallel Append) ​

聚合与多路合并是数据仓库场景里最耗 CPU 的两类操作。PostgreSQL 把前者拆成「每个 worker 先做局部聚合,再由 leader 汇总」的两阶段(实际输出上是三段节点),把后者交给 Parallel Append 把进程分散到多个子计划上同时推进。理解这两套机制,才能解释清楚「为什么分组数一多、并行聚合就不划算了」以及「为什么分区表的并行扫描能用上、索引却没并行」。

本节从计划结构入手,讲清楚并行聚合的三段节点、Parallel Append 与普通 Append 的分派差异、两者的适用边界,以及如何用 EXPLAIN 逐一验证。内容偏机制与计划形状,参数本身的默认值请回看上一节。

前置知识

阅读本节前,建议先了解:并行计划的操作与诊断

基础概念 ​

并行聚合的三段结构 ​

PostgreSQL 的并行聚合(parallel aggregation)不是「把聚合拆给多个进程各做一部分然后相加」这么简单——因为聚合有分组、有状态、有去重。官方的做法是分两个阶段,在计划输出上体现为三段节点:

text
        Finalize Aggregate            ← ③ leader 上执行:把各进程的局部结果再聚合一次
              │
           Gather / Gather Merge      ← ② 汇合点:把 worker 的局部结果搬到 leader
              │
        Partial Aggregate             ← ① 每个 worker 各执行一遍:只聚合自己看到的那部分行
              │
        Parallel Seq Scan             ← 驱动扫描(必须是并行感知的)

对应关系:

阶段节点名执行者
第一阶段Partial Aggregate每个 worker(以及参与干活的 leader)各一次
汇合Gather 或 Gather Mergeleader
第二阶段Finalize Aggregateleader

如果聚合策略是哈希聚合或排序聚合,节点名会带上具体算法:

第一阶段节点第二阶段节点
Partial HashAggregateFinalize HashAggregate
Partial GroupAggregateFinalize GroupAggregate
Partial AggregateFinalize Aggregate

注意:leader 与 worker 不必使用同一种聚合节点。例如 worker 常用 Partial HashAggregate,而 leader 端可能选 Finalize GroupAggregate(因为它需要输出有序结果)。

为什么必须有 Partial 阶段 ​

一个直觉上的错误做法是:让每个 worker 都完整执行 Aggregate,然后 leader 把结果拼起来。这行不通,原因有两个:

  1. 分组会重复。每个 worker 都见过「区域 A」,各自产出一行「区域 A 的汇总值」。直接拼接会得到多行区域 A。
  2. 计数类聚合不能简单相加(除非有 combine 函数)。avg 需要同时携带 sum 与 count,array_agg 需要拼接数组。

所以每个 worker 只能输出「局部状态」(partial state),由 leader 用 combine 函数把它们合并成最终值。这正是「Partial / Finalize」这对命名的由来。

一个最小可读的并行聚合计划 ​

sql
EXPLAIN (ANALYZE, VERBOSE)
SELECT region_id, count(*), sum(amount) FROM fact_orders GROUP BY region_id;
text
 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_aggregateoff
Partitionwise Join分区表按对应分区两两连接,避免全量连接enable_partitionwise_joinoff
Parallel Append把进程分散到各分区的子计划上同时扫描enable_parallel_appendon

后两项默认关闭:它们属于「分区智能」优化,开启后计划可能更优,也可能因结构复杂而变差,必须实测。

语法与用法 ​

观察聚合是否并行 ​

sql
-- 静态形状:是否有 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 ​

sql
-- 分区表的 UNION ALL 或跨分区查询
EXPLAIN (VERBOSE)
SELECT * FROM fact_orders_2026_q1
UNION ALL
SELECT * FROM fact_orders_2026_q2;

在并行计划中,你会看到 Append(箭头前缀 -> 的多子节点)或 Parallel Append。区分方式就是节点名本身:

text
 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

开关某个形状做对照 ​

sql
-- 关掉 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;

试探分区智能是否有效 ​

sql
-- 分区智能默认关闭;临时打开做对照实验
SET enable_partitionwise_aggregate = on;
EXPLAIN (ANALYZE)
SELECT region_id, count(*) FROM fact_orders GROUP BY region_id;
RESET enable_partitionwise_aggregate;

检查聚合函数的可并行性 ​

sql
-- 看聚合是否有 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 输出做自动化比对 ​

sql
-- 便于脚本判断两次执行里 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 输出的局部结果之和。

text
输入行数 N,分组数 G,worker 数 W
Gather 之后搬给 leader 的行数 ≈ G × W     ← 每个 worker 都要输出它见过的全部 G 个组

若 G 接近 N(比如按主键分组、或按高基数列分组),则 G × W 甚至可能大于 N——并行不仅没减少要处理的数据,还额外制造了 (W - 1) × G 行的传输和 leader 端的合并开销。

规划器知道这一点,所以在「分组数相对输入行数很大」时会倾向于不选并行聚合。这也是为什么 count(DISTINCT user_id) 这类查询更难并行——去重本质上要求全局视角。

sql
-- 收敛比高: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 在聚合中的选择 ​

两者对聚合的影响完全不同:

GatherGather Merge
输入要求无(各 worker 结果可无序)每个 worker 的输出必须局部有序
输出无序有序(k 路归并)
上层是否需要 Sort若查询要求有序则需要通常不需要
常见搭配Partial HashAggregatePartial GroupAggregate(其输入来自 Parallel Index Scan 或 worker 内 Sort)

一个典型对比:

text
-- 用 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 的「尽量均匀分散」不是简单的取模分配,而是一个动态认领过程:

text
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 参与

两个要点:

  1. non-partial 子计划只会被一个进程执行,因为执行多次会产生重复行。
  2. 先启动的进程优先领取 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 解决的是「聚合」的下推:

text
-- 未开启 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_appendon是否允许 Parallel Append 节点本身
enable_partitionwise_aggregateoff是否把聚合下推到各分区
enable_partitionwise_joinoff是否按对应分区两两连接

也就是说:「把进程分散到各分区子计划」是默认开启的,而「把聚合/连接下推到分区」默认是关的。这两件事经常被一起提到,但开关状态恰好相反。

三段节点的 rows 估算怎么读 ​

并行聚合计划里的 rows 估算最容易看错。记住三条规则:

  1. Parallel Seq Scan 上的 rows 是每个进程的估算行数,不是总行数。若表有 2000 万行、4 个 worker,这里大约显示 500 万。
  2. Gather 上的 rows 是汇总后的行数(各进程 rows 之和)。
  3. Partial Aggregate 上的 rows 是每个进程输出的分组数(通常等于总分组数 G);Finalize Aggregate 上的 rows 才是最终结果行数。
text
 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 分组并输出有序结果。初始版本没有索引可用时,聚合后需要一次全量排序。

第一步,先看没有合适索引时的计划:

sql
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;
text
 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 成为可能:

sql
EXPLAIN (ANALYZE, VERBOSE, BUFFERS)
SELECT region_id, count(*), sum(amount)
FROM fact_orders
GROUP BY region_id
ORDER BY region_id;
text
 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 HashAggregatePartial GroupAggregate
汇合节点GatherGather Merge
leader 端额外排序有(约 25ms)无
扫描方式Parallel Seq ScanParallel 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 都不支持并行扫描。

第一步,确认分区与索引情况:

sql
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;
text
      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 看「退化版」:

sql
SET enable_parallel_append = off;
EXPLAIN (ANALYZE, VERBOSE)
SELECT count(*) FROM logs WHERE device_id = 42;
text
 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。

第三步,恢复默认设置后再看:

sql
RESET enable_parallel_append;   -- 恢复默认 on
EXPLAIN (ANALYZE, VERBOSE)
SELECT count(*) FROM logs WHERE device_id = 42;
text
 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 ScanFinalize Aggregate → Gather → Parallel Append → 4×Index Scan
同时推进的子计划1 个最多 4 个
子计划的 loops1(隐式)1(non-partial,单进程执行)
耗时6210ms2181ms

这个例子的价值在于:Index Scan 本身完全不支持并行扫描,但 Parallel Append 仍然带来了约 2.8 倍的加速——因为并行发生在「不同分区的子计划之间」,而不是「单个扫描内部」。这正是官方所说的「粗粒度并行」。

注意事项 ​

  1. 不要在含 DISTINCT 的聚合上期待并行。count(DISTINCT x) 这类聚合无法并行,规划器不会生成 Partial Aggregate。

    sql
    -- 错误!这样写永远拿不到并行聚合
    EXPLAIN SELECT count(DISTINCT user_id) FROM fact_orders;
  2. 不要忽略「分组数接近输入行数」这一事实。Finalize Aggregate 的输入是 分组数 × 进程数,此时并行反而更慢。

    sql
    -- 不推荐:按高基数列分组,并行收益基本为零
    SELECT order_id, count(*) FROM fact_orders GROUP BY order_id;
  3. 不要在有序集聚合上期待并行。percentile_cont、mode 等有序集聚合不支持并行聚合。

    sql
    -- 错误!ordered-set aggregate 不支持并行聚合
    EXPLAIN SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY amount) FROM fact_orders;
  4. 不要在 GROUPING SETS 查询上期待并行聚合。 官方明确列出这一条限制。

    sql
    -- 错误!GROUPING SETS 无法并行聚合
    EXPLAIN SELECT region_id, status, count(*)
    FROM fact_orders
    GROUP BY GROUPING SETS ((region_id), (status), ());
  5. 不要自己写没有 COMBINEFUNC 的聚合还指望它并行。 自定义聚合必须显式声明 combine 函数(必要时还有 SERIALFUNC / DESERIALFUNC)。

    sql
    -- 不推荐:没有 COMBINEFUNC,无法并行聚合
    CREATE AGGREGATE my_sum(numeric) (
        SFUNC = numeric_add,
        STYPE = numeric
        -- 缺少 COMBINEFUNC = numeric_add
    );
  6. 不要把 ordered aggregate 的 ORDER BY 与 Gather Merge 搞混。 前者是聚合内部对输入排序(阻止并行),后者是汇合节点做归并(不阻止并行)。

    sql
    -- 不推荐:聚合内部 ORDER BY,直接堵死并行聚合
    SELECT string_agg(name, ',' ORDER BY name) FROM customers;
  7. 不要为「让 Parallel Append 生效」而盲目关闭索引扫描。enable_indexscan = off 是诊断手段,长期保留会让规划器失去索引能力。

    sql
    -- 错误!生产环境长期关闭索引扫描
    ALTER SYSTEM SET enable_indexscan = off;
  8. 不要以为 Parallel Append 里的每个子计划都在并行。 non-partial 子计划只由单个进程执行,这就是它的固有短板。

    text
    不推荐的结构(短板明显):
      Parallel Append
        ->  Index Scan on huge_partition   ← 单个进程要扫完这个大分区
        ->  Index Scan on small_partition
  9. 不要把 Partitionwise Aggregate 当成「免费的并行」。 它默认 off 是有原因的:分区数多时计划节点会成倍膨胀,规划时间与内存都会上涨。

    sql
    -- 不推荐:分区有几百个时草率全局打开
    ALTER SYSTEM SET enable_partitionwise_aggregate = on;
  10. 不要只看有没有 Partial Aggregate 就断定并行聚合生效了。 还要确认 Gather 下面真的有进程在执行——若 Workers Launched = 0,Partial 只是被 leader 独自执行了一遍。

    sql
    -- 不推荐:忽略 Workers Launched 就下结论
    EXPLAIN SELECT region_id, count(*) FROM fact_orders GROUP BY region_id;
  11. 不要在分区表上同时开启两个 partitionwise 开关而不做对照。 两者叠加时计划会显著复杂化,收益未必叠加。

    sql
    -- 不推荐:一次性打开所有分区智能开关
    SET enable_partitionwise_join = on;
    SET enable_partitionwise_aggregate = on;

最佳实践 ​

  1. 先判断收敛比,再看有没有 Partial。 收敛比是「输入行数 / 分组数」,它远大于 1 才值得并行聚合。

    text
    适合:2000 万行 → 240 组(收敛比约 8 万)
    不适合:2000 万行 → 1500 万组(收敛比约 1.3)
  2. 优先让分组键有索引,以便走 Gather Merge 省掉排序。 条件是扫描按分组键有序返回。

    sql
    -- 建立与分组键对应的索引,使并行索引扫描可用
    CREATE INDEX ON fact_orders (region_id) INCLUDE (amount);
  3. 聚合查询统一用 EXPLAIN (ANALYZE, VERBOSE) 验证。 只看计划形状不够,必须确认 worker 数量与 per-worker 均衡。

    sql
    EXPLAIN (ANALYZE, VERBOSE)
    SELECT region_id, count(*) FROM fact_orders GROUP BY region_id;
  4. 用 enable_parallel_append 做 A/B 对照,量化它的实际收益。 尤其当子计划是 non-partial 时,收益完全取决于分区之间的负载是否均衡。

    sql
    SET 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;  -- 并行版本
  5. 对分区表,先看分区之间的大小是否均衡。 严重倾斜的分区会让 Parallel Append 的耗时由最大的那个分区决定。

    sql
    SELECT 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;
  6. 自定义聚合要显式补齐 COMBINEFUNC。 状态类型是 internal 时,还必须提供 SERIALFUNC 与 DESERIALFUNC,否则无法跨进程传递。

    sql
    CREATE AGGREGATE my_sum(numeric) (
        SFUNC     = numeric_add,
        STYPE     = numeric,
        COMBINEFUNC = numeric_add      -- 并行聚合的必要条件
    );
  7. 把「不能用并行聚合」的写法整理成评审清单。 典型的有:count(DISTINCT x)、string_agg(... ORDER BY ...)、percentile_cont、GROUPING SETS。

    sql
    -- 改写建议:先按天/按区域预聚合到汇总表,再查询汇总表
    -- 而不是在大表上直接做 DISTINCT 或有序集聚合
  8. 留意 Gather Merge 的前提条件是否真的满足。 若 worker 输出不局部有序,规划器不会选它,此时要检查扫描是否能按排序键有序返回。

    text
    Gather Merge 可用 → 扫描按 Merge Key 有序(如 Parallel Index Scan)
    Gather Merge 不可用 → 扫描无序(如 Parallel Seq Scan)
  9. 对大型分区表,考虑用 Parallel Append + 汇总表的组合。 粗粒度并行解决扫描,汇总表解决收敛比,两者互补。

    sql
    -- 每日汇总表:让报表查询在更小的数据量上并行
    CREATE TABLE daily_sales (day date, region_id int, total numeric);
  10. 分区智能开关要在测试环境先量化。 打开 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;
  11. 在监控里为聚合类查询单独标记。 报表聚合与 OLTP 点查对并行的诉求完全相反,混在同一指标里看不出问题。

    sql
    -- 记录并行聚合的关键指标:Workers Launched 与 Gather 之后的行数
    EXPLAIN (ANALYZE, VERBOSE, FORMAT JSON)
    SELECT region_id, count(*) FROM fact_orders GROUP BY region_id;
  12. 把 Gather 之后的「行数放大倍数」记为健康指标。 它等于 分组数 × 进程数 / 最终行数,倍数越大说明 leader 端的合并负担越重。

    text
    Finalize Aggregate (rows=240)
      ->  Gather (rows=960)      ← 放大 4 倍,恰好等于 worker 数
    若放大倍数远超 worker 数,说明分组键基数异常增大

下一节

继续学习:VACUUM 与膨胀概览,了解清理机制如何与查询性能相互影响。

参考链接 ​