跳转到内容

统计信息:优化器凭什么做决定

准备中…

优化器不看数据,只看统计信息。理解它存了什么、什么时候会失效,是解释「为什么计划突然变了」的唯一途径。

ANALYZE orders;
⌘/Ctrl + Enter
SELECT attname AS 列,
     n_distinct AS 不同值数,
     null_frac AS 空值比例,
     avg_width AS 平均字节,
     array_length(most_common_vals::text::text[], 1)   AS MCV个数,
     array_length(histogram_bounds::text::text[], 1)   AS 直方图边界数,
     correlation AS 物理相关性
FROM pg_stats WHERE tablename = 'orders'
ORDER BY attname;
⌘/Ctrl + Enter

逐个字段解释:

正数是不同值的绝对个数,负数是「占总行数的比例」。

  • user_id = 1000 → 恰好 1000 个不同用户;
  • id = -1 → 每行都不同(唯一)。

用比例表示的好处是表增长时不用重新采样——一列如果一直是唯一的,行数翻倍后 -1 仍然正确。

高频值及其频率。低基数列会把所有值都收进来:

SELECT most_common_vals::text  AS 高频值,
     most_common_freqs::text AS 各自频率
FROM pg_stats WHERE tablename = 'orders' AND attname = 'status';
⌘/Ctrl + Enter

四个状态各占 25%。估算 WHERE status = 'paid' 时优化器直接查这张表,得到 0.25 × 10000 = 2500——这就是第一节里它坚持走 Seq Scan 的依据。

注意 status 没有直方图:所有值都进了 MCV,不需要再用直方图描述剩余分布。

把 MCV 之外的值等频切成若干桶(默认 100 个)。估算范围查询 WHERE created_at > X 时,优化器定位 X 落在哪个桶,按比例插值。

SELECT (histogram_bounds::text::text[])[1:6] AS 前六个直方图边界
FROM pg_stats WHERE tablename = 'orders' AND attname = 'created_at';
⌘/Ctrl + Enter

列的逻辑顺序和物理顺序有多一致,范围 −1 到 1。

SELECT attname, correlation
FROM pg_stats WHERE tablename = 'orders' ORDER BY correlation DESC;
⌘/Ctrl + Enter

id = 1 表示完全按 id 顺序存放。这个值直接影响索引扫描的代价估算:

  • 相关性高 → 索引扫描时回表访问的页面是顺序的,代价低;
  • 相关性低 → 每次回表都跳到随机位置,代价按 random_page_cost 计。

它也是判断 BRIN 是否可用的依据(上一节)。

ANALYZE 不读全表,它随机采样约 300 × default_statistics_target 行:

SELECT name, setting, short_desc FROM pg_settings WHERE name = 'default_statistics_target';
⌘/Ctrl + Enter

默认 100,也就是采样 30000 行。对上亿行的表,这个采样率下 n_distinct 的估算经常严重偏低——尤其是值分布不均时。

多列相关:估算错 100 倍的典型场景

Section titled “多列相关:估算错 100 倍的典型场景”

优化器估算多个条件时,默认假设它们互相独立——把各自的选择率相乘。当列之间有相关性时,这个假设会灾难性地错。

DROP TABLE IF EXISTS corr;
CREATE TABLE corr (a int, b int);
INSERT INTO corr SELECT i % 100, i % 100 FROM generate_series(1, 100000) i;
ANALYZE corr;
⌘/Ctrl + Enter

ab 永远相等——知道 a 就完全知道 b。看优化器怎么估:

EXPLAIN (ANALYZE) SELECT * FROM corr WHERE a = 1 AND b = 1;
⌘/Ctrl + Enter

估算大约 10 行,实际 1000 行——错了两个数量级。

算法是这样的:a = 1 的选择率是 1/100,b = 1 也是 1/100,独立假设下相乘得 1/10000,乘以 10 万行等于 10。但实际上 b = 1 这个条件没有过滤掉任何东西,因为满足 a = 1 的行必然满足它。

扩展统计告诉优化器这两列相关:

CREATE STATISTICS IF NOT EXISTS corr_stat (dependencies, ndistinct) ON a, b FROM corr;
ANALYZE corr;
⌘/Ctrl + Enter
EXPLAIN (ANALYZE) SELECT * FROM corr WHERE a = 1 AND b = 1;
⌘/Ctrl + Enter

估算变成 1000 上下,实际 1000。 从两个数量级的误差降到百分之几。

这是「昨天还好好的查询今天突然变慢」的头号原因。

1. 大批量写入之后,autovacuum 的 ANALYZE 还没跑。

SELECT name, setting FROM pg_settings
WHERE name IN ('autovacuum_analyze_threshold', 'autovacuum_analyze_scale_factor');
⌘/Ctrl + Enter

触发条件是「变更行数 > 50 + 0.1 × 表行数」。刚导入完数据、刚建完表就跑查询,很可能用的是空统计——优化器会假设表只有几百行,选出完全错误的计划。

批量导入之后手工 ANALYZE 是标准操作,不是可选项。

2. 数据分布本身变了。 比如新上线一个大客户,tenant_id 的分布从均匀变成 90% 集中在一个值上。统计信息即使是新的,MCV 也需要重新采样才能反映。

3. pg_upgrade 之后。 大版本升级不迁移统计信息,升级完必须全库 ANALYZE,否则第一波查询会全部选错计划。这是升级事故的经典成因。

统计信息修好之前,可以用会话级参数验证你的猜测(注意:这些是诊断工具,不是生产配置):

SET enable_seqscan = off;
EXPLAIN (ANALYZE) SELECT * FROM orders WHERE status = 'paid';
⌘/Ctrl + Enter
SET enable_seqscan = on;
SELECT 'restored' AS 已恢复;
⌘/Ctrl + Enter

注意计划里多了一行 Disabled: true——PG 18 会明确标出「这个节点是在被惩罚的情况下仍然被选中的」。上面这个例子里 status 没有可用索引,所以哪怕关掉 Seq Scan,优化器也只能继续用它。

如果换成一个有索引可选的查询,关掉之后计划会真的改变。这时看执行时间:变快了说明代价模型的参数需要调(通常是 random_page_cost——默认 4.0 是为机械硬盘设的,SSD 上应该设到 1.1 左右);变慢了说明优化器本来就是对的,问题在别处。

先自己回答,再点开对照。

n_distinct 为负数是什么意思?为什么这种表示法更好?

负数表示「不同值数占总行数的比例」,正数是不同值的绝对个数id = -1 意味着每行都不同(唯一列),user_id = 1000 意味着恰好一千个不同用户。

比例表示法的好处是表增长时不用重新采样:一列如果本质上一直是唯一的,行数翻倍之后 -1 仍然正确;写成绝对值就立刻过期了。

常见错误:以为 Postgres 总能自己判断该用哪种表示。这个判断来自采样——而 ANALYZE 只采约 300 × default_statistics_target 行(默认配置下 30000 行)。对上亿行的表,这个采样率下 n_distinct 经常严重偏低,尤其值分布不均时;一旦偏低,所有基于它的选择率估算都会连锁偏高。正因如此才留了手动固定的口子:

ALTER TABLE big ALTER COLUMN tenant_id SET (n_distinct = 5000);

固定之后这个值不再被 ANALYZE 覆盖——当你比采样更清楚答案时,直接告诉优化器。

优化器怎么估算 WHERE status = 'paid' 的行数?它查的是哪个字段?

most_common_vals / most_common_freqsstatus 只有四个取值,全部进了 MCV,各占约 25%——优化器直接读出这个频率乘以总行数,得到约 2500 行。这就是第一节里它坚持走 Seq Scan 的依据。

顺带一提:status 这一列没有直方图。所有值都进了 MCV,剩余分布是空的,不需要再用直方图描述。

常见错误:以为等值估算一律走 1 / n_distinct。那是兜底算法,只在 MCV 里查不到这个值时才用,它隐含了「所有值均匀分布」的假设。两者在倾斜分布上差出数量级:一个 90% 集中在单个值上的列,均匀假设会把那个热值估成 1/n_distinct,错得离谱。MCV 存在的全部意义就是让高频值不走这条兜底路径——所以「MCV 有没有收进这个值」才是判断估算靠不靠谱的关键,而不是 n_distinct 本身准不准。

correlation 影响什么决策?它和 BRIN 有什么关系?

correlation单列的逻辑顺序与物理存储顺序有多一致,范围 −1 到 1。它直接影响索引扫描的代价估算

  • 相关性高 → 回表访问的页面是顺序的,代价低;
  • 相关性低 → 每次回表跳到随机位置,代价按 random_page_cost 计。

同一个值也是判断 BRIN 是否可用的依据:接近 ±1 说明每个页面段的 min/max 区间很窄,BRIN 能整段整段排除;接近 0 就别用。

常见错误:望文生义,把 correlation 当成「两个列之间的相关性」,拿它去解释 WHERE a = 1 AND b = 1 的估算错误。完全是两回事——correlation一列的值顺序 vs 物理顺序,跟别的列毫无关系;列与列之间的相关性由扩展统计(dependencies / ndistinct / mcv)处理,而且不会自动创建。这一节里两个都叫「相关」的东西,一个在 pg_stats 里、一个要手工 CREATE STATISTICS,撞名撞得非常不巧。

为什么 WHERE a = 1 AND b = 1 会被估算错 100 倍?怎么修?

因为优化器估算多个条件时默认假设它们互相独立,把各自的选择率相乘。a = 1 是 1/100,b = 1 也是 1/100,独立假设下相乘得 1/10000,乘 10 万行就是 10 行上下。但这张表里 ab 永远相等——b = 1 这个条件一行都没过滤掉,实际是 1000 行。

修法是用扩展统计告诉优化器这两列相关:

CREATE STATISTICS corr_stat (dependencies, ndistinct) ON a, b FROM corr;
ANALYZE corr;

之后估算回到千这个量级,误差从两个数量级降到百分之几(具体数字每次 ANALYZE 都会浮动,看数量级即可)。

常见错误一:建了一次扩展统计就以为多列估算问题解决了。它不会自动创建,也不会自动扩展——声明了哪几列,就只对哪几列的组合生效。查询里换一组列,独立假设立刻回来。

常见错误二:「估算虽然错了,但计划选对了,那就没事。」估算是下游所有决定的输入:连接算法、扫描方式、分给下游节点的 work_mem 都基于它。一个在当前数据量下侥幸正确的计划,会在数据涨一个量级时以完全无法预测的方式翻车——而那时没人会想到根因是一条几个月前就已经估错的选择率。

详见 EXPLAIN 逐字段读法

列举三种统计信息失效的场景。为什么 pg_upgrade 之后必须全库 ANALYZE?
  1. 大批量写入之后 autovacuum 的 ANALYZE 还没跑。 触发条件是「变更行数超过 50 加 0.1 倍表行数」。刚导入完、刚建完表就查,用的可能是空统计——优化器会假设表只有几百行,选出完全错误的计划。批量导入之后手工 ANALYZE 是标准操作,不是可选项。
  2. 数据分布本身变了。 比如新上线一个大客户,tenant_id 从均匀分布变成 90% 集中在一个值上。统计信息即使是新的,MCV 也要重新采样才能反映这个变化。
  3. pg_upgrade 之后。 大版本升级不迁移统计信息,整个库的 pg_stats 是空的。

pg_upgrade 之后必须全库 ANALYZE,就是因为空统计会让第一波查询全部选错计划——而升级后的第一波往往正是全量业务流量。

常见错误一:以为 pg_upgrade 跑完、服务起来了、数据查得到,升级就算成功了。表数据确实是完整的,缺的只有统计信息——所以症状不是报错,而是整个系统在几分钟内被自己的慢查询压垮。它和「新版本有性能回归」长得一模一样,很容易导致一个本来不必要的回滚决定。

常见错误二:以为 autovacuum 开着就不用操心。它的阈值主要是比例:一张一亿行的表要变更一千万行才触发一次 autoanalyze。对于持续小批量写入、但值分布在快速变化的大表,等 autovacuum 可能等不到——这类表需要手工调低 autovacuum_analyze_scale_factor 或定时 ANALYZE