PostgreSQL 计划缓存实战(第 10 篇):预编译 SQL 前五次都快,第六次为什么可能变慢

普通租户只有百条订单,头部租户却有九十万条。相同 prepared statement 前几次都快,某个连接复用后,头部租户突然用索引扫描九十万行;重连又恢复。直接答案是:这个会话可能在累计五个 custom plan 后开始评估 generic plan,而 generic plan 看不到本次参数,冷热租户差异便被平均分布掩盖。

Custom plan 看得到本次参数,generic plan 省掉重复规划却只能按平均分布估算。参数倾斜越强,省下的规划毫秒越可能换来执行秒级退化。
在这里插入图片描述

先纠正“第六次必切换”

plan_cache_mode = auto 时,PostgreSQL 对带参数的 prepared statement 前五次使用 custom plan,并计算其平均估算成本;之后生成 generic plan,将其成本与平均 custom 成本比较。如果重复规划看起来不值得,后续才使用 generic plan。

因此:

  • 第六次是开始具备选择 generic 的条件,不是必然切换;
  • 最终可能继续 custom;
  • DDL、统计更新等会触发重新分析/规划;
  • 每个数据库会话拥有自己的 prepared statement 与计数历史。

源码证明:比较的不是前五次真实耗时

PostgreSQL 18.6 的核心入口位于 src/backend/utils/cache/plancache.cchoose_custom_plan()。去掉强制模式、一次性计划等前置分支后,决策等价于:

# 逻辑等价伪代码,不是可编译源码
num_custom_plans < 5                  → custom
avg_custom_cost = total_custom_cost / num_custom_plans
generic_cost < avg_custom_cost       → generic
其他情况                              → custom

这里有两个容易写错的细节:

  1. 比较的是 planner 的估算成本,不是前五次的真实执行时间;
  2. custom cost 会计入重复规划的估算开销,generic cost 不计每次重规划,因此 generic 只要在“执行估算 + 重规划代价”的总账上更便宜就可能胜出。

第一次尝试 generic 时,generic_cost 尚未确定,代码会先构建 generic plan、记录成本,再调用一次 choose_custom_plan() 复核;如果 custom 一直明显占优,这个刚生成的 generic plan 不会被实际执行。因此,“第六次开始评估”比“第六次执行 generic”更准确。

PREPARE/EXECUTE
  → GetCachedPlan()
  → choose_custom_plan()
  → num_custom_plans、total_custom_cost、generic_cost
  → custom 或 generic
  → pg_prepared_statements.generic_plans/custom_plans

本文不补无关 Java 示例:要证明的是 PostgreSQL 服务端计划缓存决策,SQL PREPARE/EXECUTE 是最直接入口。JDBC 驱动的 server-side prepare 阈值属于调用前置条件,必须在生产排查时另行确认,不能代替服务端证据。

构造冷热租户

DROP TABLE IF EXISTS tenant_order;

CREATE TABLE tenant_order (
    id bigint PRIMARY KEY,
    tenant_id bigint NOT NULL,
    payload text NOT NULL
);

-- 热租户 1:900000 行
INSERT INTO tenant_order
SELECT g, 1, repeat('h', 100)
FROM generate_series(1, 900000) AS g;

-- 1000 个冷租户:每个约 100 行
INSERT INTO tenant_order
SELECT 900000 + g, 2 + ((g - 1) % 1000), repeat('c', 100)
FROM generate_series(1, 100000) AS g;

CREATE INDEX tenant_order_tenant_idx ON tenant_order (tenant_id);
ANALYZE tenant_order;

PREPARE tenant_q(bigint) AS
SELECT sum(length(payload))
FROM tenant_order
WHERE tenant_id = $1;

先做确定性对照

SET plan_cache_mode = force_custom_plan;
EXPLAIN (ANALYZE, BUFFERS, SETTINGS) EXECUTE tenant_q(2);
EXPLAIN (ANALYZE, BUFFERS, SETTINGS) EXECUTE tenant_q(1);

SET plan_cache_mode = force_generic_plan;
EXPLAIN (ANALYZE, BUFFERS, SETTINGS) EXECUTE tenant_q(2);
EXPLAIN (ANALYZE, BUFFERS, SETTINGS) EXECUTE tenant_q(1);

Custom plan 对冷租户通常适合索引路径,对 90% 行都命中的热租户可能选择顺序扫描。Generic plan 中会保留 $1,无法知道这次是头部租户,可能按平均每租户行数选择索引路径,导致热参数大量随机/重复 heap 访问。

实际计划取决于缓存、成本参数和数据宽度。这个实验的判定不是强求某个节点,而是比较同一参数在 custom/generic 下的行数估算、Buffers 与耗时。

恢复自动模式,重新准备以清空该语句的计划历史:

DEALLOCATE tenant_q;
SET plan_cache_mode = auto;

PREPARE tenant_q(bigint) AS
SELECT sum(length(payload))
FROM tenant_order
WHERE tenant_id = $1;

EXECUTE tenant_q(2);
EXECUTE tenant_q(3);
EXECUTE tenant_q(4);
EXECUTE tenant_q(5);
EXECUTE tenant_q(6);

EXPLAIN (ANALYZE, BUFFERS, SETTINGS) EXECUTE tenant_q(1);

SELECT name, generic_plans, custom_plans, statement
FROM pg_prepared_statements
WHERE name = 'tenant_q';

前五次全是冷租户,会影响平均 custom 成本;第六次热参数可能遇到 generic,也可能算法仍判定 custom 更好。generic_plans/custom_plans 是事实证据,不能仅凭“恰好第六次慢”倒推。

还应记录同一次观察前后的计数差,而不是只看最终总数:

SELECT name, generic_plans, custom_plans
FROM pg_prepared_statements
WHERE name = 'tenant_q';

EXPLAIN (ANALYZE, BUFFERS, SETTINGS) EXECUTE tenant_q(1);

SELECT name, generic_plans, custom_plans
FROM pg_prepared_statements
WHERE name = 'tenant_q';

generic_plans 增加,说明这次取得了 generic plan;若只看到 $1,也应与计数交叉验证,避免把展示差异当成完整会话历史。

为什么连接池让故障像随机事件

Prepared statement 是 session 对象。连接池中的每个后端经历不同参数序列:

连接 A:冷、冷、冷、冷、冷 → generic → 热参数退化
连接 B:热、热、冷、热、冷 → custom 平均成本不同
连接 C:刚重建连接 → 重新从 custom 开始

应用日志看到同一 SQL,数据库看到的是多份会话级计划历史。pg_prepared_statements 也只显示当前会话可见对象,不能从一个管理连接观察整个池。

还要确认驱动是否真的创建 server-side prepared statement、准备阈值是多少、事务池化是否保留 session,以及代理是否改写连接语义。客户端叫“预编译”不自动等于 PostgreSQL PREPARE

四种处理路径

路径适用条件代价与边界
保持 auto参数分布较均匀,规划成本值得节省极端热点可能被平均值掩盖
局部 force_custom_plan参数强烈决定路径、单次执行较重每次支付规划 CPU
拆分冷热 SQL/连接热租户可稳定识别应用路由与观测复杂度上升
索引、分区或模型治理倾斜是长期业务事实变更成本高,但可能根治访问路径

不要全局强制 custom。高 QPS、执行极短的语句可能把大量 CPU 浪费在重复规划;也不要用 DEALLOCATE 或重连作为长期修复,它只重置历史,退化可能再次出现。

生产证据链

  1. 在同一会话、同一参数下分别强制 custom/generic,比较计划与执行。
  2. $1 是否仍出现在计划中,结合 generic_plans/custom_plans 确认类型。
  3. 找第一处 estimated/actual rows 分叉,确认租户热点是否进入统计。
  4. 核对驱动、连接池、代理和 prepared threshold。
  5. pg_stat_statements 比较 calls、planning/execution time 与波动,但注意它不会直接替代会话级计划证据。
  6. 在真实参数分布和并发下计算“规划 CPU + 执行成本”的总账。

如果第一处分叉来自列相关性或数据分布估错,先阅读第 9 篇:SQL 和索引没变,计划为什么突然慢一百倍修复统计证据;计划缓存不能替代基数估算治理。

灰度与回滚

优先对专用报表角色、事务或单个连接设置:

BEGIN;
SET LOCAL plan_cache_mode = force_custom_plan;
-- 目标查询
COMMIT;

灰度记录热/冷参数 p95、规划 CPU、数据库总 CPU 和连接池吞吐。若规划时间或整体 CPU 超过停止阈值,停止扩大范围。恢复 auto 可撤销设置,但不能自动修复数据倾斜和索引模型。

证据边界

证据能证明不能证明
第六次变慢与启发式时点吻合一定已经切 generic
计划中出现 $1当前展示的是 generic plan所有连接都使用 generic
强制 custom 更快参数感知对该值有收益全局 custom 总成本更低
重连恢复session 状态参与故障根因只有 plan cache
统计显示热点planner 有热点信息generic 能使用本次参数值

面试表达主线

Prepared statement 可以用参数感知 custom plan,也可复用 generic plan。Auto 前五次采样 custom 成本,之后比较 generic 与平均 custom 成本,并非第六次必切。参数倾斜时要在同一会话对比两类计划,并结合驱动、连接池和规划 CPU 做局部治理。

实验清理

DEALLOCATE tenant_q;
RESET plan_cache_mode;
DROP TABLE IF EXISTS tenant_order;

官方资料

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值