DeepSeek总结的plan_cache_mode 的隐藏行为
原文地址: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-s10-Upostgres 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 行选择顺序扫描。SETplan_cache_mode=force_custom_plan;PREPAREflag_lookup(char)ASSELECTaid,abalanceFROMpgbench_accountsWHEREflag=$1;EXPLAINEXECUTEflag_lookup('Y');QUERY PLAN ------------------------------------------------------------------------- Seq Scan on pgbench_accounts (cost=0.00..28910.00 rows=999033 width=8) Filter: (flag = 'Y'::bpchar) <-- 字面值 'Y' 表示自定义计划DEALLOCATEflag_lookup;-- 通用计划:规划器没有值可以查找。由于 ndistinct = 2-- (只有 'Y' 和 'N' 存在),它估计选择性为 1/ndistinct = 50%,-- 即 500,000 行。在此估计下,更便宜的路径是索引扫描。SETplan_cache_mode=force_generic_plan;PREPAREflag_lookup(char)ASSELECTaid,abalanceFROMpgbench_accountsWHEREflag=$1;EXPLAINEXECUTEflag_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 次执行开始选择通用计划。
DEALLOCATEflag_lookup;SETplan_cache_mode=auto;PREPAREflag_lookup(char)ASSELECTaid,abalanceFROMpgbench_accountsWHEREflag=$1;-- 执行 1-5 次:自定义计划,每次都解析字面值 'Y'EXPLAIN(COSTSOFF)EXECUTEflag_lookup('Y');EXPLAIN(COSTSOFF)EXECUTEflag_lookup('Y');EXPLAIN(COSTSOFF)EXECUTEflag_lookup('Y');EXPLAIN(COSTSOFF)EXECUTEflag_lookup('Y');EXPLAIN(COSTSOFF)EXECUTEflag_lookup('Y');每次显示:
QUERY PLAN -------------------------------- Seq Scan on pgbench_accounts Filter: (flag = 'Y'::bpchar)在第六次执行时:
EXPLAIN(COSTSOFF)EXECUTEflag_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(COSTSOFF)EXECUTEflag_lookup('Y');-- 999,000 行通过索引扫描(糟糕!)EXPLAIN(COSTSOFF)EXECUTEflag_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明确说明了这一点:
staticboolchoose_custom_plan(CachedPlanSource*plansource){/* ... 检查 force_custom / force_generic 的设置 ... *//* 如果我们还没有执行 5 次自定义计划,继续执行 */if(plansource->num_custom_plans<5)returntrue;/* 否则,将 generic_cost 与平均 custom_cost 进行比较。 * 如果通用计划更便宜(或相等),我们就切换! */if(plansource->generic_cost<=plansource->total_custom_cost/plansource->num_custom_plans)returnfalse;returntrue;}最后的思考
查询规划器的自动计划缓存通常是英雄,节省了 CPU 周期。但是,当你拥有高度偏斜的数据或易变的临时对象时,这种“第六次运行切换”可能会对客户端/应用程序性能产生负面影响。
如果你在预备语句中看到无法解释的性能回退,你可能想检查它是否被调用了超过 5 次,或者尝试SET plan_cache_mode = force_custom_plan作为排查步骤。这会强制每次执行都生成一个全新的自定义计划,确保规划器总是能看到实际的参数值,并能选择正确的策略。
祝你好运!
