20 规划器也在估算
下面是一条查询某个账户内某个项目的 85 个事件的语句:
规划器预计会得到一行:
估算差了 85 倍,但没有任何东西坏了。这张表一分钟前才做过分析,规划器使用的每个数字都是正确的。它做了一个不适用于这份数据的假设;除非你告诉它实际情况,否则它会一直这样假设下去。
大多数糟糕的计划都从这样的差距开始。规划器不了解你的数据。它根据一份很小的数据摘要进行估算,而且通常估得不错。弄清它知道什么、又假设了什么之后,“规划器怎么这么蠢”就会变成一个可以指出并修复的具体估算错误。
规划器是代价计算器
Postgres 不会尝试执行各种计划来看看哪个最快。它会列出候选方案(扫描每张表的所有方式、所有连接方法和连接顺序),用公式为每个方案计算代价,然后运行代价最低的那个。一个计划的代价取决于两件事:每一步会产生多少行,这来自统计信息;每个工作单位的代价是多少,这来自少数几个设置。
行数估得准,哪怕代价估得粗糙,通常也能得到好计划。行数估错了,单靠调整代价无法弥补。所以本章讨论的是估算。规划器如何把这些估算变成扫描、连接和连接顺序的选择,见代价最低的计划胜出。
每个代价都相对于一次顺序页读取
代价是以一个数为基准的任意单位:顺序读取一个 8 KB 页的代价是 seq_page_cost = 1.0。其他一切都相对于它计算代价:
| 参数 | 默认值 | 它计算的代价 |
|---|---|---|
seq_page_cost | 1.0 | 顺序扫描时读取一个页 |
random_page_cost | 4.0 | 无序获取一个页(索引扫描) |
cpu_tuple_cost | 0.01 | 处理一行 |
cpu_index_tuple_cost | 0.005 | 处理一个索引项 |
cpu_operator_cost | 0.0025 | 计算一个运算符或函数调用 |
一次顺序扫描的代价,是页数乘以 seq_page_cost,加上行数乘以 cpu_tuple_cost,再加上每行每个过滤条件对应的一个 cpu_operator_cost(精确到分)。公式本身不是难点。注意其中大部分项目都乘以什么:行数。真正的工作量就在这里。
random_page_cost 是一个比值,不是测量值。默认值 4.0 源自旋转磁盘;使用 SSD 的系统通常设为 1.1 到 2(random_page_cost 假定使用旋转磁盘)。而 effective_cache_size 根本不是代价:它告诉规划器可能有多少数据已经缓存在内存中(effective_cache_size 是提示,不是内存)。
计划看起来不对时,人们的本能反应是不断微调 random_page_cost,直到计划翻转。若把它设为全服务器范围,这样做只是通过让规划器以另一种方式误判其他每条查询来修好一条查询,其中一些查询还会翻到足以拖垮服务器的计划。先修正估算。只有为了描述硬件特性,才应一次性为整台服务器调整代价参数。
pg_stats 是规划器看到的全部信息
行数估算来自 ANALYZE 保存的统计信息,而 pg_stats 视图会按每列一行显示这些信息。对于 events 来说(ANALYZE 会随机抽样,所以你看到的末位数字会不同):
这就是规划器看到的一张两百万行表的全部信息:
null_frac:空值所占的比例。(在projects中,archived_at的空值比例为 0.80445,因此archived_at is null的估算值是 80.4%。)avg_width:每个值的平均字节数,用来估算哈希和排序的大小。n_distinct:不同值的数量。正数表示数量(kind有 5 个);负数表示行数的比例,-1表示每个值都唯一。most_common_vals和most_common_freqs:高频值列表(MCV 列表),也就是出现频率最高的值及其出现频率。histogram_bounds:其他值,被切分成 100 个行数大致相等的桶。correlation:物理行顺序与列值顺序的接近程度,范围从 -1 到 1。它会影响索引扫描的代价(相关性会影响索引扫描的代价)。
下面是 kind 的高频值列表:
因此,where kind = 'deploy' 的估算值是 rows=502927:即这张表 1,999,974 行的 0.25146666 倍。高频值列表中的值使用它自己的频率;其他值则平均分摊剩余部分。范围条件会统计直方图桶:created_at 上 2026 年 9 月 1 日所在的一周估计有 36,613 行,实际为 38,356 行。
深入阅读:手算规划器的算术
每个行数估算都是一个基于 pg_stats 的公式,你可以自行核对。以 events.account_id 为例(有 1,000 个不同值;这次抽样恰好把六个稍微更繁忙的账户放进了高频值列表,它们的频率之和为 0.0091666665):
第二个公式解释了为什么数据倾斜的列会带来问题。如果几百个中等规模的租户各自占有 0.5% 的行数,100 个 MCV 槽位就容不下它们,剩下的每个租户都会被估算为约 2,000 行,而不是 10,000 行。提高统计目标(见下一节)可以让大租户各自拥有一个条目。
范围条件会找到两端所在的直方图桶,并假设桶内的值均匀分布。对于 created_at >= '2026-04-01' and created_at < '2026-04-08':
规划器给出的结果是 38,431(差异来自我们的四舍五入),实际数量是 38,356。没有更好信息时,= 连接会把两个输入相乘,再除以较大的 n_distinct:events 与 173 个企业账户连接时,估算值为 2,000,000 x 173 / 1,000 = 346,000。它假设企业账户产生的事件数处于平均水平,这和下面的独立性假设相同。
动手验证 · lab 19-planner
pg_stats 还会提供 most_common_elems 和 elem_count_histogram,用于数组和 tsvector 列;对范围类型,还会提供范围直方图:其他数据形态也是同样的思路。
ANALYZE 读取样本,而不是整张表
ANALYZE 会抽取 300 x default_statistics_target 行作为样本。默认目标是 100,因此样本有 30,000 行:
统计目标还会把 MCV 列表和直方图分别限制为最多 100 个条目:对大多数列来说足够,但对拥有大量不同值且数据倾斜的列来说太少。如果 account_id 有很长的大租户尾部,100 个 MCV 槽位容不下它们,其余值就只能采用平均估算。应按列提高统计目标,而不是全局提高:
现在,ANALYZE 会为一个 300,000 行的样本读取全部 35,173 个页,1,000 个账户也都会各自得到一个 MCV 条目。代价是 ANALYZE 变慢(在我们的机器上约 2 秒,而不是 0.2 秒),规划也会略微变慢。只有在估算确实有明显错误的地方才使用 500 到 1000,通常是租户 ID 和状态列。如果 ANALYZE 持续把 n_distinct 估错,可以用 alter table ... alter column ... set (n_distinct = 20000) 把它固定下来。
自动清理的分析会漏掉三类变化
自动清理会在大约 autovacuum_analyze_threshold + autovacuum_analyze_scale_factor x reltuples 行发生变化后分析一张表:默认是 50 加上 10%,因此两百万行的表要等到 200,050 行发生变化。以下三种情况会漏过去:
- 批量加载。 规划器使用的是加载前的统计信息,或者根本没有统计信息。每次批量加载或大型回填结束时都运行
ANALYZE(在开门前清理并分析)。 - 不断增长的时间范围。
ANALYZE上次运行是在昨天,而你查询的是“最近一小时”,直方图的末端还没有覆盖新行。Postgres 对有索引的列会部分补偿,但只追加数据的大表需要更低的表级autovacuum_analyze_scale_factor(大表不该使用默认值)。 - 临时表。 自动清理永远不会处理临时表。在把它和其他表连接之前,先对它运行分析。
PostgreSQL 18 的 pg_upgrade 会保留规划器统计信息,消除了过去升级后“要等分析完成才恢复正常速度”的窗口。但它不会保留本章后面讨论的扩展统计信息,因此应随后运行 vacuumdb --all --analyze-only --missing-stats-only(18 中新增),只重建这些统计信息。
规划器假设各列彼此独立
回到开头的查询。规划器从 pg_stats 知道两件事:account_id = 696 大约匹配 1,000 个值中的 1 个,project_id = 4242 大约匹配 20,000 个值中的 1 个。要把它们合并,它会相乘:
只有在项目不能告诉你任何账户信息时,相乘才是正确的。但一个项目恰好属于一个账户,所以第二个过滤条件什么也不会排除。正确答案是“约 100 行”。两个列各有一个单列索引时,计划会显示这一点:
(BitmapAnd 总是报告 0 行:它会合并位图,但从不统计行数。)每个叶节点的估算都很准:项目是 100 对实际 85,账户是 1,990 对实际 1,961。只有合并结果错了。叶节点正确、组合错误:这就是列相关性的特征。
这条查询无论怎样都很快。但这个 1 行的估算会传给它上面的每个节点:如果再和其他东西连接,它就会选择嵌套循环,对 1 行来说很好,对 10,000 行来说却很痛苦。列相关性无处不在:租户和项目、城市和邮政编码、status 和 completed_at。连接也会继承这个假设。多余的 and p.account_id = e.account_id 会让连接估算再除以 1,000(代价最低的计划胜出一开头就展示了这种情况)。
扩展统计信息联合测量多列
让 ANALYZE 联合测量这些列:
这就是 pg_stats_ext,在 psql 中用 \x 可以让它每行显示一列。把 dependencies 读作:“第 3 列(project_id)以 1.0 的程度决定第 2 列(account_id)”:项目每次都能告诉你账户。n_distinct 表示这对列有 19,959 种组合,而不是 1,000 x 20,000 种。再次运行开头的查询,估算值为 rows=100,实际为 85。
扩展统计信息分为三种,分别对应一种相关性:
| 类型 | 修复的问题 | 用于 |
|---|---|---|
dependencies | 一个列决定另一个列时的等值过滤 | 租户 + 项目、邮政编码 + 城市 |
ndistinct | 多列 GROUP BY 和 DISTINCT 的分组数量 | 相关键上的汇总 |
mcv | 部分相关组合上的过滤 | 状态 + 类型、套餐 + 区域 |
省略类型列表就会得到全部三种。它们开销很小,但应在发现估算错误的地方添加,而不是为每一对列都添加。
深入阅读:ndistinct 和 mcv 统计信息的实测修复效果
动手验证 · lab 19-planner
ndistinct 会修复分组数量。在有它之前,select account_id, project_id, count(*) from events group by 1, 2 的计划如下:
由于不知道有多少对组合,规划器把输入的 10%(200,005 个组)当作估算值,并为它预计会溢出 work_mem 的哈希表规划了 4 个磁盘分区。之后:rows=19959,没有计划中的溢写,代价只有三分之一。运行时间几乎没变,因为哈希表原本就能放进内存;但在更大的查询中,把分组数量高估 10 倍会让聚合改用排序,或使上方第三层的连接选择错误的一侧来建立哈希表。
mcv 处理部分相关的情况,也就是一列只在大多数情况下能预测另一列。在一份 events 的副本中,status 在 95% 的告警中为 failed,其他类型的事件则只有 5% 为此状态;单独相乘会在两个方向上都估错:失败告警估计为 42,822(实际 237,213,低估 5.5 倍),失败构建估计为 123,067(实际 37,336,高估 3.3 倍)。执行 create statistics event_outcomes_kind_status (mcv) on kind, status from event_outcomes 后,两者的估算误差都落在 10% 以内。存储的列表说明了原因:
frequency 是样本看到的频率;base_frequency 是按每列频率相乘后预测的频率。两者相差接近 6 倍时,独立性假设就严重错误。
动手验证 · lab 19-planner
在估算出错的节点上,如果它同时过滤同一张表的两列或更多列,而每列单独的估算都没问题,那就是存在列相关性:只对这几列创建统计信息,运行分析,再次检查。
函数会隐藏你的统计信息
统计信息按列保存。把一列包在函数里,规划器就没有可查的信息,只能退回到源代码中的常量:等值条件使用整张表的 0.5%,范围条件使用 33%。
10,000 行是两百万行的 0.5%;实际数量为 500,420,是估算值的 50 倍。lower(kind) = 'deploy' 得到相同的 10,000 行(实际为 499,992),而 (payload->>'duration_ms')::int > 4000 会得到固定的三分之一,即 666,682 行(实际为 400,064)。这些数字和你的数据根本无关。
深入阅读:证明回退常量
这些常量是规划器 selfuncs.h 中的 DEFAULT_EQ_SEL(0.005)和 DEFAULT_INEQ_SEL(三分之一),旁边还有几个用于 like、范围和布尔值的常量。计划的代价也能暴露这个表达式:65,173.71 是普通扫描的 60,173,再加上每行一个 cpu_operator_cost,用于执行 ->> 调用本身。
动手验证 · lab 19-planner
你可以用两种方式给规划器提供真实数字。一种是表达式索引:ANALYZE 会像对待一列一样,收集每个已索引表达式的统计信息。另一种是表达式统计信息(PostgreSQL 14+),它收集相同的统计信息,却不需要在每次写入时维护索引:
现在,第一个查询的估算值是 500,670 行,实际为 500,420 行。比较估计行数和实际行数展示了这个修复如何把糟糕的嵌套循环变成哈希连接。(如果一个 jsonb 字段需要统计信息,应问问它是不是应该成为真正的列。)
FROM 中的函数默认估算为 1,000 行
除非规划器能够看穿函数内部,否则 FROM 中的函数就是一个黑盒,估算为 1,000 行:
这是一个返回某个账户最近项目的 PL/pgSQL 函数。若它是单语句的 language sql 函数,规划器会把函数体内联进去,统计信息也一并可见:Seq Scan on projects 的估算值为 1 行。无法内联时,应声明一个符合实际的估算值:alter function recent_projects_plpgsql(bigint) rows 5。什么会阻止内联,以及易变性向规划器作出什么保证,见易变性是给规划器的承诺。
深入阅读:函数的两个版本
这就是 explain select * from recent_projects(42) 的结果:函数体被内联了。像 generate_series 这样的内置函数带有支持函数,当参数是常量时可以计算出实际数量(结果为 rows=50,对应 generate_series(1, 50))。COST 以 cpu_operator_cost 为单位计算一次调用的代价;在 WHERE 子句中,规划器会先计算代价更低的条件。
预备语句运行五次后切换计划
大多数驱动和 ORM 都使用预备语句:解析一次,执行多次。前五次执行都会获得一个定制计划,根据实际值构建。从第六次开始,Postgres 还会构建一个参数未知的通用计划;如果它看起来不比定制计划的平均代价更高,Postgres 就会永久切换到通用计划并停止规划。对于每秒执行数千次的短查询,这样省下的规划开销确实可观。
危险在于数据倾斜:通用计划看不到具体值,只能按平均值规划。设想 latest($1),它查询某种类型最新的 10 个事件;events (kind) 和 events (created_at) 上都有索引,而一种罕见的新类型 incident 有 20 行,且都来自十个月前。定制计划会随值适配:查询 incident 时,通过 kind 索引取出 20 行再排序(23 个页);查询 build 时,反向遍历 created_at 索引,找到 10 行后停止(5 个页)。
像应用一样再执行几次,第六次执行就会切换到通用计划。此时有人打开 incident 事件页面:
通用计划假定 kind = $1 匹配整张表的五分之一(即 1 除以 n_distinct),因此它认为反向遍历 created_at 几乎立刻就能找到 10 行。实际上,它遍历了 170 万行和 34,536 个页,才找到十个月前的 incident;定制计划只访问了 23 个页:两者相差 1,500 倍,完全由计划缓存造成。
Filter: (kind = $1) 中使用 $1 而不是字面值,就是识别通用计划的方法。
plan_cache_mode 控制这一行为:auto(默认值)、force_custom_plan 或 force_generic_plan。对于数据倾斜列上的查询,可以按角色、函数或会话设置 force_custom_plan;规划只需几分之一毫秒,属于便宜的保险措施,它能让 incident 事件查询回到 23 个页。高频且分布均匀的查找则保持 auto。PL/pgSQL 在函数内部也会以同样方式缓存计划(PL/pgSQL 按会话缓存计划)。
在 psql 中粘贴执行很快,从应用中执行却很慢?先怀疑通用计划。psql 发送的是字面值,所以总会得到定制计划;你的驱动发送的是参数。用 PREPARE 和六次 EXECUTE 重现它,或者在会话中执行 set plan_cache_mode = force_generic_plan。在 PostgreSQL 16+ 中,explain (generic_plan) 会接收原样带有 $1 占位符的查询,直接显示通用计划。
深入阅读:实验设置,以及通用计划为何猜成五分之一
30,000 行的样本通常会漏掉全部 20 个 incident,因此统计信息会记录 5 种类型(如果样本碰巧抽到一个,则记录 6 种),通用计划把 kind = $1 的代价按整张表的五分之一计算:rows=399999。下面列出两个定制计划,供参考:
(generic_plans 和 custom_plans 这两列从 PostgreSQL 14 起才出现在 pg_prepared_statements 中。)
动手验证 · lab 19-planner
提示会冻结计划;应该修复估算
Postgres 没有内置的优化器提示:没有 /*+ INDEX(...) */,也没有 FORCE INDEX(Postgres 修复估算,而不是接受提示)。这是有意为之,而且我们认为这样是对的。提示会冻结一个在写下它的那天还正确的计划;随着数据变化,这个计划会悄悄腐化,并掩盖真正的问题:估算错误。
本章介绍的都是替代做法:找到估算出错的节点(慢查询的方法),然后用批量加载后的新统计信息、更好的统计信息、一个可使用索引的查询,或更好的索引,修正规划器掌握的信息。给定规划器当时掌握的信息,糟糕的计划往往已经是它能找到的最佳方案。下一章最后会介绍查看规划器拒绝的计划的工具,以及在你确实需要提示的夜晚可以使用的扩展。
本章要点
- 错误的行数估算导致大多数糟糕的计划。先修估算,再动代价设置。
pg_stats是规划器看到的列的全部信息。估算看起来不对时,读取它。- 对数据倾斜的租户 ID,按列把统计目标提高到 500 到 1000;批量加载后自己运行
ANALYZE。 - 规划器把列当作彼此独立来相乘。对于相关列,
create statistics把估算从 1 行修正到了 100 行,而实际是 85 行。 - 列上的表达式会得到固定的估算值(等值条件为 0.5%,范围条件为 33%)。为它们创建表达式索引或表达式统计信息。
- 预备语句运行五次后切换到通用计划。数据倾斜时,代价会相差 1,500 倍;把
plan_cache_mode = force_custom_plan限定在角色或函数范围内即可修复。 - Postgres 没有提示。修复估算、统计信息、索引或查询。
延伸阅读
- Statistics Used by the Planner,PostgreSQL 文档。介绍
ANALYZE收集的内容,包括扩展统计信息;配合 How the Planner Uses Statistics 阅读行数估算算术的完整示例。 - CREATE STATISTICS,PostgreSQL 文档。介绍每种扩展统计信息及表达式形式。
- Query Planning settings,PostgreSQL 文档。介绍代价常量和
plan_cache_mode。 - PREPARE: notes on custom and generic plans,PostgreSQL 文档。说明预备语句何时在定制计划和通用计划之间切换的确切规则。
- How Good Are Query Optimizers, Really?,Leis 等,PVLDB 2015。使用 PostgreSQL 的证据表明,糟糕的基数估算,而不是代价模型,导致了大多数糟糕的计划。
- OptimizerHintsDiscussion,PostgreSQL Wiki。介绍该项目将提示排除在核心之外的理由。