首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >PostgreSQL plan_cache_mode:一个参数让倾斜数据的查询快了 44 倍

PostgreSQL plan_cache_mode:一个参数让倾斜数据的查询快了 44 倍

作者头像
小徐
发布2026-07-28 10:15:29
发布2026-07-28 10:15:29
50
举报
文章被收录于专栏:GreenplumGreenplum

PostgreSQL plan_cache_mode:一个参数让倾斜数据的查询快了 44 倍

同一条 PreparedStatement,第 5 次执行后突然变慢——不是数据变了,是执行计划被"冻结"了。

数据倾斜:95% 都是 APPROVED

假设有一张 post 表,10 万行,状态字段是枚举类型:

代码语言:javascript
复制
CREATE TYPE post_status AS ENUM ('PENDING', 'APPROVED', 'SPAM');

-- 10万行数据,分布极度倾斜
SELECT status, COUNT(*) AS matching_records,
       COUNT(*)::float / (SELECT COUNT(*) FROM post) AS selectivity
FROM post GROUP BY status ORDER BY selectivity DESC;
代码语言:javascript
复制
 status  | matching_records | selectivity
---------+------------------+-------------
 APPROVED|            95000 |        0.95
 PENDING |             4000 |        0.04
 SPAM    |             1000 |        0.01

95% 是 APPROVED,4% 是 PENDING,1% 是 SPAM。典型的长尾分布。

同一条查询,三种执行计划

代码语言:javascript
复制
SELECT id, title, status FROM post WHERE status = ?;

为了优化查询,在 status 上建了索引:

代码语言:javascript
复制
CREATE INDEX idx_post_status ON post (status);

但 PG 不会无脑走索引——它根据选择性决定执行计划

查 PENDING(4%,4000行)→ Bitmap Heap Scan

代码语言:javascript
复制
Bitmap Heap Scan on post (cost=46.44..1231.07 rows=3890 width=57)
  (actual time=0.233..1.132 rows=4000 loops=1)
  Recheck Cond: (status = 'PENDING'::post_status)
  Heap Blocks: exact=46
  -> Bitmap Index Scan on idx_post_status (cost=0.00..45.47 rows=3890 width=0)
       (actual time=0.211..0.212 rows=4000 loops=1)
       Index Cond: (status = 'PENDING'::post_status)

查 SPAM(1%,1000行)→ Index Scan

代码语言:javascript
复制
Index Scan using idx_post_status on post (cost=0.29..1089.83 rows=963 width=57)
  (actual time=0.059..0.500 rows=1000 loops=1)
  Index Cond: (status = 'SPAM'::post_status)

查 APPROVED(95%,95000行)→ Seq Scan(全表扫描)

代码语言:javascript
复制
Seq Scan on post (cost=0.00..2386.00 rows=95147 width=57)
  (actual time=0.034..36.765 rows=95000 loops=1)
  Filter: (status = 'APPROVED'::post_status)
  Rows Removed by Filter: 5000

扫 95% 的表还走索引?成本比全表扫描还高。PG 选择 Seq Scan 是对的。

参数值

选择性

执行计划

执行时间

PENDING

4%

Bitmap Heap Scan

1.1ms

SPAM

1%

Index Scan

0.5ms

APPROVED

95%

Seq Scan

36.8ms

关键点:最优执行计划依赖参数值。这正是后面踩坑的根源。

部分索引:砍掉不需要的 95%

既然 APPROVED 永远走全表扫描,索引里存它纯属浪费空间。改成部分索引:

代码语言:javascript
复制
CREATE INDEX idx_post_status ON post (status) WHERE (status <> 'APPROVED');

索引只索引 PENDING 和 SPAM 的行,体积缩到原来的 5%。

默认行为:前 4 次定制,第 5 次通用

PG 对 PreparedStatement 的执行计划缓存策略(plan_cache_mode = auto,默认值):

执行次数

计划类型

行为

第 1-4 次

Custom Plan

根据实际参数值生成定制计划

第 5 次起

Generic Plan

忽略参数值,用通用计划

Custom Plan:每次执行都看参数值,选最优计划。代价是每次都要重新规划。

Generic Plan:规划一次,后续复用。省了规划开销,但计划可能对某些参数值是错的。

auto_explain 验证这个行为:

代码语言:javascript
复制
LOAD 'auto_explain';
SET auto_explain.log_analyze = true;
SET auto_explain.log_min_duration = 0;
SHOW plan_cache_mode;
-- auto

执行 PreparedStatement 10 次(参数 = APPROVED),再执行 1 次(参数 = SPAM):

代码语言:javascript
复制
-- 第1-4次:not prepared on the server(Custom Plan)
Time: 127ms  Params: [APPROVED]
Time: 124ms  Params: [APPROVED]
Time: 128ms  Params: [APPROVED]
Time: 116ms  Params: [APPROVED]

-- 第5次起:prepared on the server(Generic Plan)
Time: 100ms  Params: [APPROVED]
Time: 137ms  Params: [APPROVED]
...
Time: 93ms   Params: [APPROVED]

-- 第11次:SPAM 查询也用了 Generic Plan!
Time: 22ms   Params: [SPAM]

坑在哪:Generic Plan 用 Seq Scan 查 SPAM

看 PG 日志,第 5 次后的执行计划:

代码语言:javascript
复制
LOG: duration: 124.261 ms
plan:
  Query Text: SELECT id, title, status FROM post WHERE status = $1
  Seq Scan on post (cost=0.00..2386.00 rows=94997 width=57)
    (actual time=0.040..29.204 rows=95000 loops=1)
    Filter: (status = 'APPROVED'::post_status)
    Rows Removed by Filter: 5000

注意 status = $1 而不是具体值——这就是 Generic Plan。规划器不知道参数是什么,按"平均"情况估了个 Seq Scan。

用 APPROVED 查没问题,本来就是该走 Seq Scan。但查 SPAM 时:

参数值

期望计划

实际计划

期望时间

实际时间

SPAM

Index Scan

Seq Scan

0.5ms

22ms

查 1000 行 SPAM 花了 22ms——本该 0.5ms 搞定的事,慢了 44 倍

原因很简单:Generic Plan 锁定了 Seq Scan,不管你传什么参数。规划器认为"大部分时候查 APPROVED,Seq Scan 成本最优",但碰到 SPAM 就翻车了。

解法:force_custom_plan

代码语言:javascript
复制
SET plan_cache_mode = force_custom_plan;

强制每次执行都生成 Custom Plan——规划器始终看参数值,选最优计划。

设置后再执行同样的测试:

代码语言:javascript
复制
-- 每次都是 Custom Plan
Time: 36ms   Params: [APPROVED]  → Seq Scan(正确)
Time: 0.5ms  Params: [SPAM]      → Index Scan(正确)

SPAM 查询从 22ms 降回 0.5ms,快了 44 倍

plan_cache_mode 三个值

行为

适用场景

auto(默认)

前 4 次 Custom,第 5 次起切换 Generic

大部分 OLTP 场景

force_custom_plan

每次都生成定制计划

数据倾斜严重、参数敏感的查询

force_generic_plan

始终用通用计划

数据均匀、规划开销大的 OLTP

可以按会话、用户、数据库级别设置:

代码语言:javascript
复制
-- 当前会话
SET plan_cache_mode = force_custom_plan;

-- 特定用户
ALTER USER app_user SET plan_cache_mode = force_custom_plan;

-- 特定数据库
ALTER DATABASE mydb SET plan_cache_mode = force_custom_plan;

如何判断是否踩坑

方法 1:查 pg_stat_statements 的执行时间方差

代码语言:javascript
复制
SELECT query, calls, mean_exec_time, max_exec_time, stddev_exec_time
FROM pg_stat_statements
WHERE stddev_exec_time > mean_exec_time  -- 方差大于均值 = 计划不稳定
ORDER BY max_exec_time DESC
LIMIT 10;

stddev_exec_time > mean_exec_time 是强烈信号——同一条查询时而快时而慢,大概率是计划缓存的问题。

方法 2:查 pg_prepared_statements 的计划统计

代码语言:javascript
复制
SELECT name, statement, generic_plans, custom_plans
FROM pg_prepared_statements;

generic_plans > 0 且查询有性能波动,就是被通用计划"冻"住了。

方法 3:手动 DEALLOCATE 重置

代码语言:javascript
复制
-- 重置特定预处理语句
DEALLOCATE my_prepared_statement;

-- 重置所有
DEALLOCATE ALL;

重置后前 4 次执行会重新走 Custom Plan,可以验证是否是计划缓存导致的问题。

设计权衡:为什么 PG 要切换 Generic Plan

省规划开销。复杂查询的规划本身可能耗时几十甚至上百毫秒(尤其分区表,256 个分区规划时间可达 60ms)。如果每次执行都重新规划,CPU 开销可能超过执行开销。

Generic Plan 的问题

  1. 1. 不看参数值——规划器用默认选择率估算(等值过滤默认 1/ndistinct,范围过滤默认 0.5%),和实际差很远
  2. 2. 一旦锁定不回头——切到 Generic 后,除非 ANALYZE 触发统计信息更新使计划失效,否则一直用
  3. 3. 被"毒化"的风险——如果前 5 次恰好都传同一个参数值(比如都是 APPROVED),Generic Plan 就按这个值"定型"了,后续传其他值就翻车

维度

Custom Plan

Generic Plan

规划开销

每次都规划

只规划一次

计划质量

最优(看参数值)

可能次优(不看参数值)

数据倾斜适应

适合 OLAP

✅(并发低,计划质量优先)

适合 OLTP

看情况

✅(并发高,规划开销优先)

实战建议

  1. 1. 数据倾斜 + PreparedStatement → 考虑 force_custom_plan:状态字段、优先级字段、类型字段,这些都是倾斜高发区
  2. 2. 分区表 + 复杂查询 → force_custom_plan:规划时间可能 60ms+,但 Generic Plan 可能选错分区
  3. 3. OLTP + 数据均匀 → 保持 auto:大部分场景默认值就够了
  4. 4. 提高统计目标ALTER TABLE post ALTER COLUMN status SET STATISTICS 500; 给规划器更精细的直方图
  5. 5. 部分索引:对倾斜字段,只索引高选择性值,既省空间又避免计划翻车

引用链接

  • 原文:PostgreSQL plan_cache_mode by Vlad Mihalcea:https://vladmihalcea.com/postgresql-plan-cache-mode/
  • PG 官方文档:plan_cache_mode:https://www.postgresql.org/docs/current/runtime-config-query.html#RUNTIME-CONFIG-QUERY-OTHER
  • PostgreSQL's Generic Plan Trap:https://dev.to/msdousti/postgresqls-generic-plan-trap-1382
  • The Hidden Behavior of plan_cache_mode:https://richyen.com/postgres/2026/03/30/plan_cache_mode.html
  • Cybertec:How PostgreSQL 12 handles prepared plans:https://www.cybertec-postgresql.com/en/tech-preview-how-postgresql-12-handles-prepared-plans/
本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-07-26,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 河马coding 微信公众号,前往查看

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

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • PostgreSQL plan_cache_mode:一个参数让倾斜数据的查询快了 44 倍
    • 数据倾斜:95% 都是 APPROVED
    • 同一条查询,三种执行计划
    • 部分索引:砍掉不需要的 95%
    • 默认行为:前 4 次定制,第 5 次通用
    • 坑在哪:Generic Plan 用 Seq Scan 查 SPAM
    • 解法:force_custom_plan
    • plan_cache_mode 三个值
    • 如何判断是否踩坑
    • 设计权衡:为什么 PG 要切换 Generic Plan
    • 实战建议
    • 引用链接
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档