内容提要
PostgreSQL 的 plan_cache_mode=auto 会先执行五次自定义计划,第六次若通用计划估算成本更低则切换。对拥有 80 万订单的大租户,通用计划误估为 216 行,改用嵌套循环并触发 320 万次缓冲命中,查询从 274 毫秒骤增至 2319 毫秒。单次 EXPLAIN 无法发现,需在同一连接重复运行并保留每次计划。修复方法是设置 plan_cache_mode=force_custom_plan。
延伸解读
为什么单次 EXPLAIN 会漏掉计划切换
文章指出,PostgreSQL 的 plan_cache_mode=auto 会先执行五次自定义计划,第六次才可能切换到通用计划。单次 EXPLAIN ANALYZE 只能看到其中一种计划,且前五次结果一致,容易让人误以为查询稳定。只有同一连接上重复运行并保留每次计划,才能发现第六次执行时计划突变。
大租户为何成为性能牺牲品
通用计划假设所有租户数据量均匀,用表行数除以不同租户数估算行数。对于拥有 80 万订单的大租户,估算仅 216 行,导致规划器选择嵌套循环并逐行探测主键,实际执行 80 万次,缓冲命中从 1.4 万飙升至 320 万,查询从 274 毫秒恶化到 2319 毫秒。
哪些场景会触发计划切换
使用预处理语句的场景都可能触发,包括 PREPARE/EXECUTE、pgjdbc(默认第五次执行后切换)、pgx、Npgsql 自动预处理以及 PL/pgSQL 函数。而 psql 使用简单查询协议,每次带入字面量,不会复用计划,因此无法复现问题。连接池中不同连接可能处于不同阶段,导致同一请求时快时慢。
修复方法与代价
设置 plan_cache_mode = force_custom_plan 可强制每次使用自定义计划,在测试中稳定保持 279 毫秒,规划开销仅增加约 0.29 毫秒。另一种思路是使用 force_generic_plan 强制通用计划,但性能会持续较差。选择取决于查询模式与参数分布,需权衡规划成本与执行效率。
Q&A
为什么同一个预处理语句在Postgres中前五次执行很快,第六次突然变慢?
因为PostgreSQL的plan_cache_mode=auto默认行为:前五次执行使用自定义计划(custom plan),第六次执行时,如果通用计划(generic plan)的估算成本更低,就会切换到通用计划。通用计划假设所有租户均匀分布,对于大租户会严重低估行数,导致选择嵌套循环等低效计划,从而变慢。
如何复现Postgres中预处理语句的计划突变问题?
需要在同一连接上使用PREPARE和EXECUTE运行同一个预处理语句至少六次,并保留每次的EXPLAIN ANALYZE输出。psql中使用字面量无法复现,因为psql使用简单查询协议,每次都是自定义计划。可以使用PREPARE/EXECUTE、pgjdbc(prepareThreshold=5)、pgx、Npgsql(开启自动预处理)、PL/pgSQL函数或ExoBench(repetitions>5)来复现。
如何解决Postgres预处理语句计划突变导致的性能问题?
设置plan_cache_mode = force_custom_plan,强制每次执行都使用自定义计划。这样每次执行都会根据实际参数值重新规划,避免切换到通用计划。代价是每次执行增加约0.29毫秒的规划时间,但可以保持稳定的快速执行。
为什么psql中运行很快,但应用程序中却慢?
psql使用简单查询协议,将查询作为文本发送,每次都用字面量规划,因此总是使用自定义计划,不会触发计划切换。而应用程序(如通过pgjdbc、pgx等)使用预处理语句,会经历五次自定义计划后可能切换到通用计划,导致变慢。所以psql无法复现该问题。
Postgres的通用计划为什么会对大租户估算错误?
通用计划不知道参数的具体值,因此假设所有值均匀分布,用表的总行数除以不同值的数量来估算行数。对于大租户(如占40%订单的租户),实际行数远高于平均值,导致估算严重偏低(例如估算216行,实际800,302行),从而选择了不适合大结果集的计划(如嵌套循环)。
为什么单次EXPLAIN ANALYZE无法发现计划突变?
因为单次EXPLAIN ANALYZE只显示一次执行的计划,而计划突变发生在第六次执行。前五次执行都使用自定义计划,结果一致,所以单次运行很可能看到的是快速计划。只有多次运行并保留每次计划,才能观察到计划切换。