并行查询原理(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(部分计划):
普通计划(不能并行)
┌────────────┐
│ Seq Scan │ 完整扫描 → 全部行
└────────────┘
如果直接让 N 个 worker 各跑一遍 → 得到 N 份完整结果(错误)
partial plan(可并行)
┌─────────────────────┐
│ Parallel Seq Scan │ 每个 worker 只扫描自己认领的数据块区间
└─────────────────────┘
N 个 worker 各跑一遍 → 合起来恰好是完整结果(正确)因此并行的前提是:驱动表的扫描必须是「并行感知」(parallel-aware)的扫描。这也是为什么 Gather 下面那个节点,名字前面常常带 Parallel 前缀。
一个最小的并行计划长什么样
官方文档给出的经典例子:
EXPLAIN SELECT * FROM pgbench_accounts WHERE filler LIKE '%x%'; 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)读这个计划要注意三点:
- 顶层是
Gather,说明整个查询都在并行部分里(Gather在计划树最顶端)。 Workers Planned: 2是规划器打算启动的 worker 数量;真实启动了几个,要在ANALYZE之后看Workers Launched。Gather的唯一子节点Parallel Seq Scan就是「并行部分」。Filter写在扫描节点上,说明过滤也在 worker 里做完了。
并行不是「越多越快」
并行的成本模型里有两条固定开销:
| 参数 | 默认值 | 含义 |
|---|---|---|
parallel_setup_cost | 1000 | 启动并行 worker 的固定代价(进程 fork、内存初始化等) |
parallel_tuple_cost | 0.1 | 从 worker 向 leader 传递每一行的代价 |
注意 parallel_tuple_cost 是按行计费的。这意味着:如果并行部分会产出海量行,把它们全部搬回 leader 的成本会迅速吃掉并行收益。这正是 Finalize Aggregate 在「分组数接近输入行数」时会变得不划算的原因。
一个直觉性的判据
| 查询特征 | 是否适合并行 |
|---|---|
| 大表全表扫描 + 高选择性过滤(返回行少) | 非常适合 |
| 大表全表扫描 + 聚合(分组数远小于输入行数) | 适合 |
小表查询(低于 min_parallel_table_scan_size) | 不适合,规划器不会选 |
只取前几行的分页查询(LIMIT 10) | 通常不适合 |
| 高并发的短事务 OLTP 查询 | 通常不适合,反而增加进程开销 |
语法与用法
并行查询没有专门的 SQL 语法——它不是 SELECT ... PARALLEL 这种写法,而是由规划器自动决定。使用者能做的只有三件事:观察、控制参数、必要时用对象级选项干预。
观察:确认计划是否真的并行
-- 静态计划:看形状(有没有 Gather / Parallel 前缀节点)
EXPLAIN SELECT count(*) FROM orders WHERE status = 'paid';
-- 实际执行:看真实启动了几个 worker
EXPLAIN (ANALYZE, BUFFERS) SELECT count(*) FROM orders WHERE status = 'paid';ANALYZE 之后 Gather 节点会多出一行:
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=49688Workers Planned与实际Workers Launched不一致时,说明 worker 池不够用(见下文「计划生成但跑不起来」)。- 如果只看到
Workers Planned而EXPLAIN里根本没有Gather,说明规划器判断这个查询不该并行。
观察每个 worker 的负载是否均衡
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.
调整:临时验证计划
-- 会话内临时关闭并行,用来对比「并行 vs 串行」的真实差异
SET max_parallel_workers_per_gather = 0;
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
-- 恢复
RESET max_parallel_workers_per_gather;干预:对象级 worker 数量
除了全局参数,还可以给单张表指定期望的 worker 数量:
-- 存储参数 parallel_workers,覆盖基于表大小自动推断的结果
ALTER TABLE orders SET (parallel_workers = 4);
-- 取消该设置,回到自动推断
ALTER TABLE orders RESET (parallel_workers);该选项只影响「这张表被扫描时希望用几个 worker」,最终仍受 max_parallel_workers_per_gather 与 worker 池上限的约束。
干预:函数与聚合的并行安全性
自定义函数默认是 PARALLEL UNSAFE,会让整个查询失去并行能力。如果确认函数无副作用,应显式声明:
CREATE FUNCTION normalize_phone(p text) RETURNS text
LANGUAGE sql
IMMUTABLE
PARALLEL SAFE -- 允许在并行 worker 中执行
AS $$ SELECT regexp_replace(p, '[^0-9]', '', 'g') $$;聚合函数则使用 PARALLEL 选项:
CREATE AGGREGATE my_sum(numeric) (
SFUNC = numeric_add,
STYPE = numeric,
COMBINEFUNC = numeric_add,
PARALLEL = SAFE
);详细说明
并行查询的执行流程
把一次并行查询拆成时间线,大致是:
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:
worker 0 → 返回 1000 万行
worker 1 → 返回 1000 万行 ← 完全重复
worker 2 → 返回 1000 万行 ← 完全重复
leader 汇总 → 3000 万行(错误结果)所以计划的并行部分必须保证:每一条输出行恰好由一个进程产出。对顺序扫描而言,实现方式是「按数据块切分」:
表的数据块: [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 ... ASSELECT INTOCREATE MATERIALIZED VIEWREFRESH 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(官方明确说明:无法自动判断,因为等价于停机问题)。因此升级后常见的现象是:
原本并行的查询,引入一个自建函数后变串行
→ 该函数默认 PARALLEL UNSAFE需要注意的还有两点:
RESTRICTED不会让规划器想办法绕开。官方特别指出:规划器不会为了避免并行受限而去「推迟」受限函数的求值。也就是说,如果某个WHERE条件里用了PARALLEL RESTRICTED的函数,规划器不会考虑把这张表的扫描放进并行部分——哪怕理论上可以「先扫码、后在Gather之上过滤」。- 误标的后果严重。把实际不安全/受限的函数标成
SAFE,可能导致并行查询抛错或产生错误结果。拿不准时应标UNSAFE。
永远属于 parallel restricted 的操作
有四类操作天然是 RESTRICTED,它们无法出现在 Gather 以下:
- 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_size | 8MB | 要并行做表扫描,至少要扫这么多数据 |
min_parallel_index_scan_size | 512kB | 要并行做索引扫描,至少要访问这么多索引数据 |
数据量越大,规划器倾向于安排越多 worker(每个 worker 至少要「分摊」到一定量的数据),但最终不会超过 max_parallel_workers_per_gather。
如果某张表你明确知道「用它的时候应该多开几个 worker」,可以用对象级参数直接指定:
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 成为瓶颈时有意义:
SET parallel_leader_participation = off;并行带来的资源放大效应
并行不是免费的,它的资源消耗是乘法式的:
| 资源 | 放大方式 |
|---|---|
| 内存 | 每个 worker 独立持有自己的 work_mem(排序、哈希表)。N 个 worker 就是 N 份 |
| 连接槽位 | 每个 worker 都是完整后端,占用进程/连接资源 |
| CPU | N+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。
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; 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 前缀节点。这不是「计划里有并行但没启动」,而是「规划器根本没打算并行」。
逐项排查:
-- 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'; 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,它让整个查询被禁用并行。
修复(前提是这个函数确实是无副作用的纯函数):
ALTER FUNCTION normalize_phone(text) PARALLEL SAFE;再跑一次:
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 倍。计划形状也变成了标准的并行聚合三段式:
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。
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM orders WHERE status = 'paid'; 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 msWorkers Launched: 0 就是答案:计划是并行的,但一个 worker 都没拿到,leader 只好独自把 Parallel Seq Scan 从头跑到尾。
用 PG18 的新字段进一步坐实这一点:
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(); datname | parallel_workers_to_launch | parallel_workers_launched | worker_shortage
---------+----------------------------+---------------------------+-----------------
shopdb | 18422 | 6104 | 12318
(1 row)计划要 18422 个 worker,实际只启动 6104 个,短缺 12318 个。再看当前时刻的 worker 占用:
SELECT count(*) FILTER (WHERE leader_pid IS NOT NULL) AS parallel_workers_now
FROM pg_stat_activity; parallel_workers_now
----------------------
16
(1 row)16 个 worker 正在跑,说明 worker 池被并发查询占满了。对照参数:
SHOW max_worker_processes; -- 8(默认)
SHOW max_parallel_workers; -- 8(默认)默认值只有 8,而当前有 16 个并行 worker 在跑——显然这台机器上调过参数,但相对高并发的负载仍然不够。
三种处理方向:
-- 方向一:如果这台机器确实适合并行(分析型、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,而是资源竞争的信号。它导致的最坏情况是「按并行计划的成本估算选了这条路,实际却串行执行」——比一开始就选串行计划还要慢。
注意事项
不要在 OLTP 主库上盲目调大
max_parallel_workers_per_gather。 单个查询会占用 N 个 worker 进程,每个进程都是完整后端(独立内存、独立work_mem)。高并发下会迅速耗尽 worker 池与内存。sql-- 错误!默认 2 之外的激进上调,需先压测 ALTER SYSTEM SET max_parallel_workers_per_gather = 16;自定义函数不要「顺手」标成
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 $$;不要把
max_parallel_workers设得比max_worker_processes还大。 并行 worker 是从max_worker_processes建立的池子里取的,超出部分无效。sql-- 错误!max_worker_processes 仍是 8,这里设 32 不会生效 ALTER SYSTEM SET max_parallel_workers = 32;不要在事务里「先看一眼统计」再依赖它。 累积统计在事务内会被缓存(由
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;不要用
DECLARE CURSOR处理大结果集,还期待它并行。 游标会被分次读取,规划器认为「查询可能被挂起」,因此永远不会生成并行计划。sql-- 不推荐:这个大查询不可能并行 BEGIN; DECLARE cur CURSOR FOR SELECT * FROM huge_table WHERE flag; FETCH 1000 FROM cur; COMMIT;注意
Gather的位置决定了「并行范围」。Gather在计划树顶端 ≠ 整个查询并行;它在中间时,只有它下面的部分并行,上面的节点(如Sort、HashAggregate)仍是 leader 单进程执行。textSort ← 单进程(在 Gather 之上) -> Gather -> Parallel Seq Scan ← 并行parallel_tuple_cost放大后会让计划「看起来变贵」。 如果并行部分产出行数巨大,规划器可能主动放弃并行。这时降低parallel_setup_cost/parallel_tuple_cost只是「强行」让它选并行,不一定是好事。sql-- 不推荐:为了看并行计划而无脑清零这两个参数 SET parallel_setup_cost = 0; SET parallel_tuple_cost = 0;CTE 与临时表会静默地阻断并行。 它们属于永久
PARALLEL RESTRICTED的操作,不会报错,只会让并行范围缩小。sql-- 不推荐:CTE 扫描是 RESTRICTED,大表扫描无法并行 WITH recent AS MATERIALIZED ( SELECT * FROM orders WHERE created_at >= now() - interval '1 day' ) SELECT count(*) FROM recent;并行不是「必须保留」的特性。 当查询是短事务、返回行很少、或已经吃满 CPU 时,串行往往更快。用
SET max_parallel_workers_per_gather = 0做 A/B 对比是很正常的调优手段。sql-- 合理的诊断流程:同一查询在开/关并行下各跑一次,比实际耗时 EXPLAIN (ANALYZE) SELECT ...;sqlSET LOCAL max_parallel_workers_per_gather = 0; EXPLAIN (ANALYZE) SELECT ...;sqlRESET max_parallel_workers_per_gather; EXPLAIN (ANALYZE) SELECT ...;单用户模式下调不出并行。 单用户模式下整个数据库只有一个进程,没有 background worker,任何并行计划都不会生成——这不是配置问题。
最佳实践
先确认「该不该并行」,再调「怎么并行」。 用查询的返回行数、表大小、并发度三个维度判断;返回行极少的大表扫描、聚合型报表最适合并行。
升级到 PG18 后,先把并行相关的监控字段接上。 持续观察
parallel_workers_to_launch与parallel_workers_launched的差值,差值长期偏大说明 worker 池不足或并发过高。sqlSELECT 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;让 worker 池参数彼此自洽。 保证
max_worker_processes >= max_parallel_workers >= max_parallel_workers_per_gather,并且给其他后台进程(逻辑复制、autovacuum)留出余量。textmax_worker_processes = 24 -- 总池子(需重启) max_parallel_workers = 16 -- 其中给并行查询的上限 max_parallel_workers_per_gather = 4 -- 单条查询的上限优先用
ALTER TABLE ... SET (parallel_workers = N)做「按表」的精细控制,而不是全局调大max_parallel_workers_per_gather。这样只有真正的大表才会用更多 worker。sqlALTER TABLE fact_orders SET (parallel_workers = 6); ALTER TABLE dim_customers SET (parallel_workers = 0);审计用户自定义函数的并行标记。 迁移到新版本或引入新函数后,检查是否有本该
SAFE的函数还停留在默认的UNSAFE。sqlSELECT 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;给并行查询留足
work_mem的预算。 记住实际内存占用约为work_mem × (worker 数 + 1);宁可调小单节点work_mem配合更多 worker,也不要让单窗口内存失控。诊断慢查询时固定一个「对照实验」流程:
EXPLAIN (ANALYZE, BUFFERS)→ 再看(ANALYZE, VERBOSE)的 per-worker 统计 → 若Workers Launched不足,转向资源排查。不要依赖
SETTINGS/参数清零来「制造」并行计划。 参数只是诊断手段,真实的修复方向通常是补统计信息、修 SQL 写法、或修函数标记。对报表类查询考虑使用只读副本。 把并行的 CPU 与内存开销从主库挪走,是最省事的隔离手段;主库上的并行则应更保守。
把「并行 vs 串行」的对比结论记录下来。 同一条 SQL 在不同数据量、不同并发下最优解可能不同,写进运维手册比凭记忆调参可靠。
监控
pg_stat_activity.leader_pid,随时看清当前有多少并行 worker 在跑,用于判断是否已触及资源天花板。sqlSELECT count(*) AS total_workers, count(DISTINCT leader_pid) AS parallel_queries FROM pg_stat_activity WHERE leader_pid IS NOT NULL;不要给每一张表都设
parallel_workers。 只给少数确实需要的大型事实表设置,其它保持自动推断,避免参数散落难维护。
下一节
继续学习:并行配置参数,了解控制并行行为的各个 GUC 参数及其默认值与取舍。