Skip to content

并行查询原理(Parallel Query) ​

一条 SQL 默认只由一个后端(backend)进程从头做到尾,无论服务器有多少个空闲 CPU 核心。当查询需要扫描几千万行、或对一张大表做全表聚合时,瓶颈往往不是磁盘也不是内存,而是单个 CPU 核心的算力。并行查询(parallel query)就是把一个查询拆成若干份,交给多个进程同时执行,最后把结果汇总起来。

PostgreSQL 从 9.6 开始提供生产可用的并行查询;到 PostgreSQL 18,并行机制已经相当成熟:顺序扫描、位图堆扫描、索引扫描、各类连接、聚合、Append 都能并行化,并新增了对异步 I/O(AIO)子系统的支持,进一步提升了顺序扫描与位图堆扫描的吞吐。但并行不是「打开就更快」的开关——它有自己的适用边界、成本模型和资源消耗代价。

本节从「并行查询是怎么跑起来的」入手,讲清楚进程模型、计划形状与取舍逻辑,为后面两节(配置参数与具体并行计划)打基础。

前置知识

阅读本节前,建议先了解:查询重写与优化手段

基础概念 ​

三个角色:leader、worker 与 Gather ​

一个并行的查询计划里,进程分成两种角色:

角色说明
leader(领导者进程)也就是用户连接对应的那个后端进程。它负责规划、启动 worker、读取 worker 产出的元组,并执行 Gather 节点之上的处理
worker(并行工作进程)由 leader 请求的后台工作进程(background worker),每个都完整执行一次「计划的并行部分」
Gather / Gather Merge计划树中的「汇合点」,它是 leader 侧唯一能看到 worker 结果的节点

关键点在于:每个 worker 都完整执行一遍计划的并行部分,而不是每个 worker 只执行一小段。

partial plan(部分计划)的概念 ​

正因为每个 worker 都会执行同一份子计划,这份子计划必须构造成「每个进程只产出整体结果的一个子集」——官方文档把这种计划称为 partial plan(部分计划):

text
普通计划(不能并行)
    ┌────────────┐
    │  Seq Scan  │  完整扫描 → 全部行
    └────────────┘
   如果直接让 N 个 worker 各跑一遍 → 得到 N 份完整结果(错误)

partial plan(可并行)
    ┌─────────────────────┐
    │ Parallel Seq Scan   │  每个 worker 只扫描自己认领的数据块区间
    └─────────────────────┘
    N 个 worker 各跑一遍 → 合起来恰好是完整结果(正确)

因此并行的前提是:驱动表的扫描必须是「并行感知」(parallel-aware)的扫描。这也是为什么 Gather 下面那个节点,名字前面常常带 Parallel 前缀。

一个最小的并行计划长什么样 ​

官方文档给出的经典例子:

sql
EXPLAIN SELECT * FROM pgbench_accounts WHERE filler LIKE '%x%';
text
                                            QUERY PLAN
--------------------------------------------------------------------------------------------------
 Gather  (cost=1000.00..217018.43 rows=1 width=97)
   Workers Planned: 2
   ->  Parallel Seq Scan on pgbench_accounts  (cost=0.00..216018.33 rows=1 width=97)
         Filter: (filler ~~ '%x%'::text)
(4 rows)

读这个计划要注意三点:

  1. 顶层是 Gather,说明整个查询都在并行部分里(Gather 在计划树最顶端)。
  2. Workers Planned: 2 是规划器打算启动的 worker 数量;真实启动了几个,要在 ANALYZE 之后看 Workers Launched。
  3. Gather 的唯一子节点 Parallel Seq Scan 就是「并行部分」。Filter 写在扫描节点上,说明过滤也在 worker 里做完了。

并行不是「越多越快」 ​

并行的成本模型里有两条固定开销:

参数默认值含义
parallel_setup_cost1000启动并行 worker 的固定代价(进程 fork、内存初始化等)
parallel_tuple_cost0.1从 worker 向 leader 传递每一行的代价

注意 parallel_tuple_cost 是按行计费的。这意味着:如果并行部分会产出海量行,把它们全部搬回 leader 的成本会迅速吃掉并行收益。这正是 Finalize Aggregate 在「分组数接近输入行数」时会变得不划算的原因。

一个直觉性的判据 ​

查询特征是否适合并行
大表全表扫描 + 高选择性过滤(返回行少)非常适合
大表全表扫描 + 聚合(分组数远小于输入行数)适合
小表查询(低于 min_parallel_table_scan_size)不适合,规划器不会选
只取前几行的分页查询(LIMIT 10)通常不适合
高并发的短事务 OLTP 查询通常不适合,反而增加进程开销

语法与用法 ​

并行查询没有专门的 SQL 语法——它不是 SELECT ... PARALLEL 这种写法,而是由规划器自动决定。使用者能做的只有三件事:观察、控制参数、必要时用对象级选项干预。

观察:确认计划是否真的并行 ​

sql
-- 静态计划:看形状(有没有 Gather / Parallel 前缀节点)
EXPLAIN SELECT count(*) FROM orders WHERE status = 'paid';

-- 实际执行:看真实启动了几个 worker
EXPLAIN (ANALYZE, BUFFERS) SELECT count(*) FROM orders WHERE status = 'paid';

ANALYZE 之后 Gather 节点会多出一行:

text
 Gather  (cost=1000.00..118450.32 rows=1 width=8) (actual time=312.551..312.556 rows=1 loops=1)
   Workers Planned: 2
   Workers Launched: 2
   Buffers: shared hit=4210 read=49688
  • Workers Planned 与实际 Workers Launched 不一致时,说明 worker 池不够用(见下文「计划生成但跑不起来」)。
  • 如果只看到 Workers Planned 而 EXPLAIN 里根本没有 Gather,说明规划器判断这个查询不该并行。

观察每个 worker 的负载是否均衡 ​

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

加了 VERBOSE 之后,ANALYZE 会为每个计划节点输出每个 worker 的独立统计(Worker 0:、Worker 1: 这样的行),可以用来判断工作是否被均匀分摊。官方文档明确指出这一点:

When executing a parallel plan, you can use EXPLAIN (ANALYZE, VERBOSE) to display per-worker statistics for each plan node.

调整:临时验证计划 ​

sql
-- 会话内临时关闭并行,用来对比「并行 vs 串行」的真实差异
SET max_parallel_workers_per_gather = 0;
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;

-- 恢复
RESET max_parallel_workers_per_gather;

干预:对象级 worker 数量 ​

除了全局参数,还可以给单张表指定期望的 worker 数量:

sql
-- 存储参数 parallel_workers,覆盖基于表大小自动推断的结果
ALTER TABLE orders SET (parallel_workers = 4);

-- 取消该设置,回到自动推断
ALTER TABLE orders RESET (parallel_workers);

该选项只影响「这张表被扫描时希望用几个 worker」,最终仍受 max_parallel_workers_per_gather 与 worker 池上限的约束。

干预:函数与聚合的并行安全性 ​

自定义函数默认是 PARALLEL UNSAFE,会让整个查询失去并行能力。如果确认函数无副作用,应显式声明:

sql
CREATE FUNCTION normalize_phone(p text) RETURNS text
LANGUAGE sql
IMMUTABLE
PARALLEL SAFE           -- 允许在并行 worker 中执行
AS $$ SELECT regexp_replace(p, '[^0-9]', '', 'g') $$;

聚合函数则使用 PARALLEL 选项:

sql
CREATE AGGREGATE my_sum(numeric) (
    SFUNC = numeric_add,
    STYPE = numeric,
    COMBINEFUNC = numeric_add,
    PARALLEL = SAFE
);

详细说明 ​

并行查询的执行流程 ​

把一次并行查询拆成时间线,大致是:

text
1. 后端进程(leader)解析 SQL、生成计划
     计划树里包含 Gather / Gather Merge 节点
2. 执行到 Gather 节点
     leader 向 postmaster 申请 N 个 background worker
3. 每个 worker 拿到「计划的并行部分」并完整执行
     leader 自己通常也执行一份(parallel_leader_participation)
4. worker 把产出的元组通过共享内存队列发给 leader
5. leader 消费这些元组,继续执行 Gather 之上的节点
6. worker 退出,资源归还 worker 池

其中第 2 步的「申请」是可能失败的:worker 并不是随时都能拿到。拿不到时,leader 会退化为自己单独执行 Gather 以下的全部内容,几乎等同于没有 Gather 节点——计划形状没变,但实际是串行跑的。

为什么必须有 partial plan ​

这是并行查询最容易被误解的一点,值得单独强调。

假设不加改造,直接让 3 个 worker 各跑一遍 SELECT * FROM big_table:

text
worker 0 → 返回 1000 万行
worker 1 → 返回 1000 万行   ← 完全重复
worker 2 → 返回 1000 万行   ← 完全重复
leader 汇总 → 3000 万行(错误结果)

所以计划的并行部分必须保证:每一条输出行恰好由一个进程产出。对顺序扫描而言,实现方式是「按数据块切分」:

text
表的数据块: [1 .. 40000]

worker 0 认领 [1 .. 10000),扫完后再来要下一段
worker 1 认领 [10000 .. 20000),扫完后再来要下一段
worker 2 认领 [20000 .. 30000),扫完后再来要下一段
...

这种「认领区间、扫完再要」的机制让各进程自然负载均衡,而不是静态平均分块(后者在数据倾斜时会严重不均)。

Gather 与 Gather Merge ​

顶层汇合节点只有两个,它们的区别在于是否保持顺序:

节点行为何时出现
Gather以任意方便的顺序读取 worker 的元组,破坏已有的排序大多数情况
Gather Merge假定每个 worker 的输出已有序,leader 做保序归并并行部分本身产出有序结果,且上层需要有序(如 ORDER BY、归并连接)

Gather Merge 的价值在于:如果每个 worker 都已经按 ORDER BY 键有序输出,leader 只需要做一次 k 路归并,而不必把全部行汇总后再排序一遍。

有没有 Gather Merge 受 enable_gathermerge(默认 on)控制。

何时根本不会生成并行计划 ​

有一类条件是「决定性」的——只要命中,规划器连考虑都不会考虑并行:

条件说明
查询会写入数据或锁定行顶层或 CTE 中存在数据修改操作,整个查询不并行
查询可能被「挂起」并分次执行例如 DECLARE CURSOR、PL/pgSQL 的 FOR x IN query LOOP
查询使用了标记为 PARALLEL UNSAFE 的函数用户自定义函数默认就是 UNSAFE
查询本身运行在另一个并行查询内部并行 worker 里再发起 SQL 查询时,那个查询不会并行

关于「写入数据」,官方给了一个重要例外清单——以下语句的底层 SELECT 部分可以并行:

  • CREATE TABLE ... AS
  • SELECT INTO
  • CREATE MATERIALIZED VIEW
  • REFRESH MATERIALIZED VIEW

原因很直观:这些语句虽然最终会写数据,但「读」与「写」是分离的,读的部分完全可以并行。

此外,还有两个「开关型」前提:

  • max_parallel_workers_per_gather 必须 大于 0,否则永远不会生成并行计划。
  • 服务器不能运行在单用户模式(single-user mode)下——此时整个数据库只有一个进程,没有 background worker 可用。

计划生成了,但执行时跑不起来 ​

即使规划器生成了并行计划,运行时也可能拿不到 worker,此时 leader 会独自执行 Gather 之下的全部内容。触发条件有三类:

条件说明
max_worker_processes 上限系统级后台工作进程总数已达上限
max_parallel_workers 上限专用于并行查询的 worker 数已达上限
客户端发送了带非零 fetch count 的 Execute 消息使用扩展查询协议且不走 libpq 的客户端可能触发

这三者中最常见的是前两个:并发一高,worker 池被占满,后面的查询就悄悄退化成串行。这也是为什么在高并发 OLTP 场景里,直接放开并行往往适得其反。

PostgreSQL 18 新增了专门的监控字段来量化这件事:pg_stat_database 与 pg_stat_statements 中的 parallel_workers_to_launch(规划器打算启动的 worker 数)与 parallel_workers_launched(实际启动的 worker 数)。两者之差就是「worker 短缺量」。

并行安全标记:SAFE / RESTRICTED / UNSAFE ​

规划器把查询中涉及的操作分成三档:

标记能否在 worker 中执行能否在 leader 中执行命中后的影响
PARALLEL SAFE能能不影响并行
PARALLEL RESTRICTED不能能该操作不能出现在 Gather 以下,但仍可在 Gather 之上执行
PARALLEL UNSAFE不能不能整个查询完全禁用并行

用户自定义函数与聚合的默认值是 PARALLEL UNSAFE(官方明确说明:无法自动判断,因为等价于停机问题)。因此升级后常见的现象是:

text
原本并行的查询,引入一个自建函数后变串行
→ 该函数默认 PARALLEL UNSAFE

需要注意的还有两点:

  1. RESTRICTED 不会让规划器想办法绕开。官方特别指出:规划器不会为了避免并行受限而去「推迟」受限函数的求值。也就是说,如果某个 WHERE 条件里用了 PARALLEL RESTRICTED 的函数,规划器不会考虑把这张表的扫描放进并行部分——哪怕理论上可以「先扫码、后在 Gather 之上过滤」。
  2. 误标的后果严重。把实际不安全/受限的函数标成 SAFE,可能导致并行查询抛错或产生错误结果。拿不准时应标 UNSAFE。

永远属于 parallel restricted 的操作 ​

有四类操作天然是 RESTRICTED,它们无法出现在 Gather 以下:

text
- CTE(公用表表达式)的扫描
- 临时表的扫描
- 外部表(foreign table)的扫描,
  除非其 FDW 提供了 IsForeignScanParallelSafe 接口表明安全
- 引用「相关 SubPlan」的计划节点

这解释了两个常见困惑:

  • 为什么 WITH 里的大表扫描没并行?因为 CTE 扫描是 RESTRICTED。把 CTE 改成子查询(或加 NOT MATERIALIZED)往往就能恢复并行。
  • 为什么 temp_table 上的大查询跑不快?临时表扫描不能并行。

worker 数量是怎么定出来的 ​

规划器选择 worker 数量时,主要受两个因素约束:

第一,硬上限。 单个 Gather 节点能使用的 worker 数不超过 max_parallel_workers_per_gather(默认 2)。这是一个「每 Gather」的限制。

第二,数据量门槛。 只有扫描的数据量足够大时才值得并行:

参数默认值作用
min_parallel_table_scan_size8MB要并行做表扫描,至少要扫这么多数据
min_parallel_index_scan_size512kB要并行做索引扫描,至少要访问这么多索引数据

数据量越大,规划器倾向于安排越多 worker(每个 worker 至少要「分摊」到一定量的数据),但最终不会超过 max_parallel_workers_per_gather。

如果某张表你明确知道「用它的时候应该多开几个 worker」,可以用对象级参数直接指定:

sql
ALTER TABLE fact_orders SET (parallel_workers = 6);

leader 是否也参与干活 ​

parallel_leader_participation(默认 on,PostgreSQL 11 起支持)控制leader 进程是否也执行并行部分。

  • on:leader 也当「一个 worker」用。当并行部分产出行数很少时,leader 的表现几乎等同于多了一个 worker,能实打实提速。
  • 即使 on,当并行部分产出行数很多时,leader 会忙于读取 worker 的结果、执行 Gather 之上的节点,此时它几乎不做并行部分的工作。

必要时可以关掉它,让 leader 专心做汇总——这在 worker 数量已经很多、leader 成为瓶颈时有意义:

sql
SET parallel_leader_participation = off;

并行带来的资源放大效应 ​

并行不是免费的,它的资源消耗是乘法式的:

资源放大方式
内存每个 worker 独立持有自己的 work_mem(排序、哈希表)。N 个 worker 就是 N 份
连接槽位每个 worker 都是完整后端,占用进程/连接资源
CPUN+1 个进程同时跑,可能打满 CPU 甚至引发上下文切换
启动开销进程创建、内存初始化,由 parallel_setup_cost 建模

一个很具体的例子:work_mem = 64MB、max_parallel_workers_per_gather = 4,那么一条排序查询在最坏情况下会同时占用约 64MB × 4 = 256MB(再加上 leader 自己那份)。多条这样的查询并发时,内存会迅速被吃满。

PostgreSQL 18 中与并行相关的变化 ​

PostgreSQL 18(发布于 2025-09-25)在并行与其周边能力上有几处值得注意的演进:

变化与本主题的关系
异步 I/O(AIO)子系统改善顺序扫描、位图堆扫描等操作的性能,间接提升并行扫描部分的效率
pg_stat_database / pg_stat_statements 新增 parallel_workers_to_launch、parallel_workers_launched第一次可以量化「规划了却没启动」的 worker,定位 worker 池不足
pg_upgrade 保留优化器统计升级后不会因为统计信息缺失而短期打乱并行计划的选择
btree skip scan让多列 btree 索引在更多场景下可用,可能改变「扫描 vs 并行扫描」的成本对比

需要强调的是:并行查询本身的核心机制(Gather、partial plan、安全标记)在 PG18 中没有推翻性变化,本节所讲的模型适用于 9.6 以来的所有版本。

实战示例 ​

场景一:判断一条慢查询「为什么没并行」 ​

背景:orders 表有 2000 万行,一条统计查询跑 42 秒。硬件是 16 核,max_parallel_workers_per_gather 是默认的 2。开发同学期待它并行执行,但计划里看不到 Gather。

sql
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT o.region,
       count(*)                    AS order_cnt,
       normalize_phone(max(o.tel)) AS sample_tel
FROM orders o
WHERE o.created_at >= now() - interval '30 days'
GROUP BY o.region;
text
                                                  QUERY PLAN
---------------------------------------------------------------------------------------------------------------
 HashAggregate  (cost=612043.55..612046.05 rows=200 width=48) (actual time=42831.402..42831.409 rows=6 loops=1)
   Output: region, count(*), normalize_phone(max(tel))
   Group Key: o.region
   Buffers: shared hit=21044 read=1180244
   ->  Seq Scan on public.orders o  (cost=0.00..559995.12 rows=4167697 width=28) (actual time=0.512..37204.116 rows=4189820 loops=1)
         Output: region, tel
         Filter: (o.created_at >= (now() - '30 days'::interval))
         Rows Removed by Filter: 15810210
         Buffers: shared hit=21044 read=1180244
 Planning Time: 0.312 ms
 Execution Time: 42831.856 ms

关键证据:整个计划里没有一个 Gather,也没有任何 Parallel 前缀节点。这不是「计划里有并行但没启动」,而是「规划器根本没打算并行」。

逐项排查:

sql
-- 1) 并行开关是否关着?预期返回 2
SHOW max_parallel_workers_per_gather;

-- 2) 函数是否声明了并行安全性?预期返回 u(unsafe)
SELECT p.proname, p.proparallel
FROM pg_proc p
WHERE p.proname IN ('normalize_phone', 'max');

-- 3) 表的规模是否达到并行门槛?
SELECT relname, relpages * 8192 / 1024 / 1024 AS approx_mb
FROM pg_class WHERE relname = 'orders';
text
 max_parallel_workers_per_gather
---------------------------------
 2
(1 row)

   proname        | proparallel
------------------+-------------
 normalize_phone  | u
 max              | s
(2 rows)

 relname | approx_mb
---------+-----------
 orders  |      9734
(1 row)

结论一目了然:

  • 开关没关(2)。
  • 表有 9.7 GB,远超 min_parallel_table_scan_size = 8MB。
  • 问题出在 normalize_phone 的 proparallel = 'u'(UNSAFE)——用户自定义函数默认就是 UNSAFE,它让整个查询被禁用并行。

修复(前提是这个函数确实是无副作用的纯函数):

sql
ALTER FUNCTION normalize_phone(text) PARALLEL SAFE;

再跑一次:

text
                                                     QUERY PLAN
---------------------------------------------------------------------------------------------------------------------
 Finalize HashAggregate  (cost=318317.44..318318.44 rows=200 width=48) (actual time=6124.902..6124.906 rows=6 loops=1)
   Group Key: o.region
   Buffers: shared hit=63132 read=1612280
   ->  Gather  (cost=278317.04..318307.44 rows=400 width=48) (actual time=6118.220..6124.774 rows=18 loops=1)
         Workers Planned: 2
         Workers Launched: 2
         Buffers: shared hit=63132 read=1612280
         ->  Partial HashAggregate  (cost=278317.04..278317.44 rows=200 width=48) (actual time=6002.331..6002.334 rows=6 loops=3)
               Group Key: o.region
               ->  Parallel Seq Scan on public.orders o  (cost=0.00..269700.12 rows=1736540 width=28) (actual time=0.412..4820.116 rows=1396606 loops=3)
                     Filter: (o.created_at >= (now() - '30 days'::interval))
                     Rows Removed by Filter: 5267403
 Execution Time: 6133.412 ms

从 42.8 秒降到 6.1 秒,约 7 倍。计划形状也变成了标准的并行聚合三段式:

text
Partial HashAggregate(每个 worker 各自聚合一份)
      ↑
   Gather(2 个 worker,Workers Launched: 2)
      ↑
Finalize HashAggregate(leader 汇总各 worker 的部分结果)

注意 Partial HashAggregate 的 loops=3:2 个 worker + 1 个 leader(因为 parallel_leader_participation 默认为 on),共计 3 份。

场景二:worker 规划了却启动不了,并行「白规划」 ​

背景:同一套库,白天业务高峰时这条查询从 6 秒退回到 30 多秒,但 SHOW max_parallel_workers_per_gather 依然是 2,计划里也有 Gather。

sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM orders WHERE status = 'paid';
text
 Aggregate  (cost=269700.12..269700.13 rows=1 width=8) (actual time=31842.554..31842.556 rows=1 loops=1)
   Buffers: shared hit=982 read=1180244
   ->  Gather  (cost=1000.00..269114.12 rows=1894320 width=0) (actual time=1024.331..31244.882 rows=1894321 loops=1)
         Workers Planned: 2
         Workers Launched: 0          <-- 注意这里
         Buffers: shared hit=982 read=1180244
         ->  Parallel Seq Scan on public.orders  (cost=0.00..268114.12 rows=789300 width=0) (actual time=1.204..29980.116 rows=1894321 loops=1)
               Filter: (status = 'paid'::text)
 Execution Time: 31842.998 ms

Workers Launched: 0 就是答案:计划是并行的,但一个 worker 都没拿到,leader 只好独自把 Parallel Seq Scan 从头跑到尾。

用 PG18 的新字段进一步坐实这一点:

sql
SELECT datname,
       parallel_workers_to_launch,
       parallel_workers_launched,
       parallel_workers_to_launch - parallel_workers_launched AS worker_shortage
FROM pg_stat_database
WHERE datname = current_database();
text
 datname | parallel_workers_to_launch | parallel_workers_launched | worker_shortage
---------+----------------------------+---------------------------+-----------------
 shopdb  |                      18422 |                      6104 |           12318
(1 row)

计划要 18422 个 worker,实际只启动 6104 个,短缺 12318 个。再看当前时刻的 worker 占用:

sql
SELECT count(*) FILTER (WHERE leader_pid IS NOT NULL) AS parallel_workers_now
FROM pg_stat_activity;
text
 parallel_workers_now
----------------------
                   16
(1 row)

16 个 worker 正在跑,说明 worker 池被并发查询占满了。对照参数:

sql
SHOW max_worker_processes;    -- 8(默认)
SHOW max_parallel_workers;    -- 8(默认)

默认值只有 8,而当前有 16 个并行 worker 在跑——显然这台机器上调过参数,但相对高并发的负载仍然不够。

三种处理方向:

sql
-- 方向一:如果这台机器确实适合并行(分析型、CPU 充足),提高 worker 池
--   max_worker_processes 需要重启;max_parallel_workers 需要重启或 reload
ALTER SYSTEM SET max_worker_processes = 24;
ALTER SYSTEM SET max_parallel_workers = 24;
-- 重启后生效

-- 方向二:如果是高并发 OLTP,反过来收紧并行,避免「规划了却拿不到」
--   让规划器少规划 worker,计划与运行时更一致
ALTER SYSTEM SET max_parallel_workers_per_gather = 2;

-- 方向三:在特定会话里干脆关掉并行,得到稳定的串行计划
SET max_parallel_workers_per_gather = 0;

核心认知:Workers Planned 与 Workers Launched 不一致,不是 bug,而是资源竞争的信号。它导致的最坏情况是「按并行计划的成本估算选了这条路,实际却串行执行」——比一开始就选串行计划还要慢。

注意事项 ​

  1. 不要在 OLTP 主库上盲目调大 max_parallel_workers_per_gather。 单个查询会占用 N 个 worker 进程,每个进程都是完整后端(独立内存、独立 work_mem)。高并发下会迅速耗尽 worker 池与内存。

    sql
    -- 错误!默认 2 之外的激进上调,需先压测
    ALTER SYSTEM SET max_parallel_workers_per_gather = 16;
  2. 自定义函数不要「顺手」标成 PARALLEL SAFE。 凡是有副作用(写表、访问序列、改配置、改事务状态)的函数,标错会导致并行查询抛错或结果错误。拿不准时保持默认的 UNSAFE。

    sql
    -- 不推荐:函数体内有 INSERT,却标成 SAFE
    CREATE FUNCTION log_and_count() RETURNS bigint
    LANGUAGE plpgsql PARALLEL SAFE      -- 错误!函数会写表
    AS $$ BEGIN INSERT INTO audit_log(...) VALUES (...); RETURN 1; END $$;
  3. 不要把 max_parallel_workers 设得比 max_worker_processes 还大。 并行 worker 是从 max_worker_processes 建立的池子里取的,超出部分无效。

    sql
    -- 错误!max_worker_processes 仍是 8,这里设 32 不会生效
    ALTER SYSTEM SET max_parallel_workers = 32;
  4. 不要在事务里「先看一眼统计」再依赖它。 累积统计在事务内会被缓存(由 stats_fetch_consistency 控制),同一事务里多次查询会得到同一份快照。

    sql
    -- 不推荐:以为这里读到的是最新值
    BEGIN;
    SELECT parallel_workers_launched FROM pg_stat_database WHERE datname = current_database();
    -- ... 其他操作 ...
    SELECT parallel_workers_launched FROM pg_stat_database WHERE datname = current_database();  -- 值没变
    ROLLBACK;
  5. 不要用 DECLARE CURSOR 处理大结果集,还期待它并行。 游标会被分次读取,规划器认为「查询可能被挂起」,因此永远不会生成并行计划。

    sql
    -- 不推荐:这个大查询不可能并行
    BEGIN;
    DECLARE cur CURSOR FOR SELECT * FROM huge_table WHERE flag;
    FETCH 1000 FROM cur;
    COMMIT;
  6. 注意 Gather 的位置决定了「并行范围」。Gather 在计划树顶端 ≠ 整个查询并行;它在中间时,只有它下面的部分并行,上面的节点(如 Sort、HashAggregate)仍是 leader 单进程执行。

    text
    Sort                          ← 单进程(在 Gather 之上)
      ->  Gather
            ->  Parallel Seq Scan ← 并行
  7. parallel_tuple_cost 放大后会让计划「看起来变贵」。 如果并行部分产出行数巨大,规划器可能主动放弃并行。这时降低 parallel_setup_cost/parallel_tuple_cost 只是「强行」让它选并行,不一定是好事。

    sql
    -- 不推荐:为了看并行计划而无脑清零这两个参数
    SET parallel_setup_cost = 0;
    SET parallel_tuple_cost = 0;
  8. CTE 与临时表会静默地阻断并行。 它们属于永久 PARALLEL RESTRICTED 的操作,不会报错,只会让并行范围缩小。

    sql
    -- 不推荐:CTE 扫描是 RESTRICTED,大表扫描无法并行
    WITH recent AS MATERIALIZED (
        SELECT * FROM orders WHERE created_at >= now() - interval '1 day'
    )
    SELECT count(*) FROM recent;
  9. 并行不是「必须保留」的特性。 当查询是短事务、返回行很少、或已经吃满 CPU 时,串行往往更快。用 SET max_parallel_workers_per_gather = 0 做 A/B 对比是很正常的调优手段。

    sql
    -- 合理的诊断流程:同一查询在开/关并行下各跑一次,比实际耗时
    EXPLAIN (ANALYZE) SELECT ...;
    sql
    SET LOCAL max_parallel_workers_per_gather = 0;
    EXPLAIN (ANALYZE) SELECT ...;
    sql
    RESET max_parallel_workers_per_gather;
    EXPLAIN (ANALYZE) SELECT ...;
  10. 单用户模式下调不出并行。 单用户模式下整个数据库只有一个进程,没有 background worker,任何并行计划都不会生成——这不是配置问题。

最佳实践 ​

  1. 先确认「该不该并行」,再调「怎么并行」。 用查询的返回行数、表大小、并发度三个维度判断;返回行极少的大表扫描、聚合型报表最适合并行。

  2. 升级到 PG18 后,先把并行相关的监控字段接上。 持续观察 parallel_workers_to_launch 与 parallel_workers_launched 的差值,差值长期偏大说明 worker 池不足或并发过高。

    sql
    SELECT datname,
           parallel_workers_to_launch,
           parallel_workers_launched,
           CASE WHEN parallel_workers_to_launch > 0
                THEN round(100.0 * parallel_workers_launched / parallel_workers_to_launch, 1)
                ELSE NULL END AS launch_pct
    FROM pg_stat_database
    WHERE datname IS NOT NULL
    ORDER BY parallel_workers_to_launch DESC;
  3. 让 worker 池参数彼此自洽。 保证 max_worker_processes >= max_parallel_workers >= max_parallel_workers_per_gather,并且给其他后台进程(逻辑复制、autovacuum)留出余量。

    text
    max_worker_processes            = 24   -- 总池子(需重启)
    max_parallel_workers            = 16   -- 其中给并行查询的上限
    max_parallel_workers_per_gather = 4    -- 单条查询的上限
  4. 优先用 ALTER TABLE ... SET (parallel_workers = N) 做「按表」的精细控制,而不是全局调大 max_parallel_workers_per_gather。这样只有真正的大表才会用更多 worker。

    sql
    ALTER TABLE fact_orders     SET (parallel_workers = 6);
    ALTER TABLE dim_customers   SET (parallel_workers = 0);
  5. 审计用户自定义函数的并行标记。 迁移到新版本或引入新函数后,检查是否有本该 SAFE 的函数还停留在默认的 UNSAFE。

    sql
    SELECT n.nspname, p.proname, p.proparallel
    FROM pg_proc p
    JOIN pg_namespace n ON n.oid = p.pronamespace
    WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
      AND p.proparallel = 'u'
    ORDER BY 1, 2;
  6. 给并行查询留足 work_mem 的预算。 记住实际内存占用约为 work_mem × (worker 数 + 1);宁可调小单节点 work_mem 配合更多 worker,也不要让单窗口内存失控。

  7. 诊断慢查询时固定一个「对照实验」流程:EXPLAIN (ANALYZE, BUFFERS) → 再看 (ANALYZE, VERBOSE) 的 per-worker 统计 → 若 Workers Launched 不足,转向资源排查。

  8. 不要依赖 SETTINGS/参数清零来「制造」并行计划。 参数只是诊断手段,真实的修复方向通常是补统计信息、修 SQL 写法、或修函数标记。

  9. 对报表类查询考虑使用只读副本。 把并行的 CPU 与内存开销从主库挪走,是最省事的隔离手段;主库上的并行则应更保守。

  10. 把「并行 vs 串行」的对比结论记录下来。 同一条 SQL 在不同数据量、不同并发下最优解可能不同,写进运维手册比凭记忆调参可靠。

  11. 监控 pg_stat_activity.leader_pid,随时看清当前有多少并行 worker 在跑,用于判断是否已触及资源天花板。

    sql
    SELECT count(*)                     AS total_workers,
           count(DISTINCT leader_pid)   AS parallel_queries
    FROM pg_stat_activity
    WHERE leader_pid IS NOT NULL;
  12. 不要给每一张表都设 parallel_workers。 只给少数确实需要的大型事实表设置,其它保持自动推断,避免参数散落难维护。

下一节

继续学习:并行配置参数,了解控制并行行为的各个 GUC 参数及其默认值与取舍。

参考链接 ​