直方图与相关性统计
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_frac | NULL 值占该列的比例 | 所有条件下都会先减掉 |
avg_width | 该列值的平均字节宽度 | 估算中间结果集的内存占用 |
n_distinct | 去重后的值个数(带符号,见下文) | 等值估算、GROUP BY 组数估算 |
most_common_vals | 出现频率最高的若干值 | 等值条件、IN 条件 |
most_common_freqs | 上面每个值的出现频率 | 与 most_common_vals 配对使用 |
histogram_bounds | 把剩余数据切成等频分桶的边界值 | 范围条件(<、>、BETWEEN) |
correlation | 物理行序与逻辑值序的相关系数 | 判断索引扫描的实际代价 |
一句话概括分工:MCV 负责「尖峰」,直方图负责「底座」,correlation 负责「顺序」。
什么是直方图
直方图是一串边界值,把列的取值范围切成若干个「桶(bucket)」。注意 PostgreSQL 直方图存的是桶的边界,不是每个桶的计数:
列 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),依此类推,最后一个桶覆盖到该列的最大值。
等频,不是等宽
这是直方图最重要的一个性质:
假设 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 覆盖的那部分数据。
一列的全部行
├── 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:
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 的做法) |
| MCV | most common values,出现频率最高的值列表 |
| 统计目标(statistics target) | 控制 MCV 条目数与直方图桶数的上限 |
语法与用法
查看某列的直方图
-- 直方图边界:一串值,读作「桶的分隔点」
SELECT histogram_bounds
FROM pg_stats
WHERE schemaname = 'public'
AND tablename = 'events'
AND attname = 'score'; histogram_bounds
-----------------------------------------
{0,993,1997,3050,4040,5036,5957,7057,8029,9016,9995}
(1 row)11 个边界对应 10 个桶,因为默认 default_statistics_target = 100 时桶数 = 统计目标,这里为便于阅读缩减成了 10 个。边界数量永远等于「桶数 + 1」。
同时查看 MCV、直方图与相关性
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; 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 同时决定三件事的规模,全局影响所有表:
# postgresql.conf
default_statistics_target = 100 # 默认值,范围 1 ~ 10000-- 全局调整(对之后运行的 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 天然不准,尤其在大表上。如果确认某个值稳定,可以手工钉死:
-- 先实测真实去重值个数
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 的直方图为:
histogram_bounds = {0,993,1997,3050,4040,5036,5957,7057,8029,9016,9995}
桶数 = 10查询 WHERE unique1 < 1000 的估算过程:
第 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 输出:
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 步藏着一个关键假设——桶内的值均匀分布。这个假设是否成立,直接决定估算准不准:
情况 A:桶内确实均匀 —— 估算准确
桶 [993, 1997),实际值均匀散布 → 线性插值误差很小
情况 B:桶内有二次尖峰 —— 估算偏差可达数倍
桶 [993, 1997) 实际是:1980~1997 挤了 900 行,其余 100 行稀疏散布
值 1500 的真实选择性远低于线性插值给出的值
情况 C:值域稀疏 + 数据倾斜 —— 偏差可达数量级提高桶数是唯一能缓解此问题的杠杆:桶越多,每个桶的跨度越小,「桶内均匀」的假设越容易成立。倾斜严重的列应当单独提高统计目标。
MCV 与直方图如何合并(完整公式)
对非唯一列,范围条件的选择性由两部分拼成。以官方 stringu1 < 'IAAAAA' 为例:
第 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 里:
-- 情况 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 恰好有多少行」。所以:
-- 有直方图帮助(范围)
WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01'
-- 直方图帮不上(等值),只能依赖 MCV 或「摊平」假设
WHERE status = 'pending'如果等值条件用到的值偏偏不在 MCV 里,而它实际上是尖峰,估算就会严重低估。此时把该列的统计目标调高,让这个值挤进 MCV,是标准解法。
correlation 如何影响索引扫描的成本
规划器比较「索引扫描」与「顺序扫描」时,random_page_cost 决定每次随机访问的代价,而 correlation 用来修正索引扫描实际上会有多少次随机访问:
correlation ≈ 1(自增主键)
索引扫描命中 1000 行 → 对应的堆页高度集中且连续
规划器按「接近顺序 I/O」估算 → 索引扫描便宜,容易被选中
correlation ≈ 0(随机 UUID)
索引扫描命中 1000 行 → 对应 1000 个分散的堆页,每次都是随机 I/O
规划器按「1000 次随机访问」估算 → 索引扫描昂贵,可能不如顺序扫描-- 对比两张表同名列的 correlation
SELECT tablename, attname, n_distinct, correlation
FROM pg_stats
WHERE schemaname = 'public'
AND attname IN ('id', 'order_no')
ORDER BY tablename, attname; 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 会因为两类操作而恶化:
1. 频繁 UPDATE 造成 HOT / 非 HOT 更新
→ 新版本被写到任意有空位的页,物理顺序被打散
2. 大批量导入时未按排序键写入
→ 数据天然乱序
3. VACUUM FULL / CLUSTER 可以恢复顺序(CLUSTER 后 correlation 接近 1)-- 检查是否值得 CLUSTER
SELECT attname, correlation
FROM pg_stats
WHERE schemaname = 'public' AND tablename = 'orders'
ORDER BY abs(correlation); 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 用采样估算不同值个数,对超大表 + 高基数列尤其不准:
采样 30000 行估算一张 10 亿行表的 user_id 去重个数
→ 采样里出现的大部分 user_id 都只出现一次
→ 经典估算器(基于「单次出现值个数」)在这种极端稀疏场景下偏差很大
→ 有时偏差可达数倍偏差的后果:
- 估小了 →
GROUP BY user_id的组数被低估 → 可能选 HashAggregate 而不是更省内存的其他方案。 - 用于 JOIN 时 → 连接结果集大小估算错误 → 连接算法选错。
官方文档对此的建议很直接:如果 n_distinct 不准导致坏计划,就手工实测后 SET (n_distinct = ...) 钉死。
表达式列没有统计信息
统计是以「列」为单位收集的,函数/表达式的结果没有对应的列统计:
-- 规划器对 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 显示估算行数与实际行数差两个数量级。
-- 1. 先看计划里的估算 vs 实际
EXPLAIN (ANALYZE, BUFFERS, TIMING OFF)
SELECT count(*)
FROM events
WHERE created_at >= '2026-03-01' AND created_at < '2026-04-01'; 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 倍。
-- 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'; 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)诊断结论:
统计看起来是新鲜的(101 个边界 = 100 个桶)
但 100 个桶要覆盖 15 个月的数据 → 每个桶跨约 4.5 天
而查询只要 1 个月 → 落在大约 0.7 个桶内
→ 「桶内均匀分布」假设在这里完全失效
因为该月恰好是促销期,写入量是平时的 10 倍-- 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'; 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 退化的列。
-- 一次性列出所有大表的关键统计指标
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; 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:
逐个判读:
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 且唯一
→ 最理想,无需处理-- 针对体检结果批量提升关键列的统计目标
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')); tablename | attname | buckets
-----------+------------+---------
orders | amount | 500
events | created_at | 1000
(2 rows)注意事项
不要为了「更准」就无脑调大全局 default_statistics_target。它会让所有表、所有列的采样行数一起增大,ANALYZE 时间与 pg_statistic 空间成比例上涨,而收益只集中在少数倾斜列上:
-- 不推荐:全局拉到 1000,每张表的 ANALYZE 都变慢 10 倍
ALTER SYSTEM SET default_statistics_target = 1000;
-- 推荐:只针对真正需要精度的列
ALTER TABLE events ALTER COLUMN created_at SET STATISTICS 1000;改完统计目标必须重新 ANALYZE,否则目标只是「下次统计时的指令」,现有统计仍旧:
-- 错误!只改了目标,没有重新统计,查询仍按旧统计做计划
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)仍然可能高效:
-- correlation 接近 0,但等值查询仍走索引,因为只需定位少量行
EXPLAIN SELECT * FROM orders WHERE order_no = 'ORD-20260301-000123';直方图并不描述 NULL,NULL 值只由 null_frac 反映。条件里带上 IS NULL / IS NOT NULL 时,估算走的是 null_frac 而非直方图:
-- IS NULL 的选择性直接取自 null_frac
WHERE deleted_at IS NULL
-- 若 null_frac = 0.97,规划器认为 97% 的行命中列上缺少 < 运算符时不会有直方图,此时范围估算退化为默认值(通常严重失真),必须改写查询而不能指望调统计:
-- 若该类型无排序运算符,histogram_bounds 为 NULL
SELECT histogram_bounds FROM pg_stats WHERE tablename='t' AND attname='j';
-- 解法:把条件改写为可利用的等价形式,或加表达式索引 + 扩展统计统计目标设为 0 会彻底关闭该列统计,这是「优化」而非「禁用维护」的手段,但也意味着该列任何条件都只能靠默认估算:
-- 适用于从未出现在 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,它做了权限过滤,普通用户只能看到自己有权访问的表:
-- 普通用户会因权限报错
SELECT * FROM pg_statistic; -- 错误!permission denied
-- 正确:pg_stats 对所有用户开放,且自动过滤
SELECT * FROM pg_stats WHERE tablename = 'events';手工 SET (n_distinct = ...) 是一把双刃剑。它压制了采样误差,但如果数据分布后来变了而你忘了更新,估算会长期错误,且不会再被 ANALYZE 修正:
-- 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 才能让规划器看到:
CLUSTER orders USING orders_created_idx;
ANALYZE orders; -- 必须重新分析,correlation 才会更新
SELECT correlation FROM pg_stats WHERE tablename='orders' AND attname='created';最佳实践
把统计体检纳入例行运维,用一条 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;优先提高「排序/范围热点列」的统计目标,因为这类列最依赖直方图精度:
-- 时间列、金额列、分数列通常是范围查询热点
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 形式,让直方图重新生效,这是收益最高、成本最低的优化:
-- 不推荐:函数包裹列,统计与索引双双失效
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 里有没有它:
-- 查询某列的高频值有没有被捕捉
SELECT attname, most_common_vals, most_common_freqs
FROM pg_stats
WHERE tablename = 'orders' AND attname = 'status';
-- 若业务上的热点值不在列表里 → 提高该列统计目标后重新 ANALYZE给会持续增长的高基数列手工校准 n_distinct,避免采样误差影响 GROUP BY 与 JOIN 估算:
SELECT count(DISTINCT user_id) FROM events; -- 实测
ALTER TABLE events ALTER COLUMN user_id SET (n_distinct = 8500000);用表达式统计(下一个主题)代替手工改写复杂表达式条件。若无法改写查询,可让 CREATE STATISTICS 自动为表达式收集单列统计:
CREATE STATISTICS stat_events_month ON date_trunc('month', created_at) FROM events;
ANALYZE events;不要给从不出现在查询条件里的列收集统计,纯属浪费 ANALYZE 时间与 pg_statistic 空间:
ALTER TABLE events ALTER COLUMN raw_payload SET STATISTICS 0;大表首次导入后立即手工 ANALYZE,不要等 autovacuum 排期——第一批查询往往就发生在这段空窗期:
COPY events FROM '/data/events.csv' WITH (FORMAT csv);
ANALYZE events; -- 立即建立统计,避免首查走坏计划在 EXPLAIN 中对比估算与真实行数,把「估算偏差倍数」当成一个可量化指标追踪:
EXPLAIN (ANALYZE, TIMING OFF) SELECT ... ;
-- 关注每行 rows=估算 (actual rows=实际 loops=N)
-- 偏差超过 10 倍即可疑,超过 100 倍基本可断定统计有问题同一列不要既设 n_distinct 又指望它能自动适应数据变化,两者只能选一个;若数据分布会变,就别钉死:
-- 数据分布稳定(如地区代码)→ 可以钉死
ALTER TABLE orders ALTER COLUMN region SET (n_distinct = 34);
-- 数据分布持续变化(如活跃用户数)→ 交给自动估算
ALTER TABLE events ALTER COLUMN user_id RESET (n_distinct);定期重新 ANALYZE 相关性被写入打散的关键列,并评估是否需要 CLUSTER 恢复物理顺序:
SELECT attname, correlation FROM pg_stats
WHERE tablename = 'orders' ORDER BY abs(correlation) LIMIT 5;
-- 若关键热点列 correlation 掉到 0.3 以下且范围查询频繁,考虑 CLUSTER下一节