
同一条 PreparedStatement,第 5 次执行后突然变慢——不是数据变了,是执行计划被"冻结"了。
假设有一张 post 表,10 万行,状态字段是枚举类型:
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; status | matching_records | selectivity
---------+------------------+-------------
APPROVED| 95000 | 0.95
PENDING | 4000 | 0.04
SPAM | 1000 | 0.0195% 是 APPROVED,4% 是 PENDING,1% 是 SPAM。典型的长尾分布。
SELECT id, title, status FROM post WHERE status = ?;为了优化查询,在 status 上建了索引:
CREATE INDEX idx_post_status ON post (status);但 PG 不会无脑走索引——它根据选择性决定执行计划:
查 PENDING(4%,4000行)→ Bitmap Heap Scan
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
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(全表扫描)
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 |
关键点:最优执行计划依赖参数值。这正是后面踩坑的根源。
既然 APPROVED 永远走全表扫描,索引里存它纯属浪费空间。改成部分索引:
CREATE INDEX idx_post_status ON post (status) WHERE (status <> 'APPROVED');索引只索引 PENDING 和 SPAM 的行,体积缩到原来的 5%。
PG 对 PreparedStatement 的执行计划缓存策略(plan_cache_mode = auto,默认值):
执行次数 | 计划类型 | 行为 |
|---|---|---|
第 1-4 次 | Custom Plan | 根据实际参数值生成定制计划 |
第 5 次起 | Generic Plan | 忽略参数值,用通用计划 |
Custom Plan:每次执行都看参数值,选最优计划。代价是每次都要重新规划。
Generic Plan:规划一次,后续复用。省了规划开销,但计划可能对某些参数值是错的。
用 auto_explain 验证这个行为:
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):
-- 第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]看 PG 日志,第 5 次后的执行计划:
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 就翻车了。
SET plan_cache_mode = force_custom_plan;强制每次执行都生成 Custom Plan——规划器始终看参数值,选最优计划。
设置后再执行同样的测试:
-- 每次都是 Custom Plan
Time: 36ms Params: [APPROVED] → Seq Scan(正确)
Time: 0.5ms Params: [SPAM] → Index Scan(正确)SPAM 查询从 22ms 降回 0.5ms,快了 44 倍。
值 | 行为 | 适用场景 |
|---|---|---|
auto(默认) | 前 4 次 Custom,第 5 次起切换 Generic | 大部分 OLTP 场景 |
force_custom_plan | 每次都生成定制计划 | 数据倾斜严重、参数敏感的查询 |
force_generic_plan | 始终用通用计划 | 数据均匀、规划开销大的 OLTP |
可以按会话、用户、数据库级别设置:
-- 当前会话
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 的执行时间方差
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 的计划统计
SELECT name, statement, generic_plans, custom_plans
FROM pg_prepared_statements;generic_plans > 0 且查询有性能波动,就是被通用计划"冻"住了。
方法 3:手动 DEALLOCATE 重置
-- 重置特定预处理语句
DEALLOCATE my_prepared_statement;
-- 重置所有
DEALLOCATE ALL;重置后前 4 次执行会重新走 Custom Plan,可以验证是否是计划缓存导致的问题。
省规划开销。复杂查询的规划本身可能耗时几十甚至上百毫秒(尤其分区表,256 个分区规划时间可达 60ms)。如果每次执行都重新规划,CPU 开销可能超过执行开销。
Generic Plan 的问题:
维度 | Custom Plan | Generic Plan |
|---|---|---|
规划开销 | 每次都规划 | 只规划一次 |
计划质量 | 最优(看参数值) | 可能次优(不看参数值) |
数据倾斜适应 | 强 | 弱 |
适合 OLAP | ✅(并发低,计划质量优先) | ❌ |
适合 OLTP | 看情况 | ✅(并发高,规划开销优先) |
ALTER TABLE post ALTER COLUMN status SET STATISTICS 500; 给规划器更精细的直方图