Hotdry.
systems

PostgreSQL 优化器对 Merge Join 的成本模型偏差分析

深入剖析 PostgreSQL 优化器在首次执行查询时对 Merge Join 节点的低估现象,揭示因索引死元组导致的直方图探测失效与成本计算偏差。

在实际生产环境中,我们常常遇到一种令人困惑的现象:某条 SQL 查询在首次执行时耗时长达数十秒甚至数分钟,而第二次执行时却仅需毫秒级响应。常规排查思路通常指向缓存效应或统计信息未及时更新,但有一种更为隐蔽的根因与 PostgreSQL 优化器的内部机制密切相关 ——Merge Join 节点的成本估算偏差。

问题现象与初步诊断

某客户的业务系统在批量数据导入后,首次执行某查询时性能极差。技术团队初步判断为自动 Vacuum 未能及时处理新增数据,导致统计信息过时。按照常规建议执行 VACUUM ANALYZE 后问题确实得到缓解,但进一步调查却发现:在慢查询与快查询之间,表的 pg_stat_user_tables 记录显示没有任何 Vacuum 或 Analyze 操作发生。这意味着统计信息在两次执行之间保持完全一致,执行计划的切换并非由常规的统计信息更新触发。

查询本身是一条典型的 Left Join 操作,包含对主键的条件过滤和结果排序。首次执行时优化器选择了 Merge Join 策略,而第二次执行时却切换为 Nested Loop Join,且性能差异显著。这种在同一统计信息状态下执行计划不稳定的案例极为罕见,指向了优化器内部更深层次的成本计算机制。

直方图与选择性估算机制

PostgreSQL 优化器在评估 Join 操作成本时,需要估算参与 Join 的两个数据集之间的重叠程度,即选择性(Selectivity)。对于 Merge Join 而言,关键在于判断两个连接列的直方图(Histogram)是否存在重叠区域。当直方图完全无重叠时,理论上 Merge Join 应当能够快速定位到第一个匹配元组并立即终止 —— 因为不存在任何满足条件的记录。

然而,优化器对于极端情况采取了保守策略:当直方图探测结果显示无重叠时,它不会天真地假设选择性为零,而是应用最小 selectivity 阈值 0.01%(即直方图分辨率的百分之一)。这一设计旨在避免因统计误差导致的过度乐观估计。问题恰恰出在直方图探测过程中涉及的一个内部函数:get_actual_variable_endpoint()

索引探测与死元组问题

当优化器需要获取直方图边界列的真实极值时,会调用 get_actual_variable_endpoint() 函数访问索引,验证统计信息中记录的极端值是否仍存在于堆表中。在存在大量已删除但尚未被 Vacuum 清理的元组(死元组)的场景下,该函数可能需要扫描大量堆页面才能确定实际的有效极值。

2021 年曾有用户报告类似问题,表现为查询的 Planning Time 异常漫长。PostgreSQL 社区在 2022 年 11 月由 Simon Riggs 提交了一个补丁,限制 get_actual_variable_endpoint() 最多读取 100 个堆页面,超出此限制即放弃并返回失败。这一修改有效防止了 Planning Time 的爆炸式增长,却也引入了新的副作用:首次执行时函数因达到页面限制而返回失败,优化器使用直方图记录的极端值进行成本估算;执行过程中死元组对应的索引项被标记为已清理(Killed),第二次执行时函数能够成功获取实际极值,从而触发不同的成本计算结果。

成本模型的具体偏差机制

以实际案例中的执行计划为证,首次执行的 Merge Join 启动成本(Startup Cost)为 2385,略高于内层索引扫描总成本的 99.99%(2326)。运行成本(Run Cost)为 14595,而外层索引扫描总成本为 145,690,068,其万分之一恰好为 14569。这种精确对应关系表明:优化器确实按照 0.01% 的最小选择性进行成本外推,将 Merge Join 的实际成本估算为只需扫描极小部分外层数据集即可完成。

第二次执行时,由于 get_actual_variable_endpoint() 成功获取了实际的极值,优化器发现两表的直方图存在实际重叠,选择性估算大幅提升。Merge Join 的总成本随之跃升至超过 728,450(假设外层扫描需执行 0.5%),超过了 Nested Loop Join 的 544,104,因此优化器转而选择后者。这一转变完全由索引状态的动态变化驱动,而非统计信息的静态更新。

工程复现与验证

要复现这一问题,关键在于制造直方图边界与实际数据分布不一致的场景。具体步骤包括:创建两张表并禁用自动 Vacuum;向两张表分别插入无重叠范围的初始数据并执行 Analyze;再向其中一张表插入存在重叠范围的新数据并再次 Analyze;创建索引后删除大部分重叠数据,使直方图边界停留在已被清理的旧值上。首次执行 EXPLAIN 将观察到 Merge Join 被选中且成本被显著低估;第二次执行则切换为 Nested Loop Join。

执行过程中可在 EXPLAIN 输出中观察到 Heap Fetches 字段,提示优化器正在探测堆页面以获取实际极值。若设置 enable_nestloop TO off 强制禁止 Nested Loop,第二次执行的 Merge Join 成本将明显高于首次执行,验证了成本估算确实发生了变化。

缓解策略与监控建议

针对此类问题,生产环境应采取以下应对措施。首先,在批量数据操作后显式执行 VACUUM ANALYZE,确保统计信息与实际数据分布同步更新,同时清理死元组以避免索引膨胀。其次,对于已知的敏感查询,可通过设置 SET enable_mergejoin = off 临时禁用 Merge Join,或调整 effective_cache_sizerandom_page_cost 参数影响成本模型的权衡倾向。此外,应建立监控机制,跟踪 pg_stat_user_tables 中的 n_tup_insn_tup_updn_tup_del 变化,当表的数据分布发生显著变动时主动触发 Analyze。

值得注意的是,此类成本估算偏差难以通过常规的 EXPLAIN 输出发现,因为受影响的通常是选择性计算而非行数估算本身。图形化 EXPLAIN 工具虽能高亮显示行数差异,却无法直接揭示直方图探测失效导致的成本模型偏差。理解优化器内部的成本计算逻辑,包括直方图探测、最小选择性阈值以及索引状态对 selectivity 估算的影响,是诊断这类边界案例的关键。

资料来源:Frédéric Yhuel, Dalibo, "The strange case of the underestimated Merge Join node", 2026 年 1 月;PostgreSQL Bug #17207, 2021 年 9 月;Simon Riggs, "Limit heap fetches in get_actual_variable_endpoint", PostgreSQL Git Repository, 2022 年 11 月。

查看归档