并行计划的操作与诊断(Parallel Plan Operations)
参数配好了,不代表计划就照着跑。真实系统里最常见的两类并行问题,一类是「计划看起来是并行的,实际没并行」——Workers Launched 为 0 或远小于 Planned;另一类是「并行了反而更慢」——worker 之间严重不均衡、Gather 上方成了新瓶颈、或者内存被放大到触发落盘。
本节聚焦操作层:如何读懂并行计划的每一行输出、如何用 EXPLAIN (ANALYZE, VERBOSE) 判断各 worker 是否均衡、如何用 pg_stat_activity 与进度视图观察正在运行的并行查询,以及一组可复用的诊断流程。至于并行聚合与 Parallel Append 的内部机制,留到下一节专门展开。
前置知识
阅读本节前,建议先了解:并行配置参数
基础概念
并行计划输出的四类关键行
读并行计划,先建立「哪些行是并行特有的」这个概念。并行计划里额外的信息只有四类:
| 输出行 | 出现在哪 | 含义 |
|---|---|---|
Workers Planned: N | Gather / 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 的区别在输出上的体现
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 Scan | Parallel Seq Scan | 按块区间分片 |
Bitmap Heap Scan | Parallel Bitmap Heap Scan | leader 先建位图,再分片扫堆 |
Index Scan / Index Only Scan | Parallel Index Scan / Parallel Index Only Scan | 仅 btree,按索引块轮流认领 |
Hash | Parallel Hash | 共享哈希表(DSM) |
Hash Join | Hash Join(无 Parallel 前缀时每进程各建一份) | 注意区别 |
Aggregate | Partial Aggregate + Finalize Aggregate | 两阶段 |
Append | Parallel Append | 进程分散到多个子计划 |
「并行范围」由 Gather 的位置决定
一个常见误解是「看到 Gather 就是整条查询并行」。实际只有 Gather 以下的部分并行:
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_* | 长期来看「规划了却没启动」的比例是多少? |
三层配合,才能区分「单条查询的问题」和「实例资源的问题」。
语法与用法
观察一条查询是否真的并行
-- 只看形状:是否有 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 的独立统计
EXPLAIN (ANALYZE, VERBOSE)
SELECT count(*) FROM fact_orders WHERE status = 'paid';VERBOSE 会让 ANALYZE 为每个计划节点额外输出 per-worker 明细,形如:
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 之间均匀分配。
控制输出详略,减少观测开销
-- 只要行数不要时间:关掉 TIMING,显著降低高调用次数节点的时钟开销
EXPLAIN (ANALYZE, TIMING OFF, VERBOSE) SELECT ... ;
-- 只看并行结构,忽略成本估算
EXPLAIN (COSTS OFF) SELECT ... ;
-- 结构化输出,便于脚本比对两次计划
EXPLAIN (ANALYZE, FORMAT JSON) SELECT ... ;查看规划时实际生效的「非默认」参数
-- SETTINGS 会列出所有「与内置默认值不同」的规划相关参数
EXPLAIN (ANALYZE, SETTINGS)
SELECT count(*) FROM fact_orders WHERE status = 'paid';输出里会多一段:
Settings: max_parallel_workers_per_gather = '4', work_mem = '64MB'这是确认「某个会话级 SET 是否真的影响了规划」最可靠的方式——比 SHOW 更贴近本次规划的实际取值。
观察正在运行的并行查询
-- 按类型统计当前进程
SELECT backend_type, count(*)
FROM pg_stat_activity
GROUP BY backend_type
ORDER BY 2 DESC; backend_type | count
-------------------+-------
client backend | 42
parallel worker | 18
autovacuum worker | 1
logical replication worker | 2-- 找出每个并行查询的 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 启动
-- PostgreSQL 18 新增的两个字段
SELECT datname,
parallel_workers_to_launch,
parallel_workers_launched
FROM pg_stat_database
WHERE datname = current_database();这两个字段是累积计数器,配合时间窗口采样才能看出趋势。
观察维护命令的并行进度
-- CREATE INDEX / REINDEX 的进度(含并行构建阶段)
SELECT pid, command, phase, blocks_total, blocks_done, tuples_total, tuples_done
FROM pg_stat_progress_create_index;-- 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 = 0 | worker 一个都没启动 | 池子被占满,或事务/会话状态限制 |
只有 Planned,没有 Launched | 没加 ANALYZE | 加上 ANALYZE 再看 |
整个计划里没有 Gather | 规划器压根没选并行 | 转向「为什么不能并行」 |
关键认知:Launched 小于 Planned 不是 bug,而是资源竞争的信号。它最坏的后果是——规划器按「并行更便宜」的成本估算选了这条路,执行时却几乎串行,结果比一开始就选串行计划更慢(因为还多付了并行启动成本)。
per-worker 统计怎么用来判断均衡
VERBOSE 输出的 Worker 0:、Worker 1: 行,是判断负载是否均衡的直接依据。看两个量:
- 每个
Worker n的actual time(或rows)是否接近; Workers Planned与真实参与工作的 worker 数量是否一致。
典型的不均衡长这样:
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:
-- 看缓冲命中率:如果命中率很低,说明大部分时间在等磁盘
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM fact_orders WHERE status = 'paid'; 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:
-> 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 上方的节点耗时占比。
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行时间差异极大(有的被强制结束)。
-- 分页查询:通常看不到并行,这是正常的
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——因为它们本质上是「读 + 建新对象」。
-- 不能并行:游标
BEGIN;
DECLARE c CURSOR FOR SELECT count(*) FROM fact_orders;
FETCH 1 FROM c;
ROLLBACK;用 EXPLAIN 的 SETTINGS 与 GENERIC_PLAN 做对照
两个容易被忽略的选项,在并行诊断中很有用:
-- ① 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)来说,它回答的是「不依赖参数值的那个计划会不会并行」——这常常与应用实际使用的计划不是同一个。
三类「无法并行」的定位顺序
遇到「查询没并行」,按这个顺序排查最省时间:
① 计划里有没有 Gather?
没有 → 是「不允许并行」的问题
→ 检查:写操作?游标?UNSAFE 函数?
有 → 继续 ②
② Workers Launched 是多少?
0 → 资源问题(池子被占满)
0 < x < Planned → 资源竞争
= Planned → 并行正常执行,问题在别处(③)
③ 并行范围是否被截断?
看 Gather 的位置:上方是否还有 Sort/HashAggregate
看是否有 RESTRICTED 操作(CTE、临时表)实战示例
场景一:并行度上不去,逐层排除定位到 UNSAFE 函数
背景:sales_report() 是一个 PL/pgSQL 函数,用于按区域汇总销售额。参数都调好了,但计划里始终没有 Gather。
第一步,确认参数没问题:
EXPLAIN (ANALYZE, SETTINGS)
SELECT region_id, sales_report(region_id) FROM fact_orders GROUP BY region_id; 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,且门槛被临时清零),但计划全串行——说明问题不在参数。
第二步,检查是否调用了非并行安全的函数:
SELECT p.oid::regprocedure AS func, p.proparallel
FROM pg_proc p
WHERE p.proname = 'sales_report'; func | proparallel
---------------------+-------------
sales_report(integer) | uproparallel = 'u'(PARALLEL UNSAFE)。函数体内如果只是聚合查询、不写表、不动序列,是可以安全并行的。
第三步,确认函数体无副作用后改为 SAFE:
ALTER FUNCTION sales_report(integer) PARALLEL SAFE;第四步,验证:
EXPLAIN (ANALYZE, VERBOSE)
SELECT region_id, sales_report(region_id) FROM fact_orders GROUP BY region_id; 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。
第一步,看两个版本的计划对比:
-- 串行版本(通过关掉并行模拟)
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; 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)-- 并行版本
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; 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 万行),但:
Partial HashAggregate每个 worker 产出 240 行(全部 240 个组),4 个 worker 共 960 行;- 这 960 行全经
Gather搬给 leader,leader 的Sort要排序 960 行——是串行版本 240 行的 4 倍; sort在 2598ms 才完成,而Gather在 2560ms 完成,说明 leader 端确实成了尾巴。
为什么会这样:Partial HashAggregate 只做「局部」聚合,每个 worker 都必须输出它见过的所有分组(240 个),无法预先「只保留前 100」。真正的收敛(Finalize)只能等 leader 汇总。于是并行带来的扫描提速,被「搬运 960 行 + leader 排序」部分吃掉了。
处理方案:
-- 方案 A:对这类「并行扫描收益 < leader 收敛成本」的查询,主动关掉并行
SET max_parallel_workers_per_gather = 0;
-- 串行版本 1790ms,反而更快-- 方案 B:把 ORDER BY ... LIMIT 的收敛逻辑交给应用层,或改写查询减少分组数
-- (例如先按天预聚合到汇总表,再查询汇总表)结论:并行不是无条件更快。当并行部分产出的行数远大于最终结果集时,Gather 的搬运与 leader 端的排序/去重会抵消收益。判断依据是「并行部分输出行数」与「串行扫描节省的时间」的对比,而不是只看有没有 Parallel 前缀。
注意事项
不要只看
Workers Planned就断定并行执行了。Planned只是规划器的意图,不加ANALYZE根本看不到Workers Launched。sql-- 错误!这样看不出 worker 是否真的启动了 EXPLAIN SELECT count(*) FROM fact_orders;不要忽略
Gather之上的节点。 并行只覆盖Gather以下的部分,上方的Sort、HashAggregate仍是 leader 单进程,常常就是新瓶颈。text不推荐的结构(leader 端压力过大): Sort ← leader 单进程处理 960 行 -> Gather -> Parallel Seq Scan不要把
Workers Launched < Planned当成 bug 去报。 它是资源竞争的正常信号,正确反应是排查 worker 池与并发度。sql-- 错误!这不是配置错误,而是池子/并发问题 -- 期望:Workers Launched 必须等于 Workers Planned不要在
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;不要忽视
Batches > 1。 哈希落盘意味着额外的磁盘 I/O,会直接抵消并行收益。text不推荐:work_mem 不足,Parallel Hash 溢出 Parallel Hash (actual time=...) Buckets: 262144 Batches: 8 Memory Usage: 8192kB ^^^^^^^^^^ 落盘 8 批不要在事务里反复查询统计视图并期望数字变化。 累积统计在事务内被缓存,同一事务多次查询会得到同一份快照。
sql-- 不推荐:以为循环里读到的是实时值 FOR i IN 1..5 LOOP SELECT parallel_workers_launched FROM pg_stat_database WHERE datname = current_database(); -- 值不会变 END LOOP;不要用
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;不要在所有节点上都开着
TIMING做大规模观测。 反复读取系统时钟在某些平台上开销显著,高loops节点尤其明显。sql-- 不推荐:只想看行数分布,却开了 TIMING EXPLAIN (ANALYZE, TIMING ON, VERBOSE) SELECT ... ;不要把 I/O 瓶颈误判为并行度不足。 若
shared read远大于shared hit,瓶颈在磁盘,加 worker 只会增加排队。text不推荐:命中率极低时继续加大并行度 Buffers: shared hit=4210 read=496880 ← 命中率 0.8%不要假设「并行部分输出行数 = 最终结果行数」。
Partial Aggregate每个 worker 都要输出全部组,行数会按 worker 数放大。text240 个组 × 4 个 worker = 960 行经 Gather 搬运 —— 这是 Finalize 阶段的真实输入规模不要忘记
VERBOSE才能看到 per-worker 明细。 没有它,你无法判断负载是否均衡、也无法发现「某个 worker 提前空转」。sql-- 不推荐:想查均衡性却没用 VERBOSE EXPLAIN (ANALYZE) SELECT count(*) FROM fact_orders;
最佳实践
固定一套诊断顺序:先
EXPLAIN看形状 → 再EXPLAIN (ANALYZE, BUFFERS)看Launched→ 再(ANALYZE, VERBOSE)看均衡 → 最后查pg_stat_activity确认资源。sqlEXPLAIN SELECT ...; -- ① 形状 EXPLAIN (ANALYZE, BUFFERS) SELECT ...; -- ② 实际启动数 + I/O EXPLAIN (ANALYZE, VERBOSE) SELECT ...; -- ③ per-worker 均衡把「并行部分输出行数」作为是否该并行的第一判据。 若它远大于最终结果集(例如分组数接近输入行数),并行往往得不偿失。
text适合并行:2000 万行 → 聚合后 240 行(收敛比 8 万 : 1) 不适合: 2000 万行 → 聚合后 800 万行(收敛比 2.5 : 1)做「开/关并行」的对照实验并记录结论。 用
SET LOCAL max_parallel_workers_per_gather = 0在事务内模拟串行,避免影响其它会话。sqlBEGIN; SET LOCAL max_parallel_workers_per_gather = 0; EXPLAIN (ANALYZE, BUFFERS) SELECT ... ; -- 串行基线 COMMIT;用
EXPLAIN (SETTINGS)确认会话级参数真的生效。 尤其是当你在应用框架或连接池里设置参数时,很难确认它是否传到了规划阶段。sqlEXPLAIN (ANALYZE, SETTINGS) SELECT count(*) FROM fact_orders;用 JSON 格式做两次计划的自动化比对。 手工看几百行输出容易漏,脚本比对更可靠。
sqlEXPLAIN (ANALYZE, FORMAT JSON) SELECT ... ;给并行查询接上实例级长期监控。 把
parallel_workers_to_launch与parallel_workers_launched按时间采样,算出「启动率」作为池子容量指标。sqlSELECT 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;实时观测用
leader_pid,别用backend_type的全表扫描。 建一个按leader_pid聚合的视图,能一次看清「哪条查询占了多少 worker」。sqlSELECT leader_pid, count(*) AS workers FROM pg_stat_activity WHERE leader_pid IS NOT NULL GROUP BY leader_pid ORDER BY 2 DESC;对并行维护命令用进度视图做可视化。 大表建索引动辄数十分钟,
pg_stat_progress_create_index能让运维知道「还剩多少」。sqlSELECT pid, command, phase, blocks_done || '/' || blocks_total AS blocks, tuples_done || '/' || tuples_total AS tuples FROM pg_stat_progress_create_index;把 I/O 观测纳入并行调优流程。 用
BUFFERS判断瓶颈在 CPU 还是磁盘;命中率低时先解决 I/O,再考虑并行度。sqlEXPLAIN (ANALYZE, BUFFERS) SELECT count(*) FROM fact_orders WHERE status = 'paid';关注最慢 worker,而不是平均耗时。 并行完成时间由最慢的 worker 决定;若各 worker 差异显著,先解决数据分布或缓存分布的不均。
textWorker 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 ← 大幅提前完成,资源浪费为并行查询单独维护一份「不该并行」的清单。 例如深分页、
LIMIT小结果、热点小表查询,写进文档并在代码评审时对照。在报表会话模板里固定诊断参数。 例如只在该会话内提高
work_mem并保留VERBOSE开关,避免每次都手工敲一长串选项。sql-- 报表会话初始化脚本 SET work_mem = '64MB'; SET max_parallel_workers_per_gather = 4; SET track_io_timing = on;
下一节
继续学习:并行聚合与 Parallel Append,深入了解三阶段并行聚合与 Parallel Append 的内部机制与调优要点。