PostgreSQL 的执行器(Executor)采用经典的火山模型(Volcano / Iterator Model):每个计划节点实现 ExecProcNode(),向上层返回一行元组,层层拉取直到顶层节点取完。这个模型简单、内存友好,但天然是单线程的——一次查询只能用一个 CPU 核心。从 9.6 开始,PostgreSQL 引入并行查询(Parallel Query),把计划树的一部分「复制」给多个后台工作进程(Background Worker),由 Gather 节点汇聚结果,从而让一条查询吃满多核。
并行查询不是自动加速开关。它依赖表足够大、代价估算足够高、查询本身可并行(没有并行不安全的函数),还受 max_parallel_workers 等全局资源约束。理解执行器如何分裂计划树、如何汇聚结果、哪些节点可并行,才能在 EXPLAIN 里读懂并行的真实收益,而不是被一个 Gather 节点误导。
一、执行器与并行模型
1.1 火山模型与节点调用链
传统执行器的每个节点是一个「迭代器」。以 SELECT count(*) FROM big WHERE k > 100 为例,计划树大致是:
Aggregate
-> Seq Scan on big
执行时,Aggregate 节点在 ExecAgg 里反复调用下层 Seq Scan 的 ExecProcNode,每次拿一行,累加到计数上。整个过程中只有一个后端进程(Backend)在跑,top 里只能看到一个 PID 占满一个核。
1.2 并行查询如何分裂计划树
开启并行后,PostgreSQL 在计划树中插入一个Gather节点。Gather 之下是「并行部分(parallel portion)」,会被复制成 N 份分别交给 N 个 worker;Gather 之上是「串行部分(leader)」,由发起查询的进程执行。
Finalize Aggregate
-> Gather
Workers Planned: 2
-> Partial Aggregate
-> Parallel Seq Scan on big
关键点:
- leader 也参与工作。默认 leader 会执行一份并行部分(参数
parallel_leader_participation = on),所以Workers Planned: 2实际可能是 3 个进程在扫描。这让小结果集时不会因为 leader 空等而浪费。 - 并行部分必须「可并行」。计划器只会把支持并行的节点放进去,遇到并行不安全的函数、临时表、游标(
FOR UPDATE)等,就会退回串行计划。 - worker 数量是「计划值」不是「保证值」。实际启动多少 worker 取决于运行时的
max_parallel_workers空闲槽位,EXPLAIN ANALYZE里的Workers Launched才是真实数字。
1.3 三种汇聚节点
| 节点 | 作用 | 结果是否有序 |
|---|---|---|
Gather | 收集各 worker 结果,顺序不确定 | 否 |
Gather Merge | 归并各 worker 已排序的结果 | 是(保序) |
Append(并行) | 分区表各分区并行扫描 | 否 |
Gather Merge 用在并行部分本身带 Sort 的场景(如 ORDER BY + LIMIT),它做的是 k 路归并,比「全部收集后再排序」更省内存。但要注意,只有当并行子节点是 Sort 或有序索引扫描时才可能生成 Gather Merge。
二、并行度决策:代价模型
2.1 三个关键参数
| 参数 | 默认值 | 含义 |
|---|---|---|
max_parallel_workers_per_gather | 2 | 单个 Gather 最多用几个 worker |
max_parallel_workers | 8 | 实例级并行 worker 总数上限 |
min_parallel_table_scan_size | 8MB | 表小于此值不并行 |
min_parallel_index_scan_size | 512kB | 索引小于此值不并行 |
parallel_setup_cost | 1000 | 启动并行框架的固定代价 |
parallel_tuple_cost | 0.1 | 每个元组经 Gather 传输的代价 |
规划器不是「表大就并行」,而是比较串行计划与并行计划的总代价。并行计划省下的是扫描与处理时间,但要额外付出:
parallel_setup_cost:一次性开销,启动 worker、建立共享内存队列。parallel_tuple_cost × 行数:每行从 worker 通过共享内存队列传给 leader 的成本。
因此高吞吐、低输出的查询最适合并行:大表聚合、大表 JOIN、大表扫描。反之,返回百万行的 SELECT * 反而会因为传输成本而变慢。
2.2 手工干预并行度
表级别的并行度可以显式声明,避免规划器保守估算:
ALTER TABLE big SET (parallel_workers = 4);
也可以用 USING 提示或 SET 临时覆盖:
SET max_parallel_workers_per_gather = 4;
SET parallel_setup_cost = 100;
SET parallel_tuple_cost = 0.01;
SET min_parallel_table_scan_size = '1MB';
调低 parallel_setup_cost / parallel_tuple_cost 会让规划器更倾向并行。这在批处理、报表场景(单条大查询、并发低)很有效,但在高并发 OLTP 里会适得其反——每条查询都拉起 worker,反而加剧上下文切换与 CPU 争抢。
2.3 并行的代价收益判断
一个粗略的经验公式:设扫描表耗时 T、返回行数 R,并行 N 路理论上扫描时间约 T/N,但要多付 parallel_setup_cost 与 R × parallel_tuple_cost。只有当
T - T/N > parallel_setup_cost + R × parallel_tuple_cost
时并行才划算。这解释了为什么「小表返回大量行」和「大表返回极少行」都不适合并行:前者传输成本高,后者串行本来就快。
EXPLAIN (ANALYZE) 里对比并行与串行计划的 actual time,是验证这个公式最直接的方式。执行计划的深度解读可参考 PostgreSQL 查询优化实战
。
三、并行扫描节点
3.1 Parallel Seq Scan
并行顺序扫描把表的块范围(block range) 划分给各 worker,每个 worker 只读自己负责的块。这是最常见的并行节点,也是并行收益最稳定的。
Gather (actual time=1200.3..1200.4 rows=1 loops=1)
Workers Planned: 4
Workers Launched: 4
-> Partial Aggregate (actual time=1180.2..1180.2 rows=1 loops=1)
-> Parallel Seq Scan on big
(actual time=0.03..950.6 rows=2500000 loops=1)
Parallel Seq Scan 后面 loops=1 但 rows 是每个 worker 的平均行数,总行数是它乘以 worker 数。这是初学者最容易误读的地方。
块范围的分配是动态的:worker 每次取一小段块(默认约 4 个块一批),做完再取下一段。这天然实现了负载均衡,不会因为某段数据密集而某个 worker 拖后腿。
3.2 Parallel Index Scan 与 Parallel Bitmap Heap Scan
并行索引扫描按索引的页划分给 worker:
-> Parallel Index Scan using idx_big_k on big
它适用于「索引选择性高但仍需读大量堆页」的场景。更常见的是并行位图堆扫描(Parallel Bitmap Heap Scan):各 worker 先并行构建位图,再并行读堆页。
-> Parallel Bitmap Heap Scan on big
Recheck Cond: (k > 100)
-> Bitmap Index Scan on idx_big_k
并行位图扫描的收益在于把「位图构建」与「堆页读取」都并行了,但它有个陷阱:位图在 leader 与 worker 间共享,worker 数越多,位图同步的锁开销越大。位图特别大时,并行反而可能变慢。
3.3 并行度与索引的关系
小索引不会触发并行(受 min_parallel_index_scan_size 限制)。如果发现一个「应该并行」的索引扫描没并行,先检查索引大小是否超过阈值,再检查代价模型是否判定串行更便宜。索引类型的选择本身也会影响可并行性,细节见 PostgreSQL 索引类型深度实战
。
3.4 分区表的并行扫描
分区表可以并行扫描,但形态与普通表不同:计划树顶层是 Append,其下每个分区各自可以是串行或并行。典型形态是「跨分区并行」——每个分区交给一个 worker:
Gather
-> Append
-> Parallel Seq Scan on events_2026_09
-> Parallel Seq Scan on events_2026_10
-> Parallel Seq Scan on events_2026_11
这种结构下,并行度受分区数约束:如果只有 3 个分区,即使用 8 个 worker 也只能同时扫 3 个。因此「分区数 < worker 数」时,并行的实际收益受限。反过来,分区过多(数百个)又会让 Append 的调度开销变大。
分区表的并行度估算还有个特殊之处:规划器按「父表总行数」判断是否并行,但实际扫描量取决于分区裁剪。若裁剪后只剩一个很小的分区,规划器仍可能给出并行计划,反而得不偿失。排查这类问题的方法与分区裁剪的验证手段一致。
4.5 并行与锁
并行 worker 读取的是查询开始时的同一快照(snapshot),因此并行查询天然一致,不会看到「一半旧数据一半新数据」。但并行 worker 也会持有其访问对象的锁:
- 并行扫描会在分区/表上持有
AccessShareLock,会阻塞ACCESS EXCLUSIVE(如ALTER TABLE)。 - 因此一个长跑的并行查询可能让 DDL 一直排队,进而阻塞后续所有查询。这是「并行查询引发雪崩」的典型链路,锁的排查手法见后续诊断章节。
四、并行 JOIN 与聚合
4.1 Partial Aggregate / Finalize Aggregate
聚合是并行的最佳搭档。规划器把 Aggregate 拆成两层:
Finalize Aggregate
-> Gather
-> Partial Aggregate
-> Parallel Seq Scan
每个 worker 在本地做部分聚合(Partial Aggregate),只把中间状态(如 count 的部分和、avg 的 (sum, count))传给 leader,leader 在 Finalize Aggregate 里合并。这大幅减少了传输量——百万行的 count(*) 只需传 N 个整数。
但并非所有聚合函数都可并行。count、sum、min、max、avg 支持「部分聚合」;而 array_agg、string_agg、json_agg 这类有序聚合没有并行合并函数,只能退化成 Gather 后串行聚合,或者干脆不并行。
可以用下面的查询确认某个聚合函数是否可并行:
SELECT p.proname, p.proparallel
FROM pg_proc p
WHERE p.proname IN ('count', 'sum', 'avg', 'array_agg', 'string_agg');
proparallel 取值:s = safe(可并行)、r = restricted(只能在 leader 执行)、u = unsafe(完全不可并行)。任何出现在并行部分的 unsafe 函数都会让整个计划退回串行。
4.2 Parallel Hash Join
Hash Join 是并行最友好的连接算法。并行版本分两个阶段:
- 构建阶段:各 worker 并行扫描内侧表,构建共享哈希表(Shared Hash Table),通过共享内存与锁协调。
- 探测阶段:各 worker 并行扫描外侧表,探测共享哈希表。
-> Gather
-> Parallel Hash Join
Hash Cond: (o.user_id = u.id)
-> Parallel Seq Scan on orders o
-> Parallel Hash
-> Parallel Seq Scan on users u
共享哈希表的构建是有锁竞争的:worker 之间用屏障(barrier) 同步,每个 worker 负责构建哈希表的一部分桶。EXPLAIN 里的 Buckets、Batches 能看出哈希表是否放得下内存。
相比之下,Nested Loop 不能并行(内侧被反复探测,无法共享),Merge Join 可以并行但收益有限(归并本质上是顺序的,只有两侧的排序可以并行)。因此并行查询里最常见的是 Hash Join。
4.3 并行排序
Sort 在并行部分里是「每个 worker 各自排序自己那段」,最终由 Gather Merge 归并。这意味着 ORDER BY ... LIMIT 在大表上可以走:
Limit
-> Gather Merge
-> Sort (each worker sorts its own slice)
-> Parallel Seq Scan
每个 worker 只排自己的部分,Gather Merge 做 k 路归并,比「全部收集再排序」省下大量内存与比较次数。
4.4 并行 CREATE INDEX 与维护操作
并行不止用于查询。从 11 开始,CREATE INDEX 也能并行构建,由独立参数控制:
max_parallel_maintenance_workers = 4
SET max_parallel_maintenance_workers = 4;
CREATE INDEX idx_big_k ON big (k);
并行建索引把「扫描表 + 排序 + 写索引」分摊到多个 worker,在 TB 级表上能把建索引时间缩短数倍。它消耗的是维护 worker(max_parallel_maintenance_workers),与查询用的 max_parallel_workers 分开计量,因此不会互相挤占——但两者都受 max_worker_processes 总上限约束。
VACUUM 目前不支持并行,CLUSTER 在较新版本中支持并行。做批量维护前,先用 pg_stat_activity 确认没有正在运行的并行查询,避免维护 worker 与查询 worker 争抢 CPU。
五、并行陷阱与调优
5.1 并行不安全的操作
以下情况会阻止并行,让计划退回串行:
- 查询里出现
unsafe标记的函数(自建 PL/pgSQL 函数默认就是unsafe,除非显式声明)。 - 使用临时表(
CREATE TEMP TABLE后的查询)。 - 声明为
PARALLEL UNSAFE的函数或触发器。 - 查询涉及游标
FOR UPDATE/FOR SHARE。 - 使用
WITH RECURSIVE递归查询(递归部分无法并行)。 - 序列的
nextval(写操作不并行)。
修复自建函数的可并行性:
CREATE OR REPLACE FUNCTION my_add(a int, b int)
RETURNS int
LANGUAGE sql
PARALLEL SAFE
AS $$ SELECT a + b $$;
对 PL/pgSQL 函数,只有确认它不修改数据库状态、不访问序列、不使用临时表时,才应标注 PARALLEL SAFE。误标会引发难以复现的数据竞争。
5.2 Gather 成为瓶颈
Gather 节点本身是单点:所有 worker 的结果都汇聚到 leader 的一个共享内存队列里。当返回行数极大时,Gather 会成为瓶颈,甚至比串行还慢。判断方法:看 EXPLAIN ANALYZE 里 Gather 的 actual time 是否远大于其子节点。
缓解手段:
- 在并行部分尽量做「减少行数」的操作(
WHERE过滤、部分聚合),让传回 leader 的行数变少。 - 对「返回大量行」的查询关掉并行:
SET max_parallel_workers_per_gather = 0;。 - 用
Gather Merge替代Gather以支持流式输出(LIMIT场景)。
5.3 资源争抢与 worker 池
max_parallel_workers 是实例级的:所有并发查询共享这批 worker。若 10 条查询各要 4 个 worker,总共需要 40 个,超过上限后后面的查询拿不到 worker,只能退化成「计划并行、实际串行」,或者排队等待。
这在高并发 OLTP 里是隐蔽的性能杀手。合理配置:
# 物理核数决定上限,留出 leader 与后台进程的余量
max_worker_processes = 16
max_parallel_workers = 12
max_parallel_workers_per_gather = 2
max_worker_processes 必须 ≥ max_parallel_workers + 逻辑复制 worker + 其它后台 worker 的总和。改小 max_parallel_workers_per_gather 能让并行更「公平」地分摊给并发查询,而不是让少数查询独占。
调优这类全局参数需要结合整体负载,系统性的方法可参考 PostgreSQL 性能调优 。
5.4 统计信息不准导致误判并行
规划器基于统计信息估算行数,再据此决定并行度。如果统计信息陈旧,估算的行数严重偏离实际,可能:
- 估算行数小 → 判定并行不划算 → 该并行没并行。
- 估算行数大 → 判定该并行 → 实际行数少 → 白付
parallel_setup_cost。
因此并行查询调优的前提是统计信息准确。定期 ANALYZE、调高 default_statistics_target、对相关列建扩展统计,都是必要的。统计信息的原理与调优见 PostgreSQL 统计信息与 ANALYZE
。
六、诊断实战
6.1 读懂 EXPLAIN 的并行字段
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT u.city, count(*) AS cnt, avg(o.amount) AS avg_amt
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.created_at >= '2026-01-01'
GROUP BY u.city
ORDER BY cnt DESC
LIMIT 20;
关注以下字段:
| 字段 | 含义 |
|---|---|
Workers Planned | 规划器希望启动的 worker 数 |
Workers Launched | 实际启动的 worker 数(受全局池限制) |
rows 在 Parallel 节点下 | 每个 worker 的平均行数 |
loops | worker 内部迭代次数 |
Gather 的 actual time | 汇聚耗时,判断是否成瓶颈 |
若 Workers Launched < Workers Planned,说明实例级 worker 池不足,需要调大 max_parallel_workers。
6.2 观察运行中的并行进程
查询执行时,并行 worker 会出现在 pg_stat_activity 里,backend_type 为 parallel worker:
SELECT pid, leader_pid, backend_type, state, query
FROM pg_stat_activity
WHERE backend_type = 'parallel worker'
ORDER BY leader_pid;
leader_pid 指向发起查询的后端。若看到大量 parallel worker 处于 active,说明实例正在高并行负载下运行,此时新查询可能拿不到 worker。可以结合 pg_stat_statements 统计哪些查询最常触发并行:
SELECT query, calls, mean_exec_time, rows
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;
6.3 强制串行做对照
判断并行是否真的带来收益,最直接的办法是关掉并行跑一遍:
SET max_parallel_workers_per_gather = 0;
EXPLAIN (ANALYZE) SELECT ...; -- 串行基线
RESET max_parallel_workers_per_gather;
EXPLAIN (ANALYZE) SELECT ...; -- 并行对照
若并行版本的 actual time 反而更高,多半是 Gather 传输瓶颈或 worker 争抢。此时应结合 5.2 / 5.3 的手段调整,而不是盲目加大并行度。
6.4 常见误区
| 误区 | 事实 |
|---|---|
看到 Gather 就一定更快 | 传输成本可能让并行更慢,需对照实验 |
rows 是总行数 | Parallel 节点下 rows 是每个 worker 的平均值 |
Workers Planned 就是实际并行度 | 实际看 Workers Launched |
| 并行度越高越快 | 受 worker 池与 Gather 单点约束,存在最优值 |
把 max_parallel_workers_per_gather 调很大就好 | 会挤占并发查询的 worker,OLTP 场景反而变慢 |
把这张表当成自查清单:每次准备「开大并行」前,逐条确认自己不是因为某个误区而做的决定。
小结
PostgreSQL 并行查询的本质是「把计划树的并行部分复制给多个 worker,用 Gather 汇聚」。它的收益来自多核并行扫描、部分聚合、并行哈希连接;成本来自 parallel_setup_cost 与逐行传输的 parallel_tuple_cost。因此大表、低输出、纯读、可并行的查询才是并行的理想场景。落地时把握四条:统计信息要准、自建函数要标 PARALLEL SAFE、实例级 worker 池要够、返回行数大时主动关并行。用 Workers Launched 与串行对照实验验证收益,而不是看到 Gather 就以为快了。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。