PAYATHON 2026

PostgreSQL 15 预处理语句为何在多次执行后变慢?

数据小许

PostgreSQL 15 的预处理语句在多次执行后可能改用 generic plan,使某些参数的执行变慢。不过,执行几次后变慢只是一条线索,还需要确认实际使用了哪类计划,并用相同参数进行对比。

题目中的联合索引能够匹配两个等值条件及时间排序,因此仅凭数据倾斜,不能断定这段 SQL 必然出现性能退化。

PostgreSQL 何时考虑 generic plan?

对于题目中的参数化预处理查询,在 plan_cache_mode = auto 且没有其他强制策略时,通常按以下规则选择计划:

  1. 前 5 次执行使用 custom plan,每次根据传入的参数规划,并累计计划的估算成本。
  2. 之后生成 generic plan,将它的估算成本与此前 custom plan 的平均成本比较。
  3. 如果成本比较支持复用,就使用 generic plan;否则继续生成 custom plan。

第 6 次执行开始考虑 generic plan,但不一定会切换。PostgreSQL 15 PREPARE 文档说明了这项规则。

在实现中,custom plan 的成本包含执行成本估算和规划开销估算;generic plan 的比较成本不包含每次重新规划的开销。当 generic plan 的成本低于 custom plan 的平均成本时,就会被选用。比较使用的是优化器成本,并非前几次执行的实际耗时。具体实现见 PostgreSQL 15 计划缓存源码。

如果继续采用 custom plan,后续生成的 custom plan 也会参与平均成本计算。若已选择 generic plan,某次实际执行变慢本身不会触发基于耗时的自动纠正。

数据倾斜为什么会影响选择?

custom plan 在规划时知道 $1、$2 的具体值,可以结合统计信息估算条件会匹配多少行。generic plan 在规划时不知道具体参数,只能使用不针对某个参数值的选择率估算;执行时仍会正确使用传入的值。plan_cache_mode 文档说明了两者的区别。

示例生成语句将前 800000 行的 account_id 固定为 1,其余账户的数据量通常少得多。如果统计信息捕捉到了这种差异,针对账户 1 和普通账户生成的 custom plan 就可能不同。

当不同参数适合不同访问路径时,共享的 generic plan 可能对多数参数表现尚可,却对少数参数很慢。最初几次传入的参数也会影响用于比较的 custom plan 平均估算成本。

不过,这只是对机制的解释。题目没有提供两份完整计划,也没有给出 force_custom_plan 的测试结果,目前还不能确认具体的退化路径。

现有索引为什么需要特别关注?

现有索引为:

(account_id, event_type, created_at DESC)

查询对前两列使用等值条件,再按第三列降序取前 20 行。从索引结构看,它具备通过有序 Index Scan 定位记录、并在取得足够行后提前停止的条件。PostgreSQL 官方文档也说明,匹配排序的 B-tree 索引对 ORDER BY ... LIMIT 查询尤其有用。索引与 ORDER BY

因此,不能直接认为“大账户匹配很多行,所以顺序扫描一定更快”。这里还要考虑 LIMIT 20,并检查实际计划是否利用了索引顺序、是否有额外排序,以及访问了多少数据页。

如何确认是否发生计划切换?

先在应用执行该预处理语句的同一个数据库连接中检查:

SHOW plan_cache_mode;

SELECT name, parameter_types, generic_plans, custom_plans
FROM pg_prepared_statements
WHERE name = 'get_events';

EXPLAIN (ANALYZE, BUFFERS)
EXECUTE get_events(1, 1);

generic_plans 和 custom_plans 分别记录两类计划被选用的次数,不是当前缓存中的计划数量。该视图只显示当前会话可用的预处理语句,换一个连接检查可能看不到目标语句。pg_prepared_statements 文档

对于这里的查询,generic plan 中的条件通常保留 $1、$2,custom plan 则显示代入后的常量。可以结合计数变化判断。

EXPLAIN ANALYZE 会实际执行查询,并可能影响后续的计划选择历史。如果需要观察从首次执行开始的变化,应在独立测试连接中重新创建 get_events,记录参数顺序和每次计划。

如何对比 custom plan 和 generic plan?

在已创建 get_events 的测试连接中,分别强制两种模式,使用同一组参数执行:

BEGIN;
SET LOCAL plan_cache_mode = force_custom_plan;

EXPLAIN (ANALYZE, BUFFERS)
EXECUTE get_events(1, 1);

EXPLAIN (ANALYZE, BUFFERS)
EXECUTE get_events(45001, 1);
COMMIT;

BEGIN;
SET LOCAL plan_cache_mode = force_generic_plan;

EXPLAIN (ANALYZE, BUFFERS)
EXECUTE get_events(1, 1);

EXPLAIN (ANALYZE, BUFFERS)
EXECUTE get_events(45001, 1);
COMMIT;

重点比较:

  • 是否使用 idx_invoice_events_account_type_created,两个条件是否进入 Index Cond。
  • 是否出现额外排序,以及扫描节点实际处理了多少行。
  • 估算行数与实际行数的差距,尤其是 Limit 下方的扫描节点。
  • BUFFERS、Planning Time 和 Execution Time。

可以适当交换测试顺序,避免把首次读取与缓存命中的差异误判为计划差异。这些命令提供了对比方法,但不能预先保证哪种计划更快。

确认原因后如何处理?

如果相同参数下 custom plan 持续明显快于 generic plan,可以在执行相关查询的事务内使用:

BEGIN;
SET LOCAL plan_cache_mode = force_custom_plan;
EXECUTE get_events(1, 1);
COMMIT;

这仍然使用预处理语句,但每次都会按实际参数重新规划,需要承担规划开销。plan_cache_mode 在执行时生效,不必仅为切换模式重新执行 PREPARE;使用连接池时,要保证设置与查询运行在同一个事务、同一个连接中。

如果估算行数明显失真,再检查统计信息。ANALYZE invoice_events 可以更新统计信息,但不会使 generic plan 在规划时获知参数值,因此并不保证消除数据倾斜带来的差异。必要时可以评估提高相关列的统计目标;官方将更新统计信息和增加列统计量列为改善计划质量的方法。查询规划配置

如果已经确认 force_custom_plan 生效,查询仍然很慢,就需要继续比较完整计划、缓冲区访问和实际等待情况。此时不能仅凭预处理语句的执行次数,将问题归因于 generic plan。

备注:内容仅供参考。