从单核蛮力到多核协同:PostgreSQL 并行查询与执行计划深度调优
当你的复杂分析查询在 16 核机器上只用了 1 个 CPU 时,你损失的不仅是 15 倍的潜在性能,还有老板对数据库"性能天花板"的耐心。PostgreSQL 的并行查询机制从 9.6 引入至今已非常成熟,但多数调优仍停留在"打开并行开关"的层面。本文通过 4 个核心参数 + 1 个实战案例,带你深入执行计划底层,掌握并行度控制、扫描策略选择、连接顺序优化以及分区裁剪的精准调优方法。
1. 并行查询参数调优:从 max_parallel_workers_per_gather 到 work_mem
1.1 核心参数链路
PostgreSQL 并行查询的生效依赖三层参数:
- 系统级 :
max_worker_processes(最大后台工作进程数,通常设为 CPU 核数) - 会话级 :
max_parallel_workers(当前会话允许的最大并行工作者数) - 查询级 :
max_parallel_workers_per_gather(单个 Gather 节点可以启用的并行工作者数)
常见误区 :只调大 max_parallel_workers_per_gather 但不调整 work_mem,导致 hash join 被迫写入磁盘,性能反而下降。
1.2 work_mem 与并行化代价
每个并行 worker 都会独立申请一份 work_mem。例如 work_mem = 4MB,并行度 4,则 hash 表总内存需求为 16MB。如果实际 hash 表超过该值,会触发磁盘临时文件(temp_files),P99 延迟飙升 10 倍以上。
生产推荐配置:
| 参数 | 推荐值 | 说明 |
|---|---|---|
max_parallel_workers_per_gather |
min(CPU核数/2, 4) | OLTP 场景建议 ≤2,分析场景可到 4~8 |
work_mem |
64MB~256MB | 根据可用内存计算:work_mem = (总内存 * 0.25) / (max_connections * max_parallel_workers_per_gather) |
parallel_setup_cost |
1000 | 降低此值可让优化器更积极地选择并行计划 |
parallel_tuple_cost |
0.1 | 降低以鼓励并行 scan |
示例:监控临时文件
sql
-- 查看是否有大量磁盘排序/hash
SELECT datname, temp_files, temp_bytes
FROM pg_stat_database
WHERE datname = 'mydb';
-- 每个 session 查看 work_mem 使用
SHOW work_mem;
踩坑记录 :某次将 work_mem 从 4MB 提升到 128MB 后,一个聚合查询从 3.2s 降到 0.8s,同时 temp_files 降为 0。提升前 60% 的查询时间花在磁盘 I/O 上。
2. 执行计划解读:Seq Scan vs Index Scan vs Bitmap Scan 的选择逻辑
2.1 优化器选择树
PostgreSQL 的扫描路径生成遵循成本估算,关键因素:
- 相关性(correlation) :索引列的物理存储顺序与逻辑顺序的相关性。
pg_stats.correlation接近 1 时,索引扫描成本低,优化器倾向 Index Scan。 - 选择率(selectivity):过滤条件筛选出的行数占比。选择率 < 5% 通常走 Index Scan,> 20% 可能走 Seq Scan 或 Bitmap Scan。
- 并行度 :当并行 worker 可用时,
Parallel Seq Scan的成本计算为(seq_page_cost + cpu_tuple_cost) / parallel_workers,往往比 Index Scan 更便宜,导致优化器在低选择性条件下选择并行全表扫描。
2.2 强制使用并行模式的调试技巧
force_parallel_mode = on 让优化器在成本允许时强制生成并行计划,用于诊断"为什么没走并行"。
sql
-- 强制并行并查看计划
SET force_parallel_mode = on;
EXPLAIN (ANALYZE, BUFFERS, COSTS)
SELECT COUNT(*) FROM orders WHERE o_orderdate >= '1994-01-01';
-- 关闭后对比
SET force_parallel_mode = off;
EXPLAIN (ANALYZE, BUFFERS, COSTS)
SELECT COUNT(*) FROM orders WHERE o_orderdate >= '1994-01-01';
观察点 :当 force_parallel_mode=on 时,如果计划出现 Gather 节点但 worker 数量为 0,说明资源不足或参数限制(max_parallel_workers_per_gather=0 等)。
2.3 Bitmap Scan 的并行化陷阱
Bitmap Scan 本身可以并行,但需要先构建位图(Bitmap Heap Scan)。若 work_mem 不足,位图会退化为 exact 模式,甚至转为 Seq Scan。
sql
-- 查看 Bitmap 构建是否触发 exact 模式
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM lineitem
WHERE l_shipdate BETWEEN '1994-01-01' AND '1994-01-31';
-- 如果看到 "exact" 说明 work_mem 过小,考虑增大或使用 Index Scan 替代
对比表格:
| 扫描类型 | 适用场景 | 并行性 | 内存敏感度 |
|---|---|---|---|
| Parallel Seq Scan | 大表全量扫描,选择率 > 20% | 高(直接并行) | 低 |
| Index Scan | 高选择性(< 5%),相关性好 | 部分(需 Gather 后扫描) | 低 |
| Bitmap Scan | 中等选择性(5%~20%) | 中等(位图构建可并行,堆扫描需 Gather) | 高(依赖 work_mem) |
3. join_collapse_limit 与 from_collapse_limit:控制连接并行化程度
3.1 这两个参数做了什么?
from_collapse_limit 控制显式 JOIN 的子查询是否能被"展平"与 FROM 列表中的其他表合并。默认 8,即 FROM 子句中表数量 ≤8 时,优化器会尝试重新排序连接顺序。join_collapse_limit 控制显式 JOIN 是否可以拆散重新排序(默认 8)。
为什么影响并行:连接顺序决定了哪个表在外层做 Gather,哪个表在内层做并行扫描。如果连接顺序不合理(比如把小表放在外层),可能无法充分利用并行。
3.2 调优策略
当查询包含 5 张表以上的多表连接,且某些表非常大时,建议:
- 适度降低
join_collapse_limit(例如设为 4),让优化器只对前 4 个显式 JOIN 做重排序,避免搜索空间爆炸导致计划生成耗时过长。 - 手动 hint 连接顺序 :使用
pg_hint_plan扩展,强制让大表作为并行驱动表。
sql
-- 查看当前计划是否因连接顺序导致并行度低
EXPLAIN (ANALYZE, COSTS)
SELECT ...
FROM orders o
JOIN lineitem l ON l_orderkey = o_orderkey
JOIN customer c ON c_custkey = o_custkey
WHERE o_orderdate >= '1994-01-01';
-- 如果发现 lineitem 在 Gather 内部但 orders 在外层,尝试调整 join 顺序
-- 使用 pg_hint_plan 强制:
/*+
Leading((o (l c)))
HashJoin(o l)
HashJoin(l c)
*/
生产数据 :某报表查询 7 表连接,默认 join_collapse_limit=8 时执行计划耗时 12s(并行度 2);将 join_collapse_limit 调为 4 并手动 hint 后,并行度提升到 4,执行时间降至 3.5s。
4. 分区表并行扫描:分区裁剪与 parallel_workers 设置
4.1 分区裁剪与并行矛盾
分区裁剪(Partition Pruning)可以在执行计划阶段就排除不相关的分区,但 并行 Gather 节点会在分区之上,导致所有 worker 先等待分区裁剪结果,然后只有部分 worker 实际干活。
关键参数 :每个分区可以设置 parallel_workers 存储参数,控制该分区的并行扫描 worker 数量。
sql
-- 为分区表设置每分区并行度
ALTER TABLE orders_1994 SET (parallel_workers = 4);
ALTER TABLE orders_1995 SET (parallel_workers = 2);
注意:如果分区间数据量差异大,统一设置会导致资源倾斜。应当根据分区大小设置:
sql
-- 基于表大小自动计算
UPDATE pg_class
SET reloptions = (SELECT 'parallel_workers=' ||
GREATEST(1, ROUND(relpages / 1000)::int) -- 每 1000 页一个 worker
FROM pg_class c2 WHERE c2.oid = 'orders_1994'::regclass)
WHERE relname = 'orders_1994';
4.2 避免跨分区开销
如果查询访问多个分区,但 parallel_workers 设置过高,跨分区的 Gather 节点会合并大量 tuple 导致瓶颈。监控 Gather 节点的 Workers Launched 数值,若远小于 max_parallel_workers_per_gather,说明分区裁剪过度或 worker 分配不均。
实用查询:查看当前并行 worker 使用情况
sql
SELECT datname, count(*) AS active_workers
FROM pg_stat_activity
WHERE backend_type = 'parallel worker'
GROUP BY datname;
5. 实战案例:基于 TPC-H Q18 的并行查询优化
TPC-H Q18 是典型的"大订单查询",涉及 customer、orders、lineitem 三表连接并聚合,数据量可放大到 10GB 以上模拟生产场景。
5.1 原始 SQL 与执行计划
sql
-- TPC-H Q18: 找出大额订单的客户
SELECT c_name, c_custkey, o_orderkey, o_orderdate, o_totalprice,
sum(l_quantity) AS sum_quantity
FROM customer, orders, lineitem
WHERE c_custkey = o_custkey
AND o_orderkey = l_orderkey
AND o_orderdate >= '1994-01-01'
GROUP BY c_name, c_custkey, o_orderkey, o_orderdate, o_totalprice
HAVING sum(l_quantity) > 312;
默认配置下,执行计划显示:
Gather (cost=... rows=...)
Workers Planned: 2
-> HashAggregate (cost=...)
-> Hash Join (cost=...)
-> Parallel Seq Scan on lineitem
-> Hash (cost=...)
-> Nested Loop (cost=...)
-> Seq Scan on orders
-> Index Scan using customer_pkey on customer
问题:orders 和 customer 的连接用了 Nested Loop,且 orders 扫描为 Seq Scan,导致 lineitem 的 Parallel Seq Scan 虽然并行,但上游处理慢,整体并行利用率低。
5.2 调优步骤
Step 1:增大 work_mem 避免 Hash 溢出
sql
SET work_mem = '128MB';
Step 2:调整 max_parallel_workers_per_gather 到 4
sql
SET max_parallel_workers_per_gather = 4;
Step 3:使用 pg_hint_plan 强制 Hash Join 顺序
sql
/*+
Leading((customer (orders lineitem)))
HashJoin(orders lineitem)
HashJoin(customer orders)
Parallel(orders 4)
Parallel(lineitem 4)
*/
EXPLAIN (ANALYZE, BUFFERS, TIMING)
...原SQL...
Step 4:为 orders 和 lineitem 建立合适索引
sql
CREATE INDEX idx_orders_date ON orders(o_orderdate);
CREATE INDEX idx_lineitem_order ON lineitem(l_orderkey);
5.3 结果对比
| 配置 | 执行时间 | 并行 worker 数 | 临时文件(MB) |
|---|---|---|---|
| 默认(work_mem=4MB,并行=2) | 58.2s | 2 | 420 |
| 增大 work_mem 到 128MB | 22.1s | 2 | 0 |
| 并行度提升到 4 + Hash Join hint | 8.7s | 4 | 0 |
| 加索引 + 分区 | 6.2s | 4 | 0 |
等待事件监控 :在调优过程中,通过 pg_stat_activity 观察等待事件,发现调优前大部分等待为 LWLock:WALWrite 和 IO:DataFileRead,调优后变为 CPU:Other,说明瓶颈从 I/O 转为 CPU 计算,进一步增大并行度有效。
sql
-- 实时查看并行 worker 状态
SELECT pid, wait_event_type, wait_event, state, query
FROM pg_stat_activity
WHERE backend_type = 'parallel worker';
总结与建议
- 并行度不是越大越好 :
max_parallel_workers_per_gather超过 4 后,在 OLTP 混部场景下反而导致上下文切换开销,建议分析型查询设为 4,混合负载设为 2。 - work_mem 是隐形杀手 :每次调大并行度,同步检查
temp_files指标,确保 Hash 和 Sort 不落盘。推荐基于(可用内存 × 0.25) / (并发连接数 × 并行度)公式动态配置。 - 连接顺序决定并行天花板 :用
from_collapse_limit结合pg_hint_plan确保大表位于并行扫描的内侧,避免 Nested Loop 串行化。 - 分区表必须显式设置
parallel_workers:根据分区大小按公式分配,并定期监控pg_stat_user_tables中每个分区的n_live_tup变化。 - 生产环境先通过
force_parallel_mode做压力测试 :确认在最大并发下不会触发 OOM 或疯抢 CPU,再用ALTER SYSTEM SET持久化。
最后,并行查询调优的本质是 在 CPU 核数、内存带宽、I/O 吞吐之间找到平衡点。对照 pg_stat_database 的 temp_files、pg_stat_activity 的等待事件,以及执行计划中的 Workers Launched 和 actual time,才能精准命中瓶颈。