Skip to content

并行计划的操作与诊断(Parallel Plan Operations) ​

参数配好了,不代表计划就照着跑。真实系统里最常见的两类并行问题,一类是「计划看起来是并行的,实际没并行」——Workers Launched 为 0 或远小于 Planned;另一类是「并行了反而更慢」——worker 之间严重不均衡、Gather 上方成了新瓶颈、或者内存被放大到触发落盘。

本节聚焦操作层:如何读懂并行计划的每一行输出、如何用 EXPLAIN (ANALYZE, VERBOSE) 判断各 worker 是否均衡、如何用 pg_stat_activity 与进度视图观察正在运行的并行查询,以及一组可复用的诊断流程。至于并行聚合与 Parallel Append 的内部机制,留到下一节专门展开。

前置知识

阅读本节前,建议先了解:并行配置参数

基础概念 ​

并行计划输出的四类关键行 ​

读并行计划,先建立「哪些行是并行特有的」这个概念。并行计划里额外的信息只有四类:

输出行出现在哪含义
Workers Planned: NGather / Gather Merge 节点规划器打算启动几个 worker
Workers Launched: N同上(需 ANALYZE)实际成功启动了几个 worker
Worker 0:、Worker 1: …各节点内(需 ANALYZE, VERBOSE)每个 worker 在该节点上的独立统计
loops=N并行部分内的节点该节点被多少个进程各执行一次

记住一句话:Planned 是承诺,Launched 是事实,Worker n: 是明细,loops 是交叉验证。

Gather 与 Gather Merge 的区别在输出上的体现 ​

text
Gather                                Gather Merge
  -> Parallel Seq Scan                  -> Sort
        (无序返回)                          -> Gather Merge
                                                    -> Parallel Seq Scan
                                                          (每个 worker 局部有序)
  • Gather:把各 worker 的结果无序汇合,上层若需要有序必须再做一次 Sort。
  • Gather Merge:要求每个 worker 的输出局部有序(通常是 Parallel Index Scan 或 worker 内已排序),然后做 k 路归并,上层不需要额外 Sort。

Gather Merge 只有恰好一个子计划,官方文档对此有明确说明——它不是一个可以挂多个子节点的「合并节点」。

并行计划 vs 串行计划:同样的节点,不同的名字 ​

并行化会改变节点名,这是判断「这段是否在并行部分」最直接的依据:

串行节点并行版本说明
Seq ScanParallel Seq Scan按块区间分片
Bitmap Heap ScanParallel Bitmap Heap Scanleader 先建位图,再分片扫堆
Index Scan / Index Only ScanParallel Index Scan / Parallel Index Only Scan仅 btree,按索引块轮流认领
HashParallel Hash共享哈希表(DSM)
Hash JoinHash Join(无 Parallel 前缀时每进程各建一份)注意区别
AggregatePartial Aggregate + Finalize Aggregate两阶段
AppendParallel Append进程分散到多个子计划

「并行范围」由 Gather 的位置决定 ​

一个常见误解是「看到 Gather 就是整条查询并行」。实际只有 Gather 以下的部分并行:

text
Sort                                 ← leader 单进程(在 Gather 之上)
  Sort Key: total DESC
  ->  Gather
        Workers Planned: 4
        ->  Partial Aggregate        ← 并行部分从这里开始
              ->  Parallel Seq Scan

在这个例子里,排序完全没有并行:4 个 worker 各自产出 partial 聚合结果后,全部行都汇集到 leader,由 leader 一个人排序。如果 partial 结果行数很多,Sort 会成为新瓶颈——此时需要的是 Gather Merge 或者改写查询。

诊断的三层视角 ​

视角工具回答什么问题
单条查询EXPLAIN (ANALYZE, VERBOSE, BUFFERS)这条查询并行了吗?worker 均衡吗?瓶颈在哪个节点?
实例瞬时pg_stat_activity(backend_type、leader_pid)此刻有多少并行 worker?池子被占满了吗?
实例累计pg_stat_database.parallel_workers_*长期来看「规划了却没启动」的比例是多少?

三层配合,才能区分「单条查询的问题」和「实例资源的问题」。

语法与用法 ​

观察一条查询是否真的并行 ​

sql
-- 只看形状:是否有 Gather / Parallel 前缀节点
EXPLAIN SELECT count(*) FROM fact_orders WHERE status = 'paid';

-- 看实际:Workers Planned 与 Workers Launched 各是多少
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM fact_orders WHERE status = 'paid';

注意:Workers Launched 只在 ANALYZE 下出现。不加 ANALYZE 时,你只能看到 Workers Planned,无法判断 worker 是否真的启动。

看每个 worker 的独立统计 ​

sql
EXPLAIN (ANALYZE, VERBOSE)
SELECT count(*) FROM fact_orders WHERE status = 'paid';

VERBOSE 会让 ANALYZE 为每个计划节点额外输出 per-worker 明细,形如:

text
   Workers Planned: 3
   Workers Launched: 3
   ->  Parallel Seq Scan on fact_orders (actual time=...)
         Filter: (status = 'paid'::text)
         Rows Removed by Filter: 500000
         Worker 0:  actual time=... rows=... loops=1
         Worker 1:  actual time=... rows=... loops=1

官方文档对它的用途说得很直接:可以用来判断工作是否在各 worker 之间均匀分配。

控制输出详略,减少观测开销 ​

sql
-- 只要行数不要时间:关掉 TIMING,显著降低高调用次数节点的时钟开销
EXPLAIN (ANALYZE, TIMING OFF, VERBOSE) SELECT ... ;

-- 只看并行结构,忽略成本估算
EXPLAIN (COSTS OFF) SELECT ... ;

-- 结构化输出,便于脚本比对两次计划
EXPLAIN (ANALYZE, FORMAT JSON) SELECT ... ;

查看规划时实际生效的「非默认」参数 ​

sql
-- SETTINGS 会列出所有「与内置默认值不同」的规划相关参数
EXPLAIN (ANALYZE, SETTINGS)
SELECT count(*) FROM fact_orders WHERE status = 'paid';

输出里会多一段:

text
 Settings: max_parallel_workers_per_gather = '4', work_mem = '64MB'

这是确认「某个会话级 SET 是否真的影响了规划」最可靠的方式——比 SHOW 更贴近本次规划的实际取值。

观察正在运行的并行查询 ​

sql
-- 按类型统计当前进程
SELECT backend_type, count(*)
FROM pg_stat_activity
GROUP BY backend_type
ORDER BY 2 DESC;
text
 backend_type      | count
-------------------+-------
 client backend    |    42
 parallel worker   |    18
 autovacuum worker |     1
 logical replication worker | 2
sql
-- 找出每个并行查询的 leader 与其 worker 数量
SELECT leader.pid                       AS leader_pid,
       leader.state                     AS leader_state,
       left(leader.query, 60)           AS query_head,
       count(worker.pid)                AS workers
FROM pg_stat_activity leader
LEFT JOIN pg_stat_activity worker ON worker.leader_pid = leader.pid
WHERE leader.pid IN (SELECT DISTINCT leader_pid FROM pg_stat_activity WHERE leader_pid IS NOT NULL)
GROUP BY 1, 2, 3
ORDER BY 4 DESC;

pg_stat_activity.leader_pid 的定义值得记住:如果当前进程是并行查询的 worker,它指向所属的 leader;如果是 leader 或不参与并行,则为 NULL。 因此 WHERE leader_pid IS NOT NULL 恰好筛出所有 worker 进程。

累计视角:规划 vs 启动 ​

sql
-- PostgreSQL 18 新增的两个字段
SELECT datname,
       parallel_workers_to_launch,
       parallel_workers_launched
FROM pg_stat_database
WHERE datname = current_database();

这两个字段是累积计数器,配合时间窗口采样才能看出趋势。

观察维护命令的并行进度 ​

sql
-- CREATE INDEX / REINDEX 的进度(含并行构建阶段)
SELECT pid, command, phase, blocks_total, blocks_done, tuples_total, tuples_done
FROM pg_stat_progress_create_index;
sql
-- VACUUM(非 FULL)的进度;并行 worker 的 cost delay 睡眠时间也被合并计入 delay_time
SELECT pid, relid::regclass, phase,
       heap_blks_total, heap_blks_scanned, heap_blks_vacuumed,
       indexes_total, indexes_processed, delay_time
FROM pg_stat_progress_vacuum;

注意 VACUUM FULL 与 CLUSTER 的进度不在 pg_stat_progress_vacuum 里,而在 pg_stat_progress_cluster——因为这两个命令都会重写整张表。

详细说明 ​

读懂 Workers Planned 与 Workers Launched 的差异 ​

这是并行诊断的第一判据:

现象含义处理方向
都有,且相等规划与实际一致,并行正常关注是否真的更快
Launched < Planned(差距小)启动时有轻微竞争,可能只是瞬时观察是否偶发
Launched < Planned(差距大)worker 池不足或并发过高查 max_worker_processes / max_parallel_workers
Launched = 0worker 一个都没启动池子被占满,或事务/会话状态限制
只有 Planned,没有 Launched没加 ANALYZE加上 ANALYZE 再看
整个计划里没有 Gather规划器压根没选并行转向「为什么不能并行」

关键认知:Launched 小于 Planned 不是 bug,而是资源竞争的信号。它最坏的后果是——规划器按「并行更便宜」的成本估算选了这条路,执行时却几乎串行,结果比一开始就选串行计划更慢(因为还多付了并行启动成本)。

per-worker 统计怎么用来判断均衡 ​

VERBOSE 输出的 Worker 0:、Worker 1: 行,是判断负载是否均衡的直接依据。看两个量:

  • 每个 Worker n 的 actual time(或 rows)是否接近;
  • Workers Planned 与真实参与工作的 worker 数量是否一致。

典型的不均衡长这样:

text
 Gather  (cost=1000.00..80000.00 rows=4 width=8) (actual time=120.0..1890.5 rows=4 loops=1)
   Workers Planned: 4
   Workers Launched: 4
   ->  Parallel Seq Scan on skew_table  (cost=0.00..79000.00 rows=250000 width=8)
         (actual time=2.1..1870.0 rows=250000 loops=4)
         Worker 0:  actual time=2.1..1870.0 rows=250000 loops=1
         Worker 1:  actual time=2.0..1869.5 rows=250000 loops=1
         Worker 2:  actual time=2.1..1868.9 rows=250000 loops=1
         Worker 3:  actual time=95.0..410.2 rows=250000 loops=1
         -- Worker 3 只花了 410ms 就干完,其它三个都是 1870ms
         -- 说明分片严重不均:Worker 3 认领的块区间里大部分是热数据(已在缓存中)

Worker 3 提前完成后就空转,整体耗时由最慢的 worker 决定。这是并行度无法线性提升的核心原因之一。

为什么并行时间不是串行时间除以 N ​

理想情况下,4 个 worker 应该接近 4 倍加速,但实际几乎不可能。原因有四:

损耗来源说明
parallel_setup_cost固定启动开销(fork、DSM 初始化),查询越短占比越大
parallel_tuple_cost × 行数worker 结果全部搬回 leader 的传输成本
Gather 之上的串行段Sort、HashAggregate、LIMIT 等在 leader 单进程执行
负载不均衡整体耗时由最慢的 worker 决定,快的那几个白等

所以「4 个 worker 就快 4 倍」只在小概率下成立。加速比(speedup)本身就是要测量的,不是可以假设的。

关注 I/O 是否已经成为瓶颈 ​

并行解决的是 CPU 瓶颈。如果瓶颈在磁盘 I/O,加 worker 只会让更多进程排队等 I/O:

sql
-- 看缓冲命中率:如果命中率很低,说明大部分时间在等磁盘
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM fact_orders WHERE status = 'paid';
text
                 Buffers: shared hit=4210 read=496880
                 -- hit=4210, read=496880 → 命中率约 0.8%,几乎全是物理读

命中率极低时,并行扫描的收益会被磁盘带宽吃掉。此时应该考虑的是:加内存(shared_buffers)、优化索引、或者用 AIO 与 effective_io_concurrency 提升并发 I/O。

并行与 work_mem 的交互:留意 Batches 落盘 ​

并行节点(尤其是 Parallel Hash)会按 worker 数放大内存。当内存不足时,哈希会溢出到临时文件,计划里出现 Batches > 1:

text
 ->  Parallel Hash  (cost=... rows=... width=...) (actual time=... rows=...)
       Buckets: 262144  Batches: 8  Memory Usage: 8192kB
       -- Batches: 8 表示哈希表被分成 8 批落盘,说明内存不够

Batches > 1 意味着额外的磁盘读写,往往直接抵消并行收益。处理方式:提高 work_mem,或降低 Parallel Hash 的并行度让每个 worker 有更多内存。

Gather 之上的节点成为新瓶颈 ​

这是「并行了但没变快」最常见的原因。判断方法很直接:看 Gather 上方的节点耗时占比。

text
 Sort  (cost=... rows=... width=...) (actual time=1890.0..1930.5 rows=100000 loops=1)
   Sort Key: total DESC
   Sort Method: external merge  Disk: 12544kB     ← leader 单进程排序,还落盘了
   ->  Gather  (actual time=300.0..1750.0 rows=100000 loops=1)
         Workers Planned: 4
         Workers Launched: 4

整体 Execution Time 约 1930ms,而 Gather 完成时是 1750ms——Sort 独占了约 180ms,且因为 rows=100000 太大而落盘。并行把 100000 行 partial 结果全搬到 leader,反而制造了 leader 端的排序压力。

改进方向:

  • 若排序键可由索引提供,用 Gather Merge 替代 Gather;
  • 把排序/去重下推到 worker(如 DISTINCT 配合排序键);
  • 在 worker 内先 Partial Aggregate 收敛行数,再让 leader 排序。

LIMIT 与并行的天然冲突 ​

LIMIT 希望「拿到足够的行就停」,而并行的 Gather 通常要等各 worker 都产出后才汇合,且无法保证「前 10 行」来自第一个 worker 的前 10 行。结果是:

  • 规划器常常不为分页查询选并行计划(成本上不划算);
  • 即使选了,Gather 也可能提前终止,导致 worker 的 Worker n 行时间差异极大(有的被强制结束)。
sql
-- 分页查询:通常看不到并行,这是正常的
EXPLAIN (ANALYZE) SELECT * FROM fact_orders ORDER BY id LIMIT 20;

事务与会话状态对并行的限制 ​

即使参数都满足,下列状态也会让查询无法并行(官方文档 §15.2 有完整清单):

状态原因
单用户模式(single-user mode)没有任何 background worker 可用
查询内嵌套在已有的并行查询中不允许嵌套并行
使用了 DECLARE CURSOR / FOR x IN query LOOP查询可能被挂起,无法并行
涉及写操作(除少数例外)并行只支持只读,写操作例外见下
调用了 PARALLEL UNSAFE 的函数函数阻塞了整个并行计划

写操作中允许并行的例外包括:CREATE TABLE ... AS、SELECT INTO、CREATE MATERIALIZED VIEW、REFRESH MATERIALIZED VIEW——因为它们本质上是「读 + 建新对象」。

sql
-- 不能并行:游标
BEGIN;
DECLARE c CURSOR FOR SELECT count(*) FROM fact_orders;
FETCH 1 FROM c;
ROLLBACK;

用 EXPLAIN 的 SETTINGS 与 GENERIC_PLAN 做对照 ​

两个容易被忽略的选项,在并行诊断中很有用:

sql
-- ① SETTINGS:确认本次规划实际用的并行参数(含会话级 SET)
EXPLAIN (ANALYZE, SETTINGS) SELECT count(*) FROM fact_orders;

-- ② GENERIC_PLAN:看预处理语句的「通用计划」里是否并行
EXPLAIN (GENERIC_PLAN)
SELECT count(*) FROM fact_orders WHERE status = $1;

GENERIC_PLAN 不能与 ANALYZE 同时使用,它只展示规划结果。对使用 PREPARE 的应用(如某些 ORM)来说,它回答的是「不依赖参数值的那个计划会不会并行」——这常常与应用实际使用的计划不是同一个。

三类「无法并行」的定位顺序 ​

遇到「查询没并行」,按这个顺序排查最省时间:

text
① 计划里有没有 Gather?
     没有 → 是「不允许并行」的问题
              → 检查:写操作?游标?UNSAFE 函数?
     有   → 继续 ②

② Workers Launched 是多少?
     0     → 资源问题(池子被占满)
     0 < x < Planned → 资源竞争
     = Planned → 并行正常执行,问题在别处(③)

③ 并行范围是否被截断?
     看 Gather 的位置:上方是否还有 Sort/HashAggregate
     看是否有 RESTRICTED 操作(CTE、临时表)

实战示例 ​

场景一:并行度上不去,逐层排除定位到 UNSAFE 函数 ​

背景:sales_report() 是一个 PL/pgSQL 函数,用于按区域汇总销售额。参数都调好了,但计划里始终没有 Gather。

第一步,确认参数没问题:

sql
EXPLAIN (ANALYZE, SETTINGS)
SELECT region_id, sales_report(region_id) FROM fact_orders GROUP BY region_id;
text
 HashAggregate  (cost=1185000.00..1185240.00 rows=240 width=12) (actual time=8420.5..8421.0 rows=240 loops=1)
   -- 没有 Gather,也没有 Parallel 前缀节点:完全串行
   Group Key: region_id
   Buffers: shared hit=1200 read=498020
   ->  Seq Scan on fact_orders  (cost=0.00..1085000.00 rows=20000000 width=4) (actual time=1.2..5100.4 rows=20000000 loops=1)
         -- Seq Scan 没有 Parallel 前缀,整个扫描压在单核上
         Buffers: shared hit=1200 read=498020
 Planning Time: 12.480 ms
 Execution Time: 8420.512 ms
 Settings: max_parallel_workers_per_gather = '4', min_parallel_table_scan_size = '0'

Settings 显示参数确实生效了(per_gather = 4,且门槛被临时清零),但计划全串行——说明问题不在参数。

第二步,检查是否调用了非并行安全的函数:

sql
SELECT p.oid::regprocedure AS func, p.proparallel
FROM pg_proc p
WHERE p.proname = 'sales_report';
text
        func         | proparallel
---------------------+-------------
 sales_report(integer) | u

proparallel = 'u'(PARALLEL UNSAFE)。函数体内如果只是聚合查询、不写表、不动序列,是可以安全并行的。

第三步,确认函数体无副作用后改为 SAFE:

sql
ALTER FUNCTION sales_report(integer) PARALLEL SAFE;

第四步,验证:

sql
EXPLAIN (ANALYZE, VERBOSE)
SELECT region_id, sales_report(region_id) FROM fact_orders GROUP BY region_id;
text
 Finalize HashAggregate  (cost=280000.00..280000.12 rows=240 width=12) (actual time=2180.3..2180.4 rows=240 loops=1)
   -- Finalize HashAggregate 在 leader 上汇总各组的部分结果
   Group Key: region_id
   Buffers: shared hit=1400 read=498020
   ->  Gather  (cost=1000.00..279400.00 rows=960 width=12) (actual time=2150.1..2180.0 rows=960 loops=1)
         -- Gather:960 = 240 组 × 4 个进程,说明每个进程各产出全部 240 组的局部结果
         Workers Planned: 4
         Workers Launched: 4
         -- 4 个 worker 全部启动成功
         ->  Partial HashAggregate  (actual time=2100.0..2120.0 rows=240 loops=4)
               -- loops=4:每个 worker 各做一遍局部聚合
               Group Key: region_id
               ->  Parallel Seq Scan on fact_orders  (actual time=1.5..1200.2 rows=5000000 loops=4)
                     -- Parallel Seq Scan 生效:每个 worker 只扫 1/4 的数据(2000万 / 4 = 500万)
                     -- 对比此前串行的 rows=20000000 loops=1
                     Buffers: shared hit=1400 read=498020
 Planning Time: 8.912 ms
 Execution Time: 2410.775 ms

耗时从 8420ms 降到 2411ms,加速比约 3.5×(4 个 worker 的理想上限是 4×)。关键证据是 Parallel Seq Scan 的 rows 变成原来的 1/4、loops=4——这才证明数据真的被分摊了。

场景二:并行了但更慢,定位到 Gather 上方的排序瓶颈 ​

背景:报表查询从串行改成并行后,耗时反而从 1800ms 涨到 2600ms。

第一步,看两个版本的计划对比:

sql
-- 串行版本(通过关掉并行模拟)
SET max_parallel_workers_per_gather = 0;
EXPLAIN (ANALYZE, BUFFERS)
SELECT region_id, sum(amount) AS total
FROM fact_orders
GROUP BY region_id
ORDER BY total DESC
LIMIT 100;
text
 Limit  (cost=1085000.00..1085000.50 rows=100 width=16) (actual time=1790.2..1790.4 rows=100 loops=1)
   -- 串行:整体 1790ms
   Buffers: shared hit=1200 read=498020
   ->  Sort  (cost=1085000.00..1085010.00 rows=240 width=16) (actual time=1790.0..1790.1 rows=100 loops=1)
         -- 串行时排序只处理 240 行(聚合后),非常快
         Sort Key: (sum(amount)) DESC
         Sort Method: top-N heapsort  Memory: 32kB
         ->  HashAggregate  (cost=... rows=240 width=16) (actual time=1750.5..1780.2 rows=240 loops=1)
               Group Key: region_id
               ->  Seq Scan on fact_orders  (actual time=1.2..1200.4 rows=20000000 loops=1)
sql
-- 并行版本
RESET max_parallel_workers_per_gather;
EXPLAIN (ANALYZE, VERBOSE, BUFFERS)
SELECT region_id, sum(amount) AS total
FROM fact_orders
GROUP BY region_id
ORDER BY total DESC
LIMIT 100;
text
 Limit  (cost=280000.00..280001.00 rows=100 width=16) (actual time=2600.5..2600.7 rows=100 loops=1)
   -- 并行版本反而 2600ms,比串行慢了 800ms
   Buffers: shared hit=1400 read=498020
   ->  Sort  (cost=280000.00..280001.00 rows=960 width=16) (actual time=2598.0..2598.5 rows=100 loops=1)
         -- 排序在 leader 单进程执行,且要处理 960 行 partial 结果(240 组 × 4 进程)
         Sort Key: (sum(amount)) DESC
         Sort Method: quicksort  Memory: 96kB
         ->  Gather  (cost=1000.00..279400.00 rows=960 width=16) (actual time=2150.1..2560.0 rows=960 loops=1)
               -- Gather 在 2560ms 才返回全部 960 行
               Workers Planned: 4
               Workers Launched: 4
               ->  Partial HashAggregate  (actual time=2100.0..2140.0 rows=240 loops=4)
                     -- 每个 worker 产 240 行;960 = 240 × 4,全部经 Gather 搬给 leader
                     Group Key: region_id
                     ->  Parallel Seq Scan on fact_orders  (actual time=1.5..1200.2 rows=5000000 loops=4)
                           Buffers: shared hit=1400 read=498020
 Planning Time: 9.104 ms
 Execution Time: 2600.883 ms

问题分析:扫描确实并行化了(Parallel Seq Scan,每个 worker 500 万行),但:

  1. Partial HashAggregate 每个 worker 产出 240 行(全部 240 个组),4 个 worker 共 960 行;
  2. 这 960 行全经 Gather 搬给 leader,leader 的 Sort 要排序 960 行——是串行版本 240 行的 4 倍;
  3. sort 在 2598ms 才完成,而 Gather 在 2560ms 完成,说明 leader 端确实成了尾巴。

为什么会这样:Partial HashAggregate 只做「局部」聚合,每个 worker 都必须输出它见过的所有分组(240 个),无法预先「只保留前 100」。真正的收敛(Finalize)只能等 leader 汇总。于是并行带来的扫描提速,被「搬运 960 行 + leader 排序」部分吃掉了。

处理方案:

sql
-- 方案 A:对这类「并行扫描收益 < leader 收敛成本」的查询,主动关掉并行
SET max_parallel_workers_per_gather = 0;
-- 串行版本 1790ms,反而更快
sql
-- 方案 B:把 ORDER BY ... LIMIT 的收敛逻辑交给应用层,或改写查询减少分组数
-- (例如先按天预聚合到汇总表,再查询汇总表)

结论:并行不是无条件更快。当并行部分产出的行数远大于最终结果集时,Gather 的搬运与 leader 端的排序/去重会抵消收益。判断依据是「并行部分输出行数」与「串行扫描节省的时间」的对比,而不是只看有没有 Parallel 前缀。

注意事项 ​

  1. 不要只看 Workers Planned 就断定并行执行了。Planned 只是规划器的意图,不加 ANALYZE 根本看不到 Workers Launched。

    sql
    -- 错误!这样看不出 worker 是否真的启动了
    EXPLAIN SELECT count(*) FROM fact_orders;
  2. 不要忽略 Gather 之上的节点。 并行只覆盖 Gather 以下的部分,上方的 Sort、HashAggregate 仍是 leader 单进程,常常就是新瓶颈。

    text
    不推荐的结构(leader 端压力过大):
      Sort                    ← leader 单进程处理 960 行
        ->  Gather
              ->  Parallel Seq Scan
  3. 不要把 Workers Launched < Planned 当成 bug 去报。 它是资源竞争的正常信号,正确反应是排查 worker 池与并发度。

    sql
    -- 错误!这不是配置错误,而是池子/并发问题
    -- 期望:Workers Launched 必须等于 Workers Planned
  4. 不要在 LIMIT 分页查询上强行制造并行。LIMIT 与 Gather 的语义冲突,清零成本参数换来的并行计划通常更慢。

    sql
    -- 不推荐:为了看并行而清零成本参数,然后跑分页查询
    SET parallel_setup_cost = 0;
    SET parallel_tuple_cost = 0;
    EXPLAIN (ANALYZE) SELECT * FROM fact_orders ORDER BY id LIMIT 20;
  5. 不要忽视 Batches > 1。 哈希落盘意味着额外的磁盘 I/O,会直接抵消并行收益。

    text
    不推荐:work_mem 不足,Parallel Hash 溢出
      Parallel Hash  (actual time=...)
        Buckets: 262144  Batches: 8  Memory Usage: 8192kB
                             ^^^^^^^^^^ 落盘 8 批
  6. 不要在事务里反复查询统计视图并期望数字变化。 累积统计在事务内被缓存,同一事务多次查询会得到同一份快照。

    sql
    -- 不推荐:以为循环里读到的是实时值
    FOR i IN 1..5 LOOP
      SELECT parallel_workers_launched FROM pg_stat_database WHERE datname = current_database();
      -- 值不会变
    END LOOP;
  7. 不要用 EXPLAIN (ANALYZE) 去「测量」高负载下的并行效果。 它本身会引入 profiling 开销,且会真实执行语句——INSERT/UPDATE/DELETE 的副作用照常发生。

    sql
    -- 错误!这会真的删数据
    EXPLAIN (ANALYZE) DELETE FROM fact_orders WHERE status = 'cancelled';
    
    -- 正确做法:包在事务里回滚
    BEGIN;
    EXPLAIN (ANALYZE) DELETE FROM fact_orders WHERE status = 'cancelled';
    ROLLBACK;
  8. 不要在所有节点上都开着 TIMING 做大规模观测。 反复读取系统时钟在某些平台上开销显著,高 loops 节点尤其明显。

    sql
    -- 不推荐:只想看行数分布,却开了 TIMING
    EXPLAIN (ANALYZE, TIMING ON, VERBOSE) SELECT ... ;
  9. 不要把 I/O 瓶颈误判为并行度不足。 若 shared read 远大于 shared hit,瓶颈在磁盘,加 worker 只会增加排队。

    text
    不推荐:命中率极低时继续加大并行度
      Buffers: shared hit=4210 read=496880   ← 命中率 0.8%
  10. 不要假设「并行部分输出行数 = 最终结果行数」。Partial Aggregate 每个 worker 都要输出全部组,行数会按 worker 数放大。

    text
    240 个组 × 4 个 worker = 960 行经 Gather 搬运
    —— 这是 Finalize 阶段的真实输入规模
  11. 不要忘记 VERBOSE 才能看到 per-worker 明细。 没有它,你无法判断负载是否均衡、也无法发现「某个 worker 提前空转」。

    sql
    -- 不推荐:想查均衡性却没用 VERBOSE
    EXPLAIN (ANALYZE) SELECT count(*) FROM fact_orders;

最佳实践 ​

  1. 固定一套诊断顺序:先 EXPLAIN 看形状 → 再 EXPLAIN (ANALYZE, BUFFERS) 看 Launched → 再 (ANALYZE, VERBOSE) 看均衡 → 最后查 pg_stat_activity 确认资源。

    sql
    EXPLAIN SELECT ...;                                  -- ① 形状
    EXPLAIN (ANALYZE, BUFFERS) SELECT ...;               -- ② 实际启动数 + I/O
    EXPLAIN (ANALYZE, VERBOSE) SELECT ...;               -- ③ per-worker 均衡
  2. 把「并行部分输出行数」作为是否该并行的第一判据。 若它远大于最终结果集(例如分组数接近输入行数),并行往往得不偿失。

    text
    适合并行:2000 万行 → 聚合后 240 行(收敛比 8 万 : 1)
    不适合:  2000 万行 → 聚合后 800 万行(收敛比 2.5 : 1)
  3. 做「开/关并行」的对照实验并记录结论。 用 SET LOCAL max_parallel_workers_per_gather = 0 在事务内模拟串行,避免影响其它会话。

    sql
    BEGIN;
    SET LOCAL max_parallel_workers_per_gather = 0;
    EXPLAIN (ANALYZE, BUFFERS) SELECT ... ;   -- 串行基线
    COMMIT;
  4. 用 EXPLAIN (SETTINGS) 确认会话级参数真的生效。 尤其是当你在应用框架或连接池里设置参数时,很难确认它是否传到了规划阶段。

    sql
    EXPLAIN (ANALYZE, SETTINGS) SELECT count(*) FROM fact_orders;
  5. 用 JSON 格式做两次计划的自动化比对。 手工看几百行输出容易漏,脚本比对更可靠。

    sql
    EXPLAIN (ANALYZE, FORMAT JSON) SELECT ... ;
  6. 给并行查询接上实例级长期监控。 把 parallel_workers_to_launch 与 parallel_workers_launched 按时间采样,算出「启动率」作为池子容量指标。

    sql
    SELECT datname,
           parallel_workers_to_launch              AS planned,
           parallel_workers_launched               AS launched,
           round(100.0 * parallel_workers_launched
                 / NULLIF(parallel_workers_to_launch, 0), 1) AS launch_pct
    FROM pg_stat_database
    WHERE datname IS NOT NULL;
  7. 实时观测用 leader_pid,别用 backend_type 的全表扫描。 建一个按 leader_pid 聚合的视图,能一次看清「哪条查询占了多少 worker」。

    sql
    SELECT leader_pid, count(*) AS workers
    FROM pg_stat_activity
    WHERE leader_pid IS NOT NULL
    GROUP BY leader_pid
    ORDER BY 2 DESC;
  8. 对并行维护命令用进度视图做可视化。 大表建索引动辄数十分钟,pg_stat_progress_create_index 能让运维知道「还剩多少」。

    sql
    SELECT pid, command, phase,
           blocks_done || '/' || blocks_total AS blocks,
           tuples_done || '/' || tuples_total AS tuples
    FROM pg_stat_progress_create_index;
  9. 把 I/O 观测纳入并行调优流程。 用 BUFFERS 判断瓶颈在 CPU 还是磁盘;命中率低时先解决 I/O,再考虑并行度。

    sql
    EXPLAIN (ANALYZE, BUFFERS) SELECT count(*) FROM fact_orders WHERE status = 'paid';
  10. 关注最慢 worker,而不是平均耗时。 并行完成时间由最慢的 worker 决定;若各 worker 差异显著,先解决数据分布或缓存分布的不均。

    text
    Worker 0: actual time=2.1..1870.0   ← 瓶颈
    Worker 1: actual time=2.0..1869.5
    Worker 2: actual time=2.1..1868.9
    Worker 3: actual time=95.0..410.2   ← 大幅提前完成,资源浪费
  11. 为并行查询单独维护一份「不该并行」的清单。 例如深分页、LIMIT 小结果、热点小表查询,写进文档并在代码评审时对照。

  12. 在报表会话模板里固定诊断参数。 例如只在该会话内提高 work_mem 并保留 VERBOSE 开关,避免每次都手工敲一长串选项。

    sql
    -- 报表会话初始化脚本
    SET work_mem = '64MB';
    SET max_parallel_workers_per_gather = 4;
    SET track_io_timing = on;

下一节

继续学习:并行聚合与 Parallel Append,深入了解三阶段并行聚合与 Parallel Append 的内部机制与调优要点。

参考链接 ​