普通租户只有百条订单,头部租户却有九十万条。相同 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.c 的 choose_custom_plan()。去掉强制模式、一次性计划等前置分支后,决策等价于:
text
# 逻辑等价伪代码,不是可编译源码
num_custom_plans < 5 → custom
avg_custom_cost = total_custom_cost / num_custom_plans
generic_cost < avg_custom_cost → generic
其他情况 → custom
这里有两个容易写错的细节:
- 比较的是 planner 的估算成本,不是前五次的真实执行时间;
- 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 或重连作为长期修复,它只重置历史,退化可能再次出现。
生产证据链
- 在同一会话、同一参数下分别强制 custom/generic,比较计划与执行。
- 看
$1是否仍出现在计划中,结合generic_plans/custom_plans确认类型。 - 找第一处 estimated/actual rows 分叉,确认租户热点是否进入统计。
- 核对驱动、连接池、代理和 prepared threshold。
- 用
pg_stat_statements比较 calls、planning/execution time 与波动,但注意它不会直接替代会话级计划证据。 - 在真实参数分布和并发下计算"规划 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;
