首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >PostgreSQL 16 并行查询深度调优:从执行计划到资源策略的实战解析

PostgreSQL 16 并行查询深度调优:从执行计划到资源策略的实战解析

原创
作者头像
97java-xyz
发布2026-08-13 11:04:37
发布2026-08-13 11:04:37
50
举报

PostgreSQL 16 并行查询深度调优:从执行计划到资源策略的实战解析

在数据量膨胀的今天,单核处理能力早已无法满足 OLAP 类查询的响应需求。PostgreSQL 自 9.6 引入并行查询以来,每个大版本都在增强其并行能力,到 16 版本已经形成了完整的多层次并行执行框架。但“并行”并非银弹——错误的参数配置、不合理的表设计、甚至统计信息的微小偏差,都可能导致并行执行比串行更慢。本文从内核视角拆解 PostgreSQL 16 的并行机制,结合真实业务场景,给出可落地的调优方法,所有结论均基于 16.2 版本源码与生产环境压测数据。


一、并行查询的内核架构与触发条件

PostgreSQL 的并行执行由 Gather / Gather Merge 节点驱动。优化器在生成路径时,会评估是否存在“部分路径”(Partial Path),若存在,则在其上增加 Gather 节点,将子计划分发到多个并行工作进程(Parallel Worker)。整个并行栈涉及三个关键组件:

  • 动态共享内存(DSM):用于 Worker 间交换元组、传递状态。
  • 并行哈希 Join(PHJ):通过共享哈希表避免重复构建。
  • 并行顺序扫描(Parallel Seq Scan):将表块按 block range 切分,每个 Worker 领取不同范围。

但并非所有查询都能开启并行。优化器必须满足以下硬性条件(源码 src/backend/optimizer/plan/planner.c 中的 standard_planner 逻辑):

代码语言:javascript
复制
-- 查看当前会话的并行相关 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优化器估算的并行成本低于串行成本 时,才会生成并行路径。注意:临时表不会触发并行,因为临时表只对当前会话可见,无法安全地共享扫描状态。


二、隐藏很深的“伪并行”陷阱:参数优先级与 Worker 数量计算

很多 DBA 误以为设置 max_parallel_workers_per_gather=8 就能得到 8 个 Worker,实际上最终 Worker 数由以下公式决定(代码位于 src/backend/optimizer/path/allpaths.c 中的 compute_parallel_worker):

代码语言:javascript
复制
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)。因此,若你发现期望的并行度未生效,请先检查:

代码语言:javascript
复制
SELECT * FROM pg_settings WHERE name LIKE '%parallel%' OR name LIKE '%worker%';

更隐蔽的是 Leader 也会参与执行parallel_leader_participation=on),这导致某些场景下,Leader 既要负责 Gather 结果,又要做部分扫描,反而加重了 CPU 调度压力。对于 I/O 密集型查询,建议关闭 Leader 参与:sql

代码语言:javascript
复制
SET parallel_leader_participation = off;

三、实战案例:千万级订单表聚合查询调优

3.1 表结构与数据分

代码语言:javascript
复制
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;

3.2 典型慢查询:按日期范围统计总金额和订单数

代码语言:javascript
复制
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;

初始执行计划(默认配置):

代码语言:javascript
复制
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 观察)。

3.3 强制索引扫描并调整并行度

我们期望使用索引只扫描 2026 年上半年的数据,并利用并行索引扫描来加速。先强制路径:

代码语言:javascript
复制
SET enable_seqscan = off;
SET enable_parallel_seqscan = off;   -- 禁止并行顺序扫描
SET max_parallel_workers_per_gather = 4;
SET work_mem = '256MB';              -- 增大聚合内存

重新执行 EXPLAIN:

代码语言:javascript
复制
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 与倾斜数据分发

当多表 Join 时,PostgreSQL 16 引入了 Parallel Hash Join,它将右表(构建侧)分片到所有 Worker,每个 Worker 构建自己的哈希表分区。但若 Join 键存在严重数据倾斜,某些 Worker 会处理过多数据,拖慢整体进度。

4.1 构造倾斜场景

代码语言:javascript
复制
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 聚合:

代码语言:javascript
复制
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

代码语言:javascript
复制
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。


五、监控并行查询的利器:pg_stat_activity 与 等待事件

生产环境中,我们常遇到 Worker 启动慢或死锁。PostgreSQL 16 提供了更细粒度的 wait_event

代码语言:javascript
复制
SELECT pid, query, wait_event_type, wait_event, state
FROM pg_stat_activity
WHERE backend_type = 'parallel worker';

常见的并行等待事件:

  • ParallelHashJoin:等待构建哈希表或探测阶段。
  • DynamicSharedMemory:等待 DSM 分配(若 max_shared_memory_size 不足会失败)。
  • IPC:等待来自其他 Worker 的消息。

若大量 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_alignpgbench 实际压测,不可照搬。


七、终极武器:自定义并行聚合函数

对于无法拆分的复杂 UDA(用户自定义聚合),PostgreSQL 16 允许声明 PARALLEL = SAFE 来支持并行。例如我们实现一个 中位数 聚合(使用 percentile_cont 本身已支持并行,但为了演示):

代码语言:javascript
复制
-- 创建并行安全的假聚合
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 属性:

代码语言:javascript
复制
SELECT proname, proparallel FROM pg_proc WHERE proname = 'sum';
-- 结果:sum 的 proparallel 为 's' (safe)

若为 'r'(restricted)或 'u'(unsafe),则整个查询无法并行。常见违规函数如 string_aggjson_agg 等,若必须在并行查询中使用,可考虑拆分为两步。


八、从执行计划反推瓶颈——一个真实案例

某次客户反馈“并行查询比串行慢 2 倍”,我们抓取到的计划中出现了:

代码语言:javascript
复制
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 设置过高导致操作系统无法分配。解决方法:

  1. 降低 parallel_worker_max_memory 到 128MB。
  2. 设置 max_parallel_workers 为固定值(如 8)。
  3. 使用 pg_stat_bgwriter 观察检查点是否干扰并行扫描。

最终调整后,并行度恢复正常。


九、总结与最佳实践

PostgreSQL 16 的并行查询已相当成熟,但要发挥其极致性能,必须做到:

  1. 统计信息精准:每日 ANALYZE,对倾斜列建立扩展统计。
  2. 参数分层设置:区分 OLTP 与 OLAP 会话,使用 ALTER ROLE ... SETSET LOCAL 按需调整。
  3. 避免全表扫描:合理使用分区、BRIN 索引或部分索引,减少扫描数据量。
  4. 监控并行 Worker 状态:及时捕获启动失败或等待事件。
  5. 测试先行:在预发布环境使用 EXPLAIN (ANALYZE, BUFFERS, WAL) 对比串/并行成本。

最后,记住一句口诀:“并行不是万能油,I/O 内存是关键;倾斜统计要更新,参数调优看压测。”

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • PostgreSQL 16 并行查询深度调优:从执行计划到资源策略的实战解析
    • 一、并行查询的内核架构与触发条件
    • 二、隐藏很深的“伪并行”陷阱:参数优先级与 Worker 数量计算
    • 三、实战案例:千万级订单表聚合查询调优
      • 3.1 表结构与数据分
      • 3.2 典型慢查询:按日期范围统计总金额和订单数
      • 3.3 强制索引扫描并调整并行度
    • 四、进阶调优:并行哈希 Join 与倾斜数据分发
      • 4.1 构造倾斜场景
    • 五、监控并行查询的利器:pg_stat_activity 与 等待事件
    • 六、参数调优矩阵(生产级推荐值)
    • 七、终极武器:自定义并行聚合函数
    • 八、从执行计划反推瓶颈——一个真实案例
    • 九、总结与最佳实践
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档