PostgreSQL 并行查询与执行器原理

PostgreSQL 并行查询(Parallel Query)全解析:执行器调用链、Gather / Gather Merge 汇聚节点、并行 Seq Scan 与并行位图扫描、Parallel Hash Join 与 Partial Aggregate、代价模型与并行度决策、max_parallel_workers 等参数调优、并行不安全函数与常见陷阱。

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_gather2单个 Gather 最多用几个 worker
max_parallel_workers8实例级并行 worker 总数上限
min_parallel_table_scan_size8MB表小于此值不并行
min_parallel_index_scan_size512kB索引小于此值不并行
parallel_setup_cost1000启动并行框架的固定代价
parallel_tuple_cost0.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 是并行最友好的连接算法。并行版本分两个阶段:

  1. 构建阶段:各 worker 并行扫描内侧表,构建共享哈希表(Shared Hash Table),通过共享内存与锁协调。
  2. 探测阶段:各 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 的平均行数
loopsworker 内部迭代次数
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 就以为快了。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「database」更多文章

  1. PostgreSQL 锁与阻塞分析
  2. COPY 与批量数据加载优化
  3. pgvector 向量检索与混合查询