结论先行:PostgreSQL 的「auto plan cache」机制(named prepared statement 执行满 5 次后自动比较 generic plan 与 custom plan 的代价并择优切换)在参数分布倾斜时会反噬——generic plan 的代价估算基于「假设参数值」,对列的实际分布不敏感,一旦切过去,热点参数和冷参数被迫共用同一份 plan,P99 直接跳到秒级。修复手段不是调
work_mem、不是扩连接池,而是按 SQL 粒度把plan_cache_mode锁成force_generic_plan或force_custom_plan,并用pg_stat_statements的plans/calls比率建立监控。
一、现象:P50 几乎没动,P99 却从 12ms 跳到 4.8s
先交代规模,方便对照:
- 库:PostgreSQL 15.4,32C 256G,NVMe(4K 随机读 ~700k IOPS);
- 表:
t_user_event,140 亿行,PARTITION BY RANGE (event_date),18 个月分区,主索引(user_id, event_ts); - 服务:HikariCP 5.1,prepared statement(named),QPS 约 900;
- 典型 SQL:
SELECT event_ts, event_type FROM t_user_event WHERE user_id = $1 AND event_date = $2 ORDER BY event_ts DESC LIMIT 20。
事故时间线(持续约 6 小时,凌晨 02:00 ~ 08:00):
- P50 从 6ms 缓涨到 18ms(看起来"还行");
- P99 从稳态 12ms 跳到 4.8s,客服工单 30 分钟堆 200+ 条;
- CPU 仅 42%,内存离 OOM 还差 60GB,GC 正常;
- 慢日志(
log_min_duration_statement=200ms)没有单条 >2s 的"慢 SQL"记录——因为 P99 的 4.8s 是同一句 SQL、不同参数的尾部,平均值被 90% 的快请求稀释了。
这个"监控全绿 + P99 毛刺 + 慢日志抓不到"的组合,最后指向了 plan cache 切换。
二、根因:generic plan 的代价估算是「假设参数」,对倾斜分布失明
PostgreSQL 对 named prepared statement 的 plan 缓存逻辑(plan_cache_mode=auto,PG 13+)是:
- 前 5 次执行都用 custom plan(把具体参数值代入优化器);
- 之后每 5 次执行,临时生成一个 generic plan(参数用占位符,优化器按"假设参数值"估代价),与 custom plan 比较;
- 若 generic 的估算代价更低(含规划开销),就切换到 generic 并粘住,直到某次 custom 明显更优才切回。
关键机制在第 2 步:generic plan 的代价估算里,user_id = $1 的选择性是按列统计的假设分布估的,不是按真实传入值。我们的 user_id 分布极偏:
- Top 1% 的
user_id(大 V / 机构号)占了全表 6.2% 的行,却贡献了 41% 的 QPS; - 这些热点
user_id在单月分区里对应约 800 万行,custom plan 走(user_id, event_ts)索引 +LIMIT 20,代价 ~180; - 但 generic plan 的估算代价 ~3,200(基于假设参数值 + 分区裁剪后仍偏高的行数),优化器认为"走索引反而更贵",改选了 partition-wide 的
event_ts倒序扫描 + 过滤。
切换发生的那一刻:热点用户查询从"索引 20 行"退化成"分区内顺序扫几十万行找 20 行",P99 从 12ms 跳到秒级。冷用户查询其实没变慢,所以 P50 只从 6ms 爬到 18ms——平均值和 P50 都骗人,只有 P99 暴露了问题。
三、具体做法:按 SQL 锁 plan 模式 + 用 plans/calls 比率监控
3.1 定位:用 pg_stat_statements 的 plans 与 calls 找"切换点"
SELECT
pgss.queryid,
left(pgss.query, 90) AS query,
pgss.calls,
pgss.plans,
round(pgss.plans::numeric / greatest(pgss.calls,1), 4) AS plan_ratio,
pgss.mean_executing_time,
pgss.temp_files,
pgss.temp_blks_read
FROM pg_stat_statements pgss
ORDER BY pgss.plans DESC
LIMIT 20;
事故窗口内我们那条 SQL 的 plans/calls 比率从 0.008 跳到 0.412,temp_files 从 0 跳到 137(generic plan 走了磁盘排序),mean_executing_time 从 8.4ms 涨到 210ms。这三项同时异动 = generic plan 切进去了。
3.2 验证:手动 EXPLAIN 看两种 plan 的实际代价
-- custom plan(代入热点 user_id)
EXPLAIN (ANALYZE, BUFFERS)
SELECT event_ts, event_type FROM t_user_event
WHERE user_id = 900217 AND event_date = '2026-10-01'
ORDER BY event_ts DESC LIMIT 20;
-- → Index Scan using t_user_event_uid_ts_idx, rows=20, 2.1ms
-- 模拟 generic plan 的决策路径(参数用占位符)
PREPARE p AS
SELECT event_ts, event_type FROM t_user_event
WHERE user_id = $1 AND event_date = $2 ORDER BY event_ts DESC LIMIT 20;
EXPLAIN (ANALYZE, GENERIC_PLAN) EXECUTE p(900217, '2026-10-01');
-- → Seq Scan on t_user_event_2026_10, rows=8,124,003, 3.9s
两条 plan 的估算代价差 1.7 万倍,优化器切 generic 是"理性"的——它只是被假设参数值骗了。
3.3 修复:按 SQL 粒度锁 plan 模式
-- 这条 SQL 参数强倾斜,强制 custom plan
ALTER ROLE app_service SET plan_cache_mode = 'force_custom_plan';
但注意:plan_cache_mode 是会话级 GUC,不能按单条 SQL 设。我们的实际落地是按连接池拆分:
- 把这条热点 SQL 路由到独立 HikariCP 池(
maximumPoolSize=8),该池启动时ALTER ROLE设force_custom_plan; - 其余 SQL 保持
auto(它们的参数分布均匀,generic plan 本来就更优——规划开销从 ~200µs 降到 ~15µs)。
修复后 6 小时观察:P99 回到 9.4ms(比修复前还略好,因为 custom plan 对热点参数命中索引更准),temp_files 归 0,plans/calls 稳定在 0.2(custom 模式下每次执行都规划,plans≈calls/5)。
3.4 兜底:给热点参数加物化视图
对 Top 1% 的热点 user_id,单独维护一张 t_user_event_hot(只存最近 7 天,按 user_id 分区),查询路由时先查热点表、miss 再回原表。这一步把热点路径的 P99 进一步压到 2.8ms,且彻底解耦了参数倾斜对 generic plan 的冲击。
四、踩过的坑
EXPLAIN不带ANALYZE时看到的是"当前参数值"的 plan,不等于 prepared statement 实际用的 plan——prepared statement 的 plan 是会话级的,每个连接独立缓存。我们用EXPLAIN看到"走索引"就以为没问题,结果线上 8 个连接里有 5 个已经切到 generic plan。定位时必须用pg_stat_statements的plans计数 + 实际执行耗时交叉验证。plan_cache_mode是PGC_USERSET,可以SET但会随连接丢失——我们第一次在应用启动脚本里SET,结果连接池复用旧连接,设置没生效。正确做法是ALTER ROLE或ALTER DATABASE,或者在 HikariCP 的connectionInitSql里SET。force_custom_plan不是万能的:对参数分布均匀的 SQL(如按event_date范围查),custom plan 每次都要重新规划(~200µs),900 QPS 下规划开销占 CPU 的 1.8%,反而比 generic plan(~15µs)更贵。所以必须按 SQL 粒度决策,不能全局一刀切。log_min_duration_statement抓不到 P99 毛刺:它是按单条语句耗时记的,P99 的 4.8s 是同一句 SQL 的尾部,平均值被 90% 的快请求稀释到 210ms,慢日志(阈值 200ms)只记了 3 条,完全掩盖了问题的量级。
五、结论与可复用清单
- prepared statement 的 plan 是会话级的,不是全局的:每个连接独立缓存,
EXPLAIN看到的不一定是线上实际用的 plan。 pg_stat_statements的plans字段是 plan 切换率的直接证据:plans/calls比率突然升高 +temp_files异动 = generic plan 切进去了。temp_files/temp_blks_read是 generic plan 退化的硬指标:custom plan 走内存排序时这两项为 0,generic plan 走磁盘排序时会跳变。- 修复优先级:① 按 SQL 粒度设
plan_cache_mode(ALTER ROLE或connectionInitSql);② 热点参数拆物化视图;③ 最后才考虑调work_mem或扩连接池(这两项对 plan 选择无效)。 plan_cache_mode三档适用场景:force_generic_plan:参数分布均匀、表大、索引选择性稳定,追求最低规划开销;force_custom_plan:参数强倾斜、不同参数值代价差 >10x,追求最准的执行计划;auto(默认):参数分布未知或混合,让优化器自己选——但要监控plans/calls比率防"静默切换"。
- 监控建议:
pg_stat_statements按queryid聚合,plans/calls > 0.05且temp_files增长 >10 的 SQL 纳入告警,提前 5 分钟感知 plan 切换。
参考了站内 #1427(work_mem 按节点 × 并发累积)的"P99 抖动"分析框架——该文指出 P99 毛刺往往不是单点参数问题,而是"并发 × 单请求资源"的乘法效应;本文的 generic plan 切换是同构的:单请求资源(plan 质量)变了,P99 就跳了,而 P50 和平均值都看不见。