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)等参数,计算顺序扫描、索引扫描、位图扫描各自的代价,取最小者。
random_page_cost 仍为 4.0,优化器会高估索引扫描代价,明明该走索引却走了顺序扫描。
下面这张图展示了统计信息从采集到影响计划选择的完整链路,以及各环节失真的典型后果:
四、实操:如何诊断与修复统计信息问题
当怀疑计划因统计信息而跑偏时,可按以下顺序排查。第一步,用 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。
五、常见误区与注意事项
- 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 的定期巡检、关键列统计目标的调整、多列扩展统计的创建,以及代价参数的校准,作为与索引设计同等重要的常规工作。