4. 统计信息与代价模型

PostgreSQL 的查询优化器是一个基于代价的优化器(Cost-Based Optimizer,CBO)。它不会“理解”SQL 的语义,而是依赖一套关于数据分布的统计信息,估算每个候选执行计划要处理多少行、需要多少次随机/顺序 I/O、消耗多少 CPU,最后选择总代价最低的那个。因此,当一条 SQL 突然变慢、计划从索引扫描退化为顺序扫描时,问题往往不在 SQL 本身,而在统计信息失真或代价参数与实际硬件不匹配。本节围绕“统计信息如何被采集、如何被使用、如何影响计划选择”这条主线展开。

一、统计信息存放在哪里:pg_statistic 与 pg_stats

PostgreSQL 把统计信息保存在系统目录 pg_statistic 中,每个被分析过的列对应一行,记录该列的 null_frac(空值比例)、n_distinct(不同值个数)、most_common_vals(高频值)、histogram_bounds(直方图边界)、correlation(物理顺序相关性)等字段。但 pg_statistic 的字段类型是 anyarray 等内部类型,直接查询可读性很差,因此日常应使用视图 pg_stats,它把同样的内容以文本形式展开,并按表名、列名组织,便于阅读。

-- 查看某张表所有列的统计信息概览
SELECT schemaname, tablename, attname, n_distinct,
       null_frac, correlation
FROM pg_stats
WHERE tablename = 'orders'
ORDER BY attname;

-- 查看某一列的高频值与直方图
SELECT attname, most_common_vals, most_common_freqs,
       histogram_bounds
FROM pg_stats
WHERE tablename = 'orders' AND attname = 'status';

需要强调:pg_stats 只显示当前用户有权限读取的列,且只对“被 ANALYZE 采集过”的列有内容。如果某列从未被分析,或表刚批量导入大量数据,统计信息就是过期的,优化器只能靠默认选择率猜测,计划极易跑偏。

二、四类核心统计量及其作用

1. MCV(Most Common Values,高频值)。优化器对出现频率明显偏高的值单独记录,例如 status 列中 'paid' 占 60%、'pending' 占 25%。当查询条件是 status = 'paid' 时,优化器直接查 MCV 得到选择率 0.6,而不是用平均值猜测。MCV 的数量由 default_statistics_target 或列级 ALTER TABLE ... ALTER COLUMN ... SET STATISTICS 控制,默认 100。

2. 直方图(histogram_bounds)。对于没有进入 MCV 的“普通值”,优化器用等频直方图描述分布:把数据按值排序后切成 N 个桶,每个桶内行数大致相等,记录每个桶的边界值。范围条件(>、<、BETWEEN)的选择率就通过落在桶内的比例估算。直方图桶数同样受 statistics target 影响,桶越多,估算越细,但 ANALYZE 开销也越大。

3. n_distinct(不同值个数)。等值条件的选择率近似为 1 / n_distinct(当该值不在 MCV 中时)。若 n_distinct 为负数,表示不同值个数随表增长而增长,其绝对值是“每行新增的不同值比例”的估计,常用于主键、唯一列。

4. correlation(相关性)。取值范围 -1 到 1,描述列值在物理存储顺序上与逻辑排序的一致程度。接近 1 表示数据基本按该列有序存放,此时索引扫描的随机 I/O 代价低,优化器更愿意走索引;接近 0 表示数据散乱,索引扫描要频繁回表随机读,代价高,优化器可能改用顺序扫描。这是“同样的索引,为什么有时走有时不走”的关键原因之一。

三、选择率估算与代价模型如何共同决定计划

优化器的工作可以概括为三步:选择率估算 → 行数估算 → 代价比较。以 WHERE status = 'paid' AND created_at > '2024-01-01' 为例,优化器分别估算两个条件的选择率,再按独立性假设相乘(除非有扩展统计信息),得到预估行数。随后,代价模型用 seq_page_cost(默认 1.0)、random_page_cost(默认 4.0)、cpu_tuple_cost(默认 0.01)等参数,计算顺序扫描、索引扫描、位图扫描各自的代价,取最小者。

关键点:选择率决定“要处理多少行”,代价参数决定“每行多贵”。两者任一失真,计划都可能选错。例如 SSD 上 random_page_cost 仍为 4.0,优化器会高估索引扫描代价,明明该走索引却走了顺序扫描。

下面这张图展示了统计信息从采集到影响计划选择的完整链路,以及各环节失真的典型后果:

数据表 orders / 实际数据分布 ANALYZE 采集 采样 + 统计计算 pg_statistic pg_stats 视图可读 四类核心统计量 MCV 高频值 等值条件选择率 直方图 范围条件选择率 n_distinct 不同值个数估计 correlation 物理顺序相关性 选择率估算 sel = f(MCV, 直方图, n_distinct) 多条件按独立性假设相乘 代价模型 seq_page_cost / random_page_cost cpu_tuple_cost / cpu_index_tuple_cost 优化器选择最低代价计划 Seq Scan / Index Scan / Bitmap Scan / Hash Join ... 统计过期/失真 → 行数估算错误 → 计划跑偏 代价参数不匹配 → 索引/顺序扫描误判 → 计划跑偏

四、实操:如何诊断与修复统计信息问题

当怀疑计划因统计信息而跑偏时,可按以下顺序排查。第一步,用 EXPLAIN (ANALYZE, BUFFERS) 对比预估行数与实际行数。如果两者相差一个数量级以上(例如预估 100 行、实际 50 万行),基本可以判定统计信息或选择率估算有问题。

EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders
WHERE status = 'paid' AND created_at > '2024-01-01';

第二步,检查相关列的统计信息是否过期或粒度过粗。若 n_distinct 明显偏离真实值,或 histogram_bounds 桶数太少,可提高该列的统计目标后重新分析:

ALTER TABLE orders ALTER COLUMN status SET STATISTICS 500;
ALTER TABLE orders ALTER COLUMN created_at SET STATISTICS 500;
ANALYZE orders (status, created_at);

第三步,若多个列经常一起出现在 WHERE 中,且它们之间存在相关性(例如 city 与 province),单列统计的独立性假设会严重低估或高估选择率。此时应创建扩展统计信息:

CREATE STATISTICS orders_city_province (dependencies, ndistinct)
ON city, province FROM orders;
ANALYZE orders;

第四步,检查代价参数是否匹配硬件。在 SSD 或全内存场景下,random_page_cost 默认 4.0 偏高,可下调至 1.1~1.5,让优化器更愿意选择索引扫描。修改前建议先用 SET 在会话级验证效果,确认计划改善后再写入 postgresql.conf。

实用判断口诀:预估行数与实际行数差 10 倍以内属正常波动;差 100 倍以上,先查统计信息是否过期,再查是否需要扩展统计,最后查代价参数是否匹配存储介质。

五、常见误区与注意事项

  • ANALYZE 不是越频繁越好。大表全量 ANALYZE 会消耗 I/O 和 CPU,应结合 autovacuum 的 autovacuum_analyze_scale_factor 与 autovacuum_analyze_threshold 调整触发时机,而非手动频繁执行。
  • 提高 statistics target 有代价。桶数和 MCV 数量增加会增大 pg_statistic 体积,也会延长 ANALYZE 时间,应按列的重要性差异化设置,而非全表统一调高。
  • correlation 只反映物理顺序,不反映索引是否存在。即使 correlation 接近 1,若查询返回大量行,优化器仍可能选择顺序扫描,这是正常的代价权衡。
  • 表达式和函数条件无法直接使用列统计。例如 WHERE upper(name) = 'ABC',优化器对 upper(name) 没有统计信息,只能按默认选择率估算。此时可创建表达式索引并对其 ANALYZE,或改写条件。

总结来说,统计信息是优化器的“眼睛”,代价模型是它的“尺子”。眼睛看不清(统计过期、粒度过粗、忽略多列相关性),尺子不准(代价参数与硬件不符),都会导致计划选择错误。日常治理中,应把 pg_stats 的定期巡检、关键列统计目标的调整、多列扩展统计的创建,以及代价参数的校准,作为与索引设计同等重要的常规工作。