原文地址: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 作为排查步骤。这会强制每次执行都生成一个全新的自定义计划,确保规划器总是能看到实际的参数值,并能选择正确的策略。

祝你好运!

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐