DeepSeek总结的plan_cache_mode 的隐藏行为
本文揭示了PostgreSQL中plan_cache_mode参数的隐藏行为。默认设置为auto时,规划器会在预备语句执行5次后比较自定义计划和通用计划的成本,可能自动切换到通用计划。通过pgbench演示展示了这种切换如何导致查询计划从顺序扫描变为索引扫描,即使数据未变。文章指出这种自动切换可能对偏斜数据或频繁执行的预备语句产生性能影响,建议通过force_custom_plan模式排查类似问题
原文地址:https://richyen.com/postgres/2026/03/30/plan_cache_mode.html
plan_cache_mode 的隐藏行为
2026年3月30日
引言
大多数 PostgreSQL 用户使用预备语句来提升性能并防止 SQL 注入。很少有人知道,查询规划器会在恰好执行五次之后,悄无声息地更改预备语句的执行计划。
这种行为常常让工程师们感到惊讶,因为一个查询计划可能会突然转变——有时甚至是戏剧性的变化,尽管查询本身并未改变。原因在于规划器处理自定义计划与通用计划的方式,而这由参数 plan_cache_mode 控制。
自定义计划 vs 通用计划
当预备语句带有参数执行时,规划器有两种选择:
- 自定义计划:使用实际的参数值生成。它可能针对该次特定执行是最优的,但每次都需要规划开销。
- 通用计划:在不知道具体参数值的情况下规划一次。它被重用于所有后续执行,以节省规划开销。
默认情况下,plan_cache_mode 设置为 auto。在此模式下,规划器在前五次执行时使用自定义计划。在第六次执行时,它会比较这些自定义计划的平均成本与通用计划的估计成本。如果通用计划被认为“更便宜”或相等,规划器将在该会话中永久切换到通用计划。
用 pgbench 演示
一如既往,pgbench 是进行简单演示时的首选模式。撰写本文时,我使用的是最新版本的 Postgres 18。出于本文的目的,添加一个具有高度偏斜值的列更容易触发切换。因此,我们添加一个具有极端偏斜的标记列:'N' 占 0.1% 的行,'Y' 占其余 99.9% 的行:
### 在 bash 中:
pgbench -i -s 10 -U postgres postgres
### 在 psql 中:
ALTER TABLE pgbench_accounts ADD COLUMN flag CHAR(1) NOT NULL DEFAULT 'Y';
UPDATE pgbench_accounts SET flag = 'N' WHERE aid <= 1000;
CREATE INDEX idx_accounts_flag ON pgbench_accounts(flag);
ANALYZE pgbench_accounts;
SELECT flag, count(*) FROM pgbench_accounts GROUP BY flag;
flag | count
------+--------
N | 1000
Y | 999000
在触发自动切换之前,让我们直接强制使用每种模式,看看规划器为同一个语句生成什么计划。
-- 自定义计划:规划器看到字面值 'Y',在列统计信息中查找
-- (MCV 频率 ≈ 0.999),并为 999,033 行选择顺序扫描。
SET plan_cache_mode = force_custom_plan;
PREPARE flag_lookup(char) AS
SELECT aid, abalance FROM pgbench_accounts WHERE flag = $1;
EXPLAIN EXECUTE flag_lookup('Y');
QUERY PLAN
-------------------------------------------------------------------------
Seq Scan on pgbench_accounts (cost=0.00..28910.00 rows=999033 width=8)
Filter: (flag = 'Y'::bpchar) <-- 字面值 'Y' 表示自定义计划
DEALLOCATE flag_lookup;
-- 通用计划:规划器没有值可以查找。由于 ndistinct = 2
-- (只有 'Y' 和 'N' 存在),它估计选择性为 1/ndistinct = 50%,
-- 即 500,000 行。在此估计下,更便宜的路径是索引扫描。
SET plan_cache_mode = force_generic_plan;
PREPARE flag_lookup(char) AS
SELECT aid, abalance FROM pgbench_accounts WHERE flag = $1;
EXPLAIN EXECUTE flag_lookup('Y');
QUERY PLAN
--------------------------------------------------------------------------------------------
Index Scan using idx_accounts_flag on pgbench_accounts (cost=0.42..19322.07 rows=500000)
Index Cond: (flag = $1) <-- 注意占位符 $1,而不是字面值 'Y'/'N'
成本数字揭示了选择索引扫描而非顺序扫描的原因:19,322 < 28,910。
自动切换的实际效果
将 plan_cache_mode 重置回 auto 后,我们使用常用值 'Y' 执行该语句五次。每次运行都会生成一个自定义的顺序扫描计划,成本约为 28,910。五次执行之后,规划器比较:
- 平均自定义计划成本:~28,910
- 通用计划成本:~19,322
由于 19,322 ≤ 28,910,从第 6 次执行开始选择通用计划。
DEALLOCATE flag_lookup;
SET plan_cache_mode = auto;
PREPARE flag_lookup(char) AS
SELECT aid, abalance FROM pgbench_accounts WHERE flag = $1;
-- 执行 1-5 次:自定义计划,每次都解析字面值 'Y'
EXPLAIN (COSTS OFF) EXECUTE flag_lookup('Y');
EXPLAIN (COSTS OFF) EXECUTE flag_lookup('Y');
EXPLAIN (COSTS OFF) EXECUTE flag_lookup('Y');
EXPLAIN (COSTS OFF) EXECUTE flag_lookup('Y');
EXPLAIN (COSTS OFF) EXECUTE flag_lookup('Y');
每次显示:
QUERY PLAN
--------------------------------
Seq Scan on pgbench_accounts
Filter: (flag = 'Y'::bpchar)
在第六次执行时:
EXPLAIN (COSTS OFF) EXECUTE flag_lookup('Y');
QUERY PLAN
--------------------------------------------------------
Index Scan using idx_accounts_flag on pgbench_accounts
Index Cond: (flag = $1)
策略在第六次调用时从顺序扫描转变为索引扫描——尽管查询和数据完全相同。$1 占位符确认了现在使用的是通用计划。
它会切换回来吗?
从第 6 次执行开始,每个查询——无论参数值是什么——都使用那个通用的索引扫描。对于 'N'(1000 行),索引扫描恰好是高效的。对于 'Y'(999,000 行),通过随机索引查找来扫描近 100 万行的表,比顺序扫描要差得多。
-- 第 7+ 次执行:无论值如何,都使用通用计划
EXPLAIN (COSTS OFF) EXECUTE flag_lookup('Y'); -- 999,000 行通过索引扫描(糟糕!)
EXPLAIN (COSTS OFF) EXECUTE flag_lookup('N'); -- 1,000 行通过索引扫描(偶然可以)
两者都显示:
QUERY PLAN
--------------------------------------------------------
Index Scan using idx_accounts_flag on pgbench_accounts
Index Cond: (flag = $1)
通用计划会一直保持,直到执行 DEALLOCATE flag_lookup 或会话结束。对于频繁执行的预备语句来说,这无疑是需要注意的一点,因为它对我合作过的一些客户造成了显著的影响。
幕后:C 逻辑
只是为了强调数字 5 并不是由任何花哨的逻辑决定的,我们可以在源代码中找到它。在 src/backend/utils/cache/plancache.c 中(大约第 1200 行),函数 choose_custom_plan 明确说明了这一点:
static bool
choose_custom_plan(CachedPlanSource *plansource)
{
/* ... 检查 force_custom / force_generic 的设置 ... */
/* 如果我们还没有执行 5 次自定义计划,继续执行 */
if (plansource->num_custom_plans < 5)
return true;
/* 否则,将 generic_cost 与平均 custom_cost 进行比较。
* 如果通用计划更便宜(或相等),我们就切换!
*/
if (plansource->generic_cost <= plansource->total_custom_cost / plansource->num_custom_plans)
return false;
return true;
}
最后的思考
查询规划器的自动计划缓存通常是英雄,节省了 CPU 周期。但是,当你拥有高度偏斜的数据或易变的临时对象时,这种“第六次运行切换”可能会对客户端/应用程序性能产生负面影响。
如果你在预备语句中看到无法解释的性能回退,你可能想检查它是否被调用了超过 5 次,或者尝试 SET plan_cache_mode = force_custom_plan 作为排查步骤。这会强制每次执行都生成一个全新的自定义计划,确保规划器总是能看到实际的参数值,并能选择正确的策略。
祝你好运!
更多推荐



所有评论(0)