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()。去掉强制模式、一次性计划等前置分支后,决策等价于:

text 复制代码
# 逻辑等价伪代码,不是可编译源码
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"更准确。

text 复制代码
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 阈值属于调用前置条件,必须在生产排查时另行确认,不能代替服务端证据。

构造冷热租户

sql 复制代码
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;

先做确定性对照

sql 复制代码
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 与耗时。

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

sql 复制代码
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 是事实证据,不能仅凭"恰好第六次慢"倒推。

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

sql 复制代码
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 对象。连接池中的每个后端经历不同参数序列:

text 复制代码
连接 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 和索引没变,计划为什么突然慢一百倍修复统计证据;计划缓存不能替代基数估算治理。

灰度与回滚

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

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 做局部治理。

实验清理

sql 复制代码
DEALLOCATE tenant_q;
RESET plan_cache_mode;
DROP TABLE IF EXISTS tenant_order;

官方资料

相关推荐
ClouGence2 小时前
CloudDM 支持达梦、KingbaseES、GoldenDB,国产数据库也能统一管起来
数据库·sql·开源
windliang3 小时前
Agent Loop 的 while 循环什么时候开始不够用
人工智能·面试·开源
CarIise3 小时前
JDBC数据库操作与SQL注入防范
数据库·sql
疯狂打码的少年4 小时前
【数据库技术】SQL嵌套查询(IN/EXISTS/ANY/ALL)
数据库·笔记·sql
不要打扰7564 小时前
2027国网提前批宣讲、面试、预报名、内部笔试时间一览
面试·职场和发展
YHHLAI4 小时前
TypeScript 高级工具类型面试指南
ubuntu·面试·typescript
二进制漫游记4 小时前
PostgreSQL超详细入门教程:Windows安装+Python异步连接+完整增删改查实战
python·postgresql
跨境数据猎手4 小时前
京东开放平台商品详情接口(jd.item_get):签名、缓存与批量采集
java·spring·缓存
IvorySQL4 小时前
基于 PostgreSQL 的非线性回归原理与 AI 数据库融合实践
数据库·人工智能·postgresql