在 PostgreSQL 中,预编译语句并不一定永远使用针对当前参数值优化的执行计划。前几次执行通常会生成 custom plan;当执行次数达到第六次时,系统可能改用 generic plan。这个变化能减少反复规划的成本,却也可能让原本很快的查询突然变慢。
第六次执行发生了什么
所谓 custom plan,是根据本次绑定参数生成的计划。它能利用参数分布,例如某个租户只有少量数据、另一个租户却占据了大多数表行。对于数据分布不均匀的列,custom plan 可能在索引扫描和顺序扫描之间做出完全不同的选择。
generic plan 则不依赖具体参数,可以在多次执行之间复用。规划成本较低,但它只能使用参数无关的估算。如果不同参数对应的最佳计划差异很大,generic plan 就可能成为性能瓶颈。
关键点是:慢并不一定从 SQL 文本变化开始。应用仍然发送同一条 prepared statement,数据库却可能在执行次数跨过这个阈值后选择另一种计划。因此,应用日志里常见的表现是“同一请求偶尔变慢”,而不是明显的语法或代码错误。
一个容易触发问题的查询
假设订单表按 tenant_id 查询,但租户之间的数据量差异很大:
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id integer NOT NULL,
status text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX orders_tenant_status_idx
ON orders (tenant_id, status);
PREPARE tenant_orders(integer, text) AS
SELECT id, created_at
FROM orders
WHERE tenant_id = $1
AND status = $2
ORDER BY created_at DESC
LIMIT 50;
EXECUTE tenant_orders(7, 'open');
EXECUTE tenant_orders(7, 'open');
EXECUTE tenant_orders(7, 'open');
EXECUTE tenant_orders(7, 'open');
EXECUTE tenant_orders(7, 'open');
EXECUTE tenant_orders(7, 'open');
SELECT name, generic_plans, custom_plans
FROM pg_prepared_statements
WHERE name = 'tenant_orders';
这段 SQL 用于观察 prepared statement 的执行计数。真实诊断时,还应为代表性参数分别运行 EXPLAIN (ANALYZE, BUFFERS),比较计划形状、扫描行数和实际耗时。不要只看平均延迟,因为某一类参数可能被 generic plan 严重拖慢。
如何确认是不是计划切换
可以在测试环境中显式控制计划模式,然后比较同一组参数:
SET plan_cache_mode = force_custom_plan;
EXPLAIN (ANALYZE, BUFFERS)
EXECUTE tenant_orders(7, 'open');
SET plan_cache_mode = force_generic_plan;
EXPLAIN (ANALYZE, BUFFERS)
EXECUTE tenant_orders(7, 'open');
如果两次输出的计划或耗时有明显差别,就说明参数敏感性值得重点调查。force_custom_plan 和 force_generic_plan 更适合诊断或针对特定会话验证,不应未经测量就作为全局修复。应用连接池也需要纳入测试,因为 prepared statement 的生命周期可能由连接而不是请求决定。
在 PostgreSQL 中,还可以查看当前会话的 prepared statement 统计:
SELECT name, statement, generic_plans, custom_plans
FROM pg_prepared_statements;
这些计数帮助确认数据库是否确实在重复执行某个 prepared statement,以及 custom/generic 两种计划分别被使用了多少次。
处理方式要基于测量
可以按以下顺序处理:
- 用真实的慢参数和快参数分别采集
EXPLAIN (ANALYZE, BUFFERS)。 - 比较
force_custom_plan与force_generic_plan的计划和延迟。 - 检查统计信息是否过期,必要时对相关列执行
ANALYZE,并确认索引与数据访问模式匹配。 - 如果 custom plan 稳定更快,再评估对相关会话设置
plan_cache_mode = force_custom_plan。 - 重新检查连接池、数据库驱动和发布后的长连接行为。
强制 custom plan 不是免费的:每次执行都要规划,低延迟查询或高并发场景可能因此增加 CPU 消耗。反过来,generic plan 也不是天然错误;当参数对计划选择影响很小,复用它通常是合理的。
上线前检查清单
遇到“第几次执行后变慢”时,先确认三件事:是否使用了 prepared statement,慢点是否与第六次执行附近的计划切换重合,以及不同参数的数据分布是否足以改变最佳计划。只有在这些证据成立时,才应该调整计划缓存策略。把计划模式、参数类别和实际缓冲区读数一起记录下来,通常比单纯提高超时时间更能定位问题。