Skip to content

直方图与相关性统计 ​

ANALYZE 采完样后,把每一列的数据分布压缩成两类结构存进 pg_statistic:MCV 列表(most common values)记录「哪些值出现得特别多」,直方图(histogram)记录「其余值的整体分布形状」。规划器估算 WHERE id < 1000 这类范围条件时,唯一依据就是直方图。

理解直方图才能理解一件反直觉的事:统计信息不是「数据快照」,而是「有损压缩」。它只有 100 个桶(默认),却要描述几千万行的分布。什么时候这个近似够用、什么时候会严重失真,取决于数据分布是否「规则」。

本节讲清 pg_stats 里与分布形状相关的字段(histogram_bounds、most_common_vals、n_distinct、correlation),规划器用它们算选择性的具体过程,以及怎么通过统计目标与列级配置把失真的估算纠正回来。

前置知识

阅读本节前,建议先了解:ANALYZE 统计收集

基础概念 ​

一行 pg_stats 里有什么 ​

pg_stats 每个「表 + 列」组合一行,把列的数据分布拆成若干互补的字段:

字段描述的东西谁在用
null_fracNULL 值占该列的比例所有条件下都会先减掉
avg_width该列值的平均字节宽度估算中间结果集的内存占用
n_distinct去重后的值个数(带符号,见下文)等值估算、GROUP BY 组数估算
most_common_vals出现频率最高的若干值等值条件、IN 条件
most_common_freqs上面每个值的出现频率与 most_common_vals 配对使用
histogram_bounds把剩余数据切成等频分桶的边界值范围条件(<、>、BETWEEN)
correlation物理行序与逻辑值序的相关系数判断索引扫描的实际代价

一句话概括分工:MCV 负责「尖峰」,直方图负责「底座」,correlation 负责「顺序」。

什么是直方图 ​

直方图是一串边界值,把列的取值范围切成若干个「桶(bucket)」。注意 PostgreSQL 直方图存的是桶的边界,不是每个桶的计数:

text
列 score 的 histogram_bounds(11 个边界 = 10 个桶)

  边界值:  0    993   1997  3050  4040  5036  5957  7057  8029  9016  9995
            │     │      │     │     │     │     │     │     │     │     │
  桶编号:    1     2      3     4     5     6     7     8     9    10
            └─────┴──────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘
            每个桶里装的「行数」大致相等(等频),而不是「取值范围」相等

10 个桶、11 个边界,这是直方图的一个固定约定:N 个桶需要 N+1 个边界值。桶 1 覆盖 [0, 993),桶 2 覆盖 [993, 1997),依此类推,最后一个桶覆盖到该列的最大值。

等频,不是等宽 ​

这是直方图最重要的一个性质:

text
假设 score 的 10000 行数据是:
  9000 行集中在 0~100(严重倾斜)
  1000 行均匀分布在 100~10000

等宽分桶(每桶 1000 值域)—— 对估算很糟:
  [0,1000)     → 9010 行   ← 一个桶装了大半数据
  [1000,2000)  → 110 行
  ...
  [9000,10000) → 110 行

等频分桶(每桶 1000 行)—— PostgreSQL 采用:
  [0, ...]     → 1000 行   ← 密集区被切成很多细桶
  ...
  边界在密集区非常密集,在稀疏区非常疏远

等频分桶的好处是:每个桶的行数一样多。因此「落在一个桶里的比例 = 1/桶数」,估算只需要算出目标值落在第几个桶、在该桶内的相对位置,就能推出选择性。密集区间会分到更多桶(更精细),稀疏区间桶少(更粗糙),正好符合「估算精度该花在哪」的直觉。

MCV 与直方图不重叠 ​

一个关键细节:直方图不包含 MCV 覆盖的那部分数据。

text
一列的全部行
├── NULL                       → 由 null_frac 描述
├── MCV 覆盖的部分(尖峰)      → 由 most_common_vals / most_common_freqs 精确描述
└── 其余部分(长尾)            → 由 histogram_bounds 描述

所以判断一列的「尖峰」是否被完整捕捉,只需看 sum(most_common_freqs):

  • 合计 0.9 → 90% 的数据被 MCV 精确描述,直方图只需管剩下 10%。
  • 合计 0.03 → MCV 只覆盖 3%,剩下 97% 全靠 100 个桶近似。

官方文档把这个分工说得很清楚:对非唯一列,规划器先把条件套用到 MCV 列表上并累加命中值的频率(这部分是精确的),再用直方图估算非 MCV 部分,最后把两者相加。

correlation:物理序与逻辑序一致吗 ​

correlation 描述「列值的逻辑顺序」与「行在磁盘上的物理顺序」的相关程度,取值 -1 ~ +1:

text
correlation ≈ +1(物理顺序与排序顺序一致,如自增主键)
  磁盘上:  [1, 2, 3, 4, 5, 6, ...]
  读 id BETWEEN 100 AND 200 时,命中的页是连续的 → 顺序 I/O

correlation ≈ 0(值随机散布在整张表里)
  磁盘上:  [57, 3, 891, 42, 620, ...]
  读 id BETWEEN 100 AND 200 时,几乎每命中一行都换一个页 → 随机 I/O

规划器会用 correlation 修正索引扫描的成本估算:correlation 接近 ±1 时,索引扫描被估算得更便宜(因为随机访问少);接近 0 时更贵。这解释了为什么同样的索引、同样的条件,在不同表上可能选出不同的计划。

n_distinct 的正负号 ​

n_distinct 有两种含义,靠符号区分:

取值形式含义什么时候出现
正数(如 676)去重后的值个数估计该列的值域看起来是固定的(如枚举、状态码)
负数(如 -1、-0.568)去重值个数 除以 行数该列的值个数看起来会随表增长(如用户 ID、订单号)
-1值个数 = 行数,即唯一列主键、唯一索引列

负值形式的意义是「随行数按比例增长」,所以估算时要乘当前行数。例如 -0.568 表示预计不同值个数约为行数的 56.8%。这也是为什么 pg_stats 里的 n_distinct 常常看起来是个奇怪的负数。

术语表 ​

术语含义
选择率(selectivity)满足某个条件的行占全表的比例,0~1
基数(cardinality)表的总行数估计,规划器从 pg_class.reltuples 起步并缩放
桶(bucket)直方图的一段,桶内行数与其他桶大致相等
等频分桶让每个桶行数相等的分桶方式(PostgreSQL 的做法)
MCVmost common values,出现频率最高的值列表
统计目标(statistics target)控制 MCV 条目数与直方图桶数的上限

语法与用法 ​

查看某列的直方图 ​

sql
-- 直方图边界:一串值,读作「桶的分隔点」
SELECT histogram_bounds
FROM pg_stats
WHERE schemaname = 'public'
  AND tablename  = 'events'
  AND attname    = 'score';
text
              histogram_bounds
-----------------------------------------
 {0,993,1997,3050,4040,5036,5957,7057,8029,9016,9995}
(1 row)

11 个边界对应 10 个桶,因为默认 default_statistics_target = 100 时桶数 = 统计目标,这里为便于阅读缩减成了 10 个。边界数量永远等于「桶数 + 1」。

同时查看 MCV、直方图与相关性 ​

sql
SELECT attname,
       null_frac,
       n_distinct,
       round((SELECT sum(f) FROM unnest(most_common_freqs) AS f)::numeric, 4) AS mcv_frac,
       array_length(histogram_bounds, 1) AS hist_points,
       correlation
FROM pg_stats
WHERE schemaname = 'public' AND tablename = 'events'
ORDER BY attname;
text
 attname | null_frac | n_distinct | mcv_frac | hist_points | correlation
---------+-----------+------------+----------+-------------+-------------
 id      |         0 |         -1 |          |             |       1.000
 status  |         0 |          3 |   0.9993 |             |            
 score   |      0.02 |       0.41 |   0.0312 |         101 |       0.012
 created |         0 |         -1 |          |             |       1.000
(4 rows)

从这一行就能读出四件事:

  • id:n_distinct = -1 唯一列,correlation = 1.000 物理有序,没有直方图(唯一列每个值频率相同,直方图无意义)。
  • status:只有 3 个值且全部进了 MCV(mcv_frac ≈ 1),因此直方图为空——MCV 覆盖了全部数据时直方图为空。
  • score:mcv_frac 仅 3.12%,说明 97% 的分数值靠 101 个边界(100 个桶)描述;correlation 接近 0,说明 score 在物理上完全乱序。
  • created:唯一且有序,是最理想的索引扫描场景。

直方图为 NULL 的三种情况 ​

histogram_bounds 为 NULL 不一定是统计缺失,官方文档列出三种原因:

  • 该列的数据类型没有 < 比较运算符(如 jsonb 的无默认排序、某些自定义类型)——无法排序就无法分桶。
  • MCV 列表已经覆盖了全部数据(如上面的 status 列)。
  • 统计目标被设为 0,或列从未被 ANALYZE。

调整统计目标:桶数与 MCV 条目数一起变 ​

default_statistics_target 同时决定三件事的规模,全局影响所有表:

ini
# postgresql.conf
default_statistics_target = 100    # 默认值,范围 1 ~ 10000
sql
-- 全局调整(对之后运行的 ANALYZE 生效)
ALTER SYSTEM SET default_statistics_target = 200;

-- 只对某一列提高精度(推荐做法)
ALTER TABLE events ALTER COLUMN score SET STATISTICS 1000;

-- 然后再跑一次 ANALYZE,新目标才会生效
ANALYZE events (score);

关键点:改统计目标不会自动重新统计,必须重新 ANALYZE。统计目标从 100 提到 1000,ANALYZE 采样的行数会成比例增加(采样行数由图中所分析各列里最大的统计目标决定),耗时与 pg_statistic 空间也随之上升。

校准 n_distinct ​

采样估算 n_distinct 天然不准,尤其在大表上。如果确认某个值稳定,可以手工钉死:

sql
-- 先实测真实去重值个数
SELECT count(DISTINCT user_id) FROM events;

-- 手工指定,避免采样误差导致的估算偏差
ALTER TABLE events ALTER COLUMN user_id SET (n_distinct = 8500000);

n_distinct 也可以设成负的小数(如 -0.8),表示「不同值个数约为行数的 80%」,适合会持续增长的表。

详细说明 ​

规划器如何用直方图估算范围条件 ​

以官方文档的经典例子为例。表 tenk1 有 10000 行,列 unique1 的直方图为:

text
histogram_bounds = {0,993,1997,3050,4040,5036,5957,7057,8029,9016,9995}
桶数 = 10

查询 WHERE unique1 < 1000 的估算过程:

text
第 1 步:定位 1000 落在哪个桶
  桶 1 覆盖 [0, 993),桶 2 覆盖 [993, 1997)
  → 1000 落在桶 2

第 2 步:假设桶内值均匀分布,算出桶 2 内 < 1000 的比例
  (1000 - 993) / (1997 - 993) = 7 / 1004 ≈ 0.006972

第 3 步:桶 1 整个命中,加上桶 2 的部分命中,除以总桶数
  selectivity = (1 + 0.006972) / 10 = 0.100697
  (公式写作:(1 + (1000 - bucket[2].min) / (bucket[2].max - bucket[2].min)) / num_buckets)

第 4 步:选择性 × 表基数 = 估算行数
  rows = 10000 × 0.100697 = 1007

核对 EXPLAIN 输出:

text
 Bitmap Heap Scan on tenk1  (cost=24.06..394.64 rows=1007 width=244)
   Recheck Cond: (unique1 < 1000)
   ->  Bitmap Index Scan on tenk1_unique1  (cost=0.00..23.80 rows=1007 width=0)
         Index Cond: (unique1 < 1000)

rows=1007 与手算完全一致。这就是「直方图 = 范围估算的唯一依据」的具体含义。

桶内均匀分布假设:精度的根本来源与极限 ​

上面的第 2 步藏着一个关键假设——桶内的值均匀分布。这个假设是否成立,直接决定估算准不准:

text
情况 A:桶内确实均匀 —— 估算准确
  桶 [993, 1997),实际值均匀散布 → 线性插值误差很小

情况 B:桶内有二次尖峰 —— 估算偏差可达数倍
  桶 [993, 1997) 实际是:1980~1997 挤了 900 行,其余 100 行稀疏散布
  值 1500 的真实选择性远低于线性插值给出的值

情况 C:值域稀疏 + 数据倾斜 —— 偏差可达数量级

提高桶数是唯一能缓解此问题的杠杆:桶越多,每个桶的跨度越小,「桶内均匀」的假设越容易成立。倾斜严重的列应当单独提高统计目标。

MCV 与直方图如何合并(完整公式) ​

对非唯一列,范围条件的选择性由两部分拼成。以官方 stringu1 < 'IAAAAA' 为例:

text
第 1 步:把条件直接套到 MCV 列表上,累加命中值的频率(精确)
  命中的 MCV 频率 = 0.00333333 + 0.003 × 5 = 0.01833333
  全部 MCV 频率之和 = 0.03033333
  → 非 MCV 部分占比 = 1 - 0.03033333 = 0.96966667(假设无 NULL)

第 2 步:用直方图估算非 MCV 部分内满足条件的比例
  histogram_selectivity ≈ 0.298387

第 3 步:两部分加权相加
  selectivity = 0.01833333 + 0.298387 × 0.96966667
              = 0.307669

第 4 步:× 表基数
  rows = 10000 × 0.307669 = 3077

这个例子里 MCV 修正只把结果从 0.298 抬到 0.308,因为该列分布其实很平(MCV 里那些「高频值」多是采样噪声)。但在真正有尖峰的列上,MCV 部分给出的是精确值,精度提升非常明显。这就是 PostgreSQL 把 MCV 与直方图分开存储、不重叠的原因:尖峰用精确描述,长尾用近似描述,各得其所。

等值条件的估算:三种情况 ​

对 WHERE col = value,估算路径取决于 value 在不在 MCV 里:

sql
-- 情况 1:值在 MCV 里 —— 直接取对应频率,精确
SELECT most_common_freqs[array_position(most_common_vals::text[], 'CRAAAA')]
FROM pg_stats WHERE tablename = 'tenk1' AND attname = 'stringu1';
-- 结果 0.003 → rows = 10000 × 0.003 = 30

-- 情况 2:值不在 MCV 里 —— 用「剩余概率 ÷ 剩余不同值个数」摊平
--   selectivity = (1 - sum(mcv_freqs)) / (n_distinct - num_mcv)
--               = (1 - 0.0303) / (676 - 10)
--               = 0.0014559
--   rows = 10000 × 0.0014559 = 15

-- 情况 3:有 NULL —— 分子还要先减掉 null_frac
--   selectivity = (1 - null_frac - sum(mcv_freqs)) / (n_distinct - num_mcv)

情况 2 的假设是「所有非 MCV 的值出现频率相同」。如果长尾里有未被 MCV 捕捉的次级尖峰,这里会低估;这正是提高统计目标(让更多次级尖峰进入 MCV)能改善估算的原因。

直方图对等值条件是无效的 ​

一个容易踩的认知误区:直方图只在范围条件下被使用,等值条件不看直方图。原因很直接——直方图只存桶边界,不存每个具体值,无法回答「值 X 恰好有多少行」。所以:

sql
-- 有直方图帮助(范围)
WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01'

-- 直方图帮不上(等值),只能依赖 MCV 或「摊平」假设
WHERE status = 'pending'

如果等值条件用到的值偏偏不在 MCV 里,而它实际上是尖峰,估算就会严重低估。此时把该列的统计目标调高,让这个值挤进 MCV,是标准解法。

correlation 如何影响索引扫描的成本 ​

规划器比较「索引扫描」与「顺序扫描」时,random_page_cost 决定每次随机访问的代价,而 correlation 用来修正索引扫描实际上会有多少次随机访问:

text
correlation ≈ 1(自增主键)
  索引扫描命中 1000 行 → 对应的堆页高度集中且连续
  规划器按「接近顺序 I/O」估算 → 索引扫描便宜,容易被选中

correlation ≈ 0(随机 UUID)
  索引扫描命中 1000 行 → 对应 1000 个分散的堆页,每次都是随机 I/O
  规划器按「1000 次随机访问」估算 → 索引扫描昂贵,可能不如顺序扫描
sql
-- 对比两张表同名列的 correlation
SELECT tablename, attname, n_distinct, correlation
FROM pg_stats
WHERE schemaname = 'public'
  AND attname IN ('id', 'order_no')
ORDER BY tablename, attname;
text
   tablename   |  attname  | n_distinct | correlation
---------------+-----------+------------+-------------
 orders        | id        |         -1 |       1.000
 orders        | order_no  |         -1 |      -0.013
 users         | id        |         -1 |       1.000
(3 rows)

orders.order_no 是随机生成且唯一的,correlation 接近 0。即使它有唯一索引,按 order_no 做范围查询也不会走索引扫描——因为要读的行散布在整表,规划器认为顺序扫描更快。这不是 bug,而是正确的代价判断。

逻辑非等价:为什么退化过程会「打散」correlation ​

correlation 会因为两类操作而恶化:

text
1. 频繁 UPDATE 造成 HOT / 非 HOT 更新
   → 新版本被写到任意有空位的页,物理顺序被打散

2. 大批量导入时未按排序键写入
   → 数据天然乱序

3. VACUUM FULL / CLUSTER 可以恢复顺序(CLUSTER 后 correlation 接近 1)
sql
-- 检查是否值得 CLUSTER
SELECT attname, correlation
FROM pg_stats
WHERE schemaname = 'public' AND tablename = 'orders'
ORDER BY abs(correlation);
text
 attname | correlation
---------+-------------
 order_no|      -0.013
 amount  |       0.004
 id      |       1.000
 created |       0.998
(4 rows)

若某个范围查询的热点列 correlation 接近 0,且该查询很关键,可以考虑 CLUSTER 一次(注意 CLUSTER 会持有 ACCESS EXCLUSIVE 锁,且此后新写入仍会逐渐打散)。

n_distinct 的估算误差从哪里来 ​

n_distinct 用采样估算不同值个数,对超大表 + 高基数列尤其不准:

text
采样 30000 行估算一张 10 亿行表的 user_id 去重个数
→ 采样里出现的大部分 user_id 都只出现一次
→ 经典估算器(基于「单次出现值个数」)在这种极端稀疏场景下偏差很大
→ 有时偏差可达数倍

偏差的后果:

  • 估小了 → GROUP BY user_id 的组数被低估 → 可能选 HashAggregate 而不是更省内存的其他方案。
  • 用于 JOIN 时 → 连接结果集大小估算错误 → 连接算法选错。

官方文档对此的建议很直接:如果 n_distinct 不准导致坏计划,就手工实测后 SET (n_distinct = ...) 钉死。

表达式列没有统计信息 ​

统计是以「列」为单位收集的,函数/表达式的结果没有对应的列统计:

sql
-- 规划器对 date_trunc('month', created_at) 一无所知
WHERE date_trunc('month', created_at) = '2026-01-01'
-- → 使用默认选择性 0.5%(等值)或 0.33%(范围),很可能离谱

-- 解法一:改写为 sargable 形式,让原始列上的直方图生效
WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01'

-- 解法二:为表达式建立扩展统计(见下一节),自动获得该表达式的单列统计

这解释了为什么「给条件列套函数」是性能反模式:不只是索引失效,统计也随之失效。改写查询通常比补统计更划算。

直方图的三个硬性局限 ​

局限表现缓解方式
只有单列分布多列相关时假设独立并相乘扩展统计(CREATE STATISTICS)
桶内假设均匀桶内二次尖峰导致估算失真提高该列统计目标
只覆盖有 < 运算符的类型无排序的类型(部分 jsonb、数组比较缺失时)无直方图无法通过统计解决,需改写查询

实战示例 ​

场景一:诊断一个范围查询的估算偏差 ​

现象:一条按时间段统计的查询偶发走错计划,EXPLAIN ANALYZE 显示估算行数与实际行数差两个数量级。

sql
-- 1. 先看计划里的估算 vs 实际
EXPLAIN (ANALYZE, BUFFERS, TIMING OFF)
SELECT count(*)
FROM events
WHERE created_at >= '2026-03-01' AND created_at < '2026-04-01';
text
 Aggregate  (cost=21500.11..21500.12 rows=1 width=8)
            (actual time=812.443..812.444 rows=1 loops=1)
   Buffers: shared hit=41200 read=86300
   ->  Seq Scan on events  (cost=0.00..20730.50 rows=630 width=8)
                           (actual time=0.014..790.220 rows=1184320 loops=1)
         Filter: ((created_at >= '2026-03-01'::timestamp)
              AND (created_at < '2026-04-01'::timestamp))
         Rows Removed by Filter: 8815680
         Buffers: shared hit=41200 read=86300
 Planning Time: 0.212 ms
 Execution Time: 812.771 ms

估算 rows=630,实际 rows=1184320,差了 1880 倍。

sql
-- 2. 检查该列的统计信息
SELECT attname,
       null_frac,
       n_distinct,
       correlation,
       array_length(histogram_bounds, 1) AS hist_points,
       histogram_bounds[1:3] AS first_bounds
FROM pg_stats
WHERE schemaname = 'public' AND tablename = 'events' AND attname = 'created_at';
text
  attname   | null_frac | n_distinct | correlation | hist_points |   first_bounds
------------+-----------+------------+-------------+-------------+-------------------
 created_at |         0 |       0.83 |        0.21 |         101 | {2025-01-01 00:00:00,
            |           |            |             |             |  2025-01-02 06:12:00,
            |           |            |             |             |  2025-01-03 11:48:00}
(1 row)
text
诊断结论:
  统计看起来是新鲜的(101 个边界 = 100 个桶)
  但 100 个桶要覆盖 15 个月的数据 → 每个桶跨约 4.5 天
  而查询只要 1 个月 → 落在大约 0.7 个桶内
  → 「桶内均匀分布」假设在这里完全失效
     因为该月恰好是促销期,写入量是平时的 10 倍
sql
-- 3. 解法:提高该列的统计目标,让桶足够细
ALTER TABLE events ALTER COLUMN created_at SET STATISTICS 1000;
ANALYZE events (created_at);

-- 4. 复测
EXPLAIN (ANALYZE, BUFFERS, TIMING OFF)
SELECT count(*)
FROM events
WHERE created_at >= '2026-03-01' AND created_at < '2026-04-01';
text
 Aggregate  (cost=19850.30..19850.31 rows=1 width=8)
            (actual time=210.113..210.114 rows=1 loops=1)
   Buffers: shared hit=9800 read=21200
   ->  Index Only Scan using events_created_at_idx on events
           (cost=0.56..18000.10 rows=1120500 width=8)
           (actual time=0.038..178.220 rows=1184320 loops=1)
         Index Cond: ((created_at >= '2026-03-01'::timestamp)
                  AND (created_at < '2026-04-01'::timestamp))
         Heap Fetches: 0
         Buffers: shared hit=9800 read=21200
 Planning Time: 0.198 ms
 Execution Time: 210.771 ms

估算从 630 提升到 1120500(真实 1184320),误差从 1880 倍缩到 1.06 倍;计划从 812ms 的 Seq Scan 换成 210ms 的 Index Only Scan。只改了一个列的统计目标。

场景二:为不均匀分布的大表做统计体检 ​

需求:每月例行检查「哪些列的统计明显失真或不新鲜」,重点关注分布倾斜与 correlation 退化的列。

sql
-- 一次性列出所有大表的关键统计指标
SELECT
  s.schemaname || '.' || s.tablename AS tbl,
  s.attname,
  s.n_distinct,
  s.correlation,
  round(coalesce((SELECT sum(f) FROM unnest(s.most_common_freqs) AS f), 0)::numeric, 4) AS mcv_frac,
  coalesce(array_length(s.histogram_bounds, 1) - 1, 0) AS buckets,
  c.reltuples::bigint AS est_rows,
  pg_size_pretty(pg_relation_size(c.oid)) AS size
FROM pg_stats s
JOIN pg_class c ON c.relname = s.tablename AND c.relkind = 'r'
JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = s.schemaname
WHERE s.schemaname NOT IN ('pg_catalog', 'information_schema')
  AND c.reltuples > 1000000
ORDER BY abs(s.correlation) ASC
LIMIT 40;
text
     tbl       |  attname   | n_distinct | correlation | mcv_frac | buckets | est_rows  |  size
---------------+------------+------------+-------------+----------+---------+-----------+--------
 public.orders | order_no   |         -1 |      -0.013 |          |         |  12000000 | 1824 MB
 public.orders | amount     |       3.14 |       0.004 |   0.0021 |     100 |  12000000 | 1824 MB
 public.events | payload    |        -1 |       0.031 |          |         |  20000000 | 6100 MB
 public.events | created_at |       0.83 |       0.210 |   0.0000 |     100 |  20000000 | 6100 MB
 public.users  | email      |         -1 |       0.998 |          |         |   3000000 |  420 MB
(5 rows)

按 abs(correlation) 升序取前 40 行,眼睛先落在 correlation 最低的行上,再看 mcv_frac 与 buckets:

text
逐个判读:
  orders.order_no  correlation ≈ 0 且 n_distinct = -1(唯一、随机)
    → 范围查询不会走索引,符合预期,不必处理

  orders.amount    mcv_frac = 0.0021(MCV 几乎没覆盖)
                   统计目标 100 → 桶只有 100 个
                   金额分布通常有多个尖峰(9.9 / 19.9 / 99)
    → 提高统计目标:ALTER TABLE orders ALTER COLUMN amount SET STATISTICS 500;
    → 若金额是等值条件热点,MCV 覆盖不足必然低估

  events.created_at correlation = 0.21(时间列本该接近 1)
                   说明存在大量更新或乱序导入
    → 若按时间范围查询是热点,考虑 CLUSTER 恢复顺序(注意锁)

  users.email      correlation ≈ 1 且唯一
    → 最理想,无需处理
sql
-- 针对体检结果批量提升关键列的统计目标
ALTER TABLE orders ALTER COLUMN amount   SET STATISTICS 500;
ALTER TABLE events ALTER COLUMN created_at SET STATISTICS 1000;

-- 重新收集(只分析这些列,省时)
ANALYZE orders (amount);
ANALYZE events (created_at);

-- 确认生效:buckets 应随之增大
SELECT tablename, attname,
       array_length(histogram_bounds, 1) - 1 AS buckets
FROM pg_stats
WHERE (tablename, attname) IN (('orders','amount'), ('events','created_at'));
text
 tablename |  attname   | buckets
-----------+------------+---------
 orders    | amount     |     500
 events    | created_at |    1000
(2 rows)

注意事项 ​

不要为了「更准」就无脑调大全局 default_statistics_target。它会让所有表、所有列的采样行数一起增大,ANALYZE 时间与 pg_statistic 空间成比例上涨,而收益只集中在少数倾斜列上:

sql
-- 不推荐:全局拉到 1000,每张表的 ANALYZE 都变慢 10 倍
ALTER SYSTEM SET default_statistics_target = 1000;

-- 推荐:只针对真正需要精度的列
ALTER TABLE events ALTER COLUMN created_at SET STATISTICS 1000;

改完统计目标必须重新 ANALYZE,否则目标只是「下次统计时的指令」,现有统计仍旧:

sql
-- 错误!只改了目标,没有重新统计,查询仍按旧统计做计划
ALTER TABLE events ALTER COLUMN score SET STATISTICS 1000;

-- 正确
ALTER TABLE events ALTER COLUMN score SET STATISTICS 1000;
ANALYZE events (score);

不要用 correlation ≈ 0 去否定索引的存在价值。它只说明「范围扫描这个列」时索引不划算,等值查询或覆盖索引(Index Only Scan)仍然可能高效:

sql
-- correlation 接近 0,但等值查询仍走索引,因为只需定位少量行
EXPLAIN SELECT * FROM orders WHERE order_no = 'ORD-20260301-000123';

直方图并不描述 NULL,NULL 值只由 null_frac 反映。条件里带上 IS NULL / IS NOT NULL 时,估算走的是 null_frac 而非直方图:

sql
-- IS NULL 的选择性直接取自 null_frac
WHERE deleted_at IS NULL
-- 若 null_frac = 0.97,规划器认为 97% 的行命中

列上缺少 < 运算符时不会有直方图,此时范围估算退化为默认值(通常严重失真),必须改写查询而不能指望调统计:

sql
-- 若该类型无排序运算符,histogram_bounds 为 NULL
SELECT histogram_bounds FROM pg_stats WHERE tablename='t' AND attname='j';

-- 解法:把条件改写为可利用的等价形式,或加表达式索引 + 扩展统计

统计目标设为 0 会彻底关闭该列统计,这是「优化」而非「禁用维护」的手段,但也意味着该列任何条件都只能靠默认估算:

sql
-- 适用于从未出现在 WHERE / GROUP BY / ORDER BY 中的列
ALTER TABLE events ALTER COLUMN raw_payload SET STATISTICS 0;

-- 不推荐:对热点条件列这么做,等于放弃该列的计划质量
ALTER TABLE events ALTER COLUMN created_at SET STATISTICS 0;   -- 不推荐

采样带随机性,统计会「抖」。每次 ANALYZE 结果都略有不同,极端情况下可能导致计划在两次 ANALYZE 之间发生变化。要降低这种不确定性,唯一办法就是提高统计目标(采样更多行)。

pg_statistic 只有超级用户能读,日常排查请用 pg_stats,它做了权限过滤,普通用户只能看到自己有权访问的表:

sql
-- 普通用户会因权限报错
SELECT * FROM pg_statistic;   -- 错误!permission denied

-- 正确:pg_stats 对所有用户开放,且自动过滤
SELECT * FROM pg_stats WHERE tablename = 'events';

手工 SET (n_distinct = ...) 是一把双刃剑。它压制了采样误差,但如果数据分布后来变了而你忘了更新,估算会长期错误,且不会再被 ANALYZE 修正:

sql
-- set 之后就固定了,ANALYZE 不再覆盖它
ALTER TABLE events ALTER COLUMN user_id SET (n_distinct = 8500000);
-- 恢复自动估算
ALTER TABLE events ALTER COLUMN user_id RESET (n_distinct);

correlation 只看当前物理布局,VACUUM FULL / CLUSTER 会改变它,但改变后需要重新 ANALYZE 才能让规划器看到:

sql
CLUSTER orders USING orders_created_idx;
ANALYZE orders;         -- 必须重新分析,correlation 才会更新
SELECT correlation FROM pg_stats WHERE tablename='orders' AND attname='created';

最佳实践 ​

把统计体检纳入例行运维,用一条 SQL 找出「倾斜且统计目标低」的列,而不是等出问题再查:

sql
-- 找 MCV 覆盖极低、且表很大的列(统计目标很可能不够)
SELECT s.schemaname, s.tablename, s.attname, c.reltuples::bigint AS est_rows,
       round(coalesce((SELECT sum(f) FROM unnest(s.most_common_freqs) AS f), 0)::numeric, 4) AS mcv_frac
FROM pg_stats s
JOIN pg_class c ON c.relname = s.tablename
WHERE c.reltuples > 5000000
  AND c.relkind = 'r'
  AND coalesce((SELECT sum(f) FROM unnest(s.most_common_freqs) AS f), 0) < 0.01
ORDER BY c.reltuples DESC;

优先提高「排序/范围热点列」的统计目标,因为这类列最依赖直方图精度:

sql
-- 时间列、金额列、分数列通常是范围查询热点
ALTER TABLE events  ALTER COLUMN created_at SET STATISTICS 1000;
ALTER TABLE orders  ALTER COLUMN amount     SET STATISTICS 500;
ALTER TABLE metrics ALTER COLUMN score      SET STATISTICS 500;
ANALYZE events (created_at);
ANALYZE orders (amount);
ANALYZE metrics (score);

把条件改写成 sargable 形式,让直方图重新生效,这是收益最高、成本最低的优化:

sql
-- 不推荐:函数包裹列,统计与索引双双失效
WHERE date_trunc('day', created_at) = '2026-03-01'

-- 推荐:范围条件直接落在列上
WHERE created_at >= '2026-03-01' AND created_at < '2026-03-02'

对等值热点列,确保热点值能进 MCV。判断方法是看 most_common_vals 里有没有它:

sql
-- 查询某列的高频值有没有被捕捉
SELECT attname, most_common_vals, most_common_freqs
FROM pg_stats
WHERE tablename = 'orders' AND attname = 'status';
-- 若业务上的热点值不在列表里 → 提高该列统计目标后重新 ANALYZE

给会持续增长的高基数列手工校准 n_distinct,避免采样误差影响 GROUP BY 与 JOIN 估算:

sql
SELECT count(DISTINCT user_id) FROM events;          -- 实测
ALTER TABLE events ALTER COLUMN user_id SET (n_distinct = 8500000);

用表达式统计(下一个主题)代替手工改写复杂表达式条件。若无法改写查询,可让 CREATE STATISTICS 自动为表达式收集单列统计:

sql
CREATE STATISTICS stat_events_month ON date_trunc('month', created_at) FROM events;
ANALYZE events;

不要给从不出现在查询条件里的列收集统计,纯属浪费 ANALYZE 时间与 pg_statistic 空间:

sql
ALTER TABLE events ALTER COLUMN raw_payload SET STATISTICS 0;

大表首次导入后立即手工 ANALYZE,不要等 autovacuum 排期——第一批查询往往就发生在这段空窗期:

sql
COPY events FROM '/data/events.csv' WITH (FORMAT csv);
ANALYZE events;      -- 立即建立统计,避免首查走坏计划

在 EXPLAIN 中对比估算与真实行数,把「估算偏差倍数」当成一个可量化指标追踪:

sql
EXPLAIN (ANALYZE, TIMING OFF) SELECT ... ;
-- 关注每行 rows=估算 (actual rows=实际 loops=N)
-- 偏差超过 10 倍即可疑,超过 100 倍基本可断定统计有问题

同一列不要既设 n_distinct 又指望它能自动适应数据变化,两者只能选一个;若数据分布会变,就别钉死:

sql
-- 数据分布稳定(如地区代码)→ 可以钉死
ALTER TABLE orders ALTER COLUMN region SET (n_distinct = 34);

-- 数据分布持续变化(如活跃用户数)→ 交给自动估算
ALTER TABLE events ALTER COLUMN user_id RESET (n_distinct);

定期重新 ANALYZE 相关性被写入打散的关键列,并评估是否需要 CLUSTER 恢复物理顺序:

sql
SELECT attname, correlation FROM pg_stats
WHERE tablename = 'orders' ORDER BY abs(correlation) LIMIT 5;
-- 若关键热点列 correlation 掉到 0.3 以下且范围查询频繁,考虑 CLUSTER

参考链接 ​