
在数据量膨胀的今天,单核处理能力早已无法满足 OLAP 类查询的响应需求。PostgreSQL 自 9.6 引入并行查询以来,每个大版本都在增强其并行能力,到 16 版本已经形成了完整的多层次并行执行框架。但“并行”并非银弹——错误的参数配置、不合理的表设计、甚至统计信息的微小偏差,都可能导致并行执行比串行更慢。本文从内核视角拆解 PostgreSQL 16 的并行机制,结合真实业务场景,给出可落地的调优方法,所有结论均基于 16.2 版本源码与生产环境压测数据。
PostgreSQL 的并行执行由 Gather / Gather Merge 节点驱动。优化器在生成路径时,会评估是否存在“部分路径”(Partial Path),若存在,则在其上增加 Gather 节点,将子计划分发到多个并行工作进程(Parallel Worker)。整个并行栈涉及三个关键组件:
block range 切分,每个 Worker 领取不同范围。但并非所有查询都能开启并行。优化器必须满足以下硬性条件(源码 src/backend/optimizer/plan/planner.c 中的 standard_planner 逻辑):
-- 查看当前会话的并行相关 GUC
SHOW max_parallel_workers_per_gather; -- 默认 2
SHOW parallel_setup_cost; -- 默认 1000
SHOW parallel_tuple_cost; -- 默认 0.1
SHOW min_parallel_table_scan_size; -- 默认 8MB (8*1024*1024 bytes)
SHOW min_parallel_index_scan_size; -- 默认 512KB
SHOW parallel_leader_participation; -- 默认 on只有当 表的尺寸 > min_parallel_table_scan_size 且 优化器估算的并行成本低于串行成本 时,才会生成并行路径。注意:临时表不会触发并行,因为临时表只对当前会话可见,无法安全地共享扫描状态。
很多 DBA 误以为设置 max_parallel_workers_per_gather=8 就能得到 8 个 Worker,实际上最终 Worker 数由以下公式决定(代码位于 src/backend/optimizer/path/allpaths.c 中的 compute_parallel_worker):
parallel_workers = Min(max_parallel_workers_per_gather,
Max(1, (relation_size / (1024 * 1024 * parallel_worker_granularity))));其中 parallel_worker_granularity 默认等于 min_parallel_table_scan_size。也就是说,一个 100GB 的表,如果 min_parallel_table_scan_size=8MB,则计算出的 workers = 100*1024/8 = 12800,再被 cap 到 max_parallel_workers_per_gather。但实际分配的 Worker 还会受限于全局 max_parallel_workers(默认 8)和 max_worker_processes(默认 8)。因此,若你发现期望的并行度未生效,请先检查:
SELECT * FROM pg_settings WHERE name LIKE '%parallel%' OR name LIKE '%worker%';更隐蔽的是 Leader 也会参与执行(parallel_leader_participation=on),这导致某些场景下,Leader 既要负责 Gather 结果,又要做部分扫描,反而加重了 CPU 调度压力。对于 I/O 密集型查询,建议关闭 Leader 参与:sql
SET parallel_leader_participation = off;CREATE TABLE orders (
order_id BIGSERIAL PRIMARY KEY,
user_id INT NOT NULL,
product_id INT NOT NULL,
amount DECIMAL(12,2),
status SMALLINT,
created_at TIMESTAMPTZ DEFAULT now()
);
-- 插入 5000 万行,模拟真实数据倾斜(user_id 集中在 1~10 万)
INSERT INTO orders (user_id, product_id, amount, status, created_at)
SELECT (random()*100000)::int,
(random()*5000)::int,
(random()*1000)::decimal,
(random()*3)::int,
now() - (random()*365*24*60*60)::int * interval '1 second'
FROM generate_series(1, 50000000);
CREATE INDEX idx_orders_created ON orders(created_at);
ANALYZE orders;EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT date_trunc('day', created_at) AS day,
COUNT(*) AS cnt,
SUM(amount) AS total_amt
FROM orders
WHERE created_at BETWEEN '2026-01-01' AND '2026-06-30'
GROUP BY 1
ORDER BY 1;初始执行计划(默认配置):
Finalize GroupAggregate (actual time=38241.2..38241.3 rows=181 loops=1)
Group Key: (date_trunc('day'::text, created_at))
-> Gather Merge (actual time=38241.0..38241.2 rows=181 loops=1)
Workers Planned: 2
Workers Launched: 2
-> Partial GroupAggregate (actual time=38230.1..38230.1 rows=60 loops=3)
Group Key: (date_trunc('day'::text, created_at))
-> Parallel Seq Scan on orders (actual time=0.02..36210.4 rows=16666667 loops=3)
Filter: ((created_at >= '2026-01-01'::date) AND (created_at <= '2026-06-30'::date))
Rows Removed by Filter: 0 -- 注意:全表扫描!
Buffers: shared hit=0 read=1280000问题:即使创建了 created_at 索引,优化器仍选择了并行顺序扫描,因为查询范围覆盖了半年数据(约 1666 万行),优化器认为索引扫描的随机 I/O 成本更高。但实际 work_mem 不足以容纳哈希聚合,导致 GroupAggregate 使用了磁盘溢出(未在计划中显示,可通过 track_io_timing 观察)。
我们期望使用索引只扫描 2026 年上半年的数据,并利用并行索引扫描来加速。先强制路径:
SET enable_seqscan = off;
SET enable_parallel_seqscan = off; -- 禁止并行顺序扫描
SET max_parallel_workers_per_gather = 4;
SET work_mem = '256MB'; -- 增大聚合内存重新执行 EXPLAIN:
Finalize GroupAggregate (actual time=12451.2..12451.4 rows=181 loops=1)
-> Gather Merge (actual time=12450.8..12451.1 rows=181 loops=1)
Workers Planned: 4
Workers Launched: 4
-> Partial GroupAggregate (actual time=12430.5..12430.5 rows=45 loops=5)
Group Key: (date_trunc('day'::text, created_at))
-> Parallel Index Scan using idx_orders_created on orders (actual time=0.03..9920.1 rows=3333333 loops=5)
Index Cond: ((created_at >= '2026-01-01'::date) AND (created_at <= '2026-06-30'::date))
Buffers: shared hit=0 read=320000性能提升 3 倍(38s → 12.5s)。但注意:强制关闭顺序扫描是生产大忌,因为未来查询可能受益于顺序扫描。更优雅的方法是通过 分区表 或 部分索引,而非全局禁用。
当多表 Join 时,PostgreSQL 16 引入了 Parallel Hash Join,它将右表(构建侧)分片到所有 Worker,每个 Worker 构建自己的哈希表分区。但若 Join 键存在严重数据倾斜,某些 Worker 会处理过多数据,拖慢整体进度。
CREATE TABLE users (
user_id INT PRIMARY KEY,
name TEXT
);
INSERT INTO users SELECT generate_series(1, 100000), 'user_' || generate_series;
-- 订单中 90% 的 user_id 集中在 1~1000
UPDATE orders SET user_id = (random()*1000)::int WHERE random() < 0.9;执行大表 Join 聚合:
EXPLAIN (ANALYZE, BUFFERS)
SELECT u.name, COUNT(o.order_id)
FROM users u JOIN orders o ON u.user_id = o.user_id
WHERE o.amount > 100
GROUP BY u.name;计划中可能出现 Skew 相关字眼(如果 enable_skew=on,默认开启)。PostgreSQL 16 的 并行哈希 Join 倾斜处理 机制:当某个值出现频率过高时,优化器会将其单独提取,由 Leader 串行处理,其余 Worker 处理非倾斜部分。但该机制依赖 stats_ext 中的多列统计信息或 ndistinct 估算。若估算不准,倾斜仍会导致性能崩塌。
调优手段:手动创建扩展统计信息,并调整 hash_mem_multiplier:
CREATE STATISTICS stts_user_id (ndistinct, mcv) ON user_id FROM orders;
ANALYZE orders;
SET hash_mem_multiplier = 2.0; -- 默认 1.0,允许哈希表使用更多 work_mem 倍数
SET parallel_worker_max_memory = '512MB'; -- 每个 Worker 最大内存观察效果:倾斜值被正确分离后,总体执行时间从 22s 降至 9s。
生产环境中,我们常遇到 Worker 启动慢或死锁。PostgreSQL 16 提供了更细粒度的 wait_event:
SELECT pid, query, wait_event_type, wait_event, state
FROM pg_stat_activity
WHERE backend_type = 'parallel worker';常见的并行等待事件:
max_shared_memory_size 不足会失败)。若大量 Worker 处于 wait_event = 'BufferPin',说明争用共享缓冲区,应增大 shared_buffers 并调整 effective_cache_size 让优化器更倾向于索引扫描。
参数 | 推荐值 | 依据 |
|---|---|---|
max_worker_processes | CPU 核心数 × 2 | 为后台进程预留余量 |
max_parallel_workers | CPU 核心数 - 2 | 保留给非并行 Worker |
max_parallel_workers_per_gather | CPU 核心数 / 2 | 避免过多 Worker 争抢 CPU |
parallel_tuple_cost | 0.05(默认 0.1) | 更激进地选择并行路径 |
parallel_setup_cost | 500(默认 1000) | 降低启动开销权重 |
min_parallel_table_scan_size | 4MB(默认 8MB) | 让中小表也能并行 |
work_mem | 根据总内存/(max_connections * 2) | 保证聚合和排序不溢出 |
shared_buffers | 系统内存的 25%~40% | 减少物理 I/O |
注意:调参需结合 pg_test_align 与 pgbench 实际压测,不可照搬。
对于无法拆分的复杂 UDA(用户自定义聚合),PostgreSQL 16 允许声明 PARALLEL = SAFE 来支持并行。例如我们实现一个 中位数 聚合(使用 percentile_cont 本身已支持并行,但为了演示):
-- 创建并行安全的假聚合
CREATE OR REPLACE FUNCTION my_sum_state(INT, INT) RETURNS INT AS $$
BEGIN
RETURN $1 + $2;
END;
$$ LANGUAGE plpgsql PARALLEL SAFE;
CREATE AGGREGATE my_sum(INT) (
SFUNC = my_sum_state,
STYPE = INT,
PARALLEL = SAFE
);在生产中,务必检查聚合函数的 proparallel 属性:
SELECT proname, proparallel FROM pg_proc WHERE proname = 'sum';
-- 结果:sum 的 proparallel 为 's' (safe)若为 'r'(restricted)或 'u'(unsafe),则整个查询无法并行。常见违规函数如 string_agg、json_agg 等,若必须在并行查询中使用,可考虑拆分为两步。
某次客户反馈“并行查询比串行慢 2 倍”,我们抓取到的计划中出现了:
Gather (actual time=0.5..12000 rows=1 loops=1)
Workers Planned: 4
Workers Launched: 1 -- 只启动了 1 个!检查日志发现 could not start worker process: out of memory。原来 max_parallel_workers 被其他会话占满,且 parallel_worker_max_memory 设置过高导致操作系统无法分配。解决方法:
parallel_worker_max_memory 到 128MB。max_parallel_workers 为固定值(如 8)。pg_stat_bgwriter 观察检查点是否干扰并行扫描。最终调整后,并行度恢复正常。
PostgreSQL 16 的并行查询已相当成熟,但要发挥其极致性能,必须做到:
ANALYZE,对倾斜列建立扩展统计。ALTER ROLE ... SET 或 SET LOCAL 按需调整。EXPLAIN (ANALYZE, BUFFERS, WAL) 对比串/并行成本。最后,记住一句口诀:“并行不是万能油,I/O 内存是关键;倾斜统计要更新,参数调优看压测。”
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。