查询规划器 — 统计信息与代价模型:优化器为什么选错了索引
你建了索引,EXPLAIN 一看,优化器还是在走 Seq
Scan。你排查了索引损坏、检查了数据类型、确认了查询条件是
= 而非 LIKE——最后发现
EXPLAIN ANALYZE 里 “rows=1”
的估计,实际跑出来是 100 万行。
这就是统计信息不准导致灾难性计划的经典场景。优化器不是错了——在它眼中,用索引查 1 行确实比扫全表划算。问题出在它”眼中”的数据分布和真实数据已经完全错位。
PG
的查询优化器是一个基于代价(cost-based)的规划器。它做每一个决策——走
Seq Scan 还是 Index Scan、用 NestLoop 还是 HashJoin、先 join
表 A 还是表
B——都依赖两样东西:统计数据(pg_statistic
系统表)和代价常量(seq_page_cost、random_page_cost
等
GUC)。本文拆解的就是这两样东西在源码中是如何产生、存储、计算,以及出错后怎么诊断。
一、pg_class 和 pg_statistic 里存了什么
优化器在做代价估算之前,首先要回答一个更基本的问题:这个查询会返回多少行?
这个数字叫选择率(selectivity),它决定了所有后续决策。而选择率的计算,完全依赖
pg_class 和 pg_statistic
两个系统表。
pg_class:宏观信息
pg_class
存储每个表(和索引)的基础统计元组:
SELECT relname, relpages, reltuples, relallvisible
FROM pg_class
WHERE relname = 'orders';
```text
| 字段 | 含义 | 优化器用途 |
|------|------|-----------|
| `relpages` | 表的页面数(由 ANALYZE 估算) | Seq Scan 的 I/O 代价:`relpages × seq_page_cost` |
| `reltuples` | 表的行数(由 ANALYZE 估算) | 所有选择率的基数(所有 `rows` 估计都来自这个值) |
| `relallvisible` | 全可见页面数(VM 中的标记) | Index-Only Scan 代价计算:可见页面可跳过 heap fetch |
这三个字段是优化器估算的起点。`relpages` 和 `reltuples` 由 `ANALYZE` 通过采样估算,不是精确值——对大表来说,误差可能显著。
### pg_statistic:每一列的数据画像
`pg_statistic` 存储每一列的数据分布统计,是优化器做选择率估算的核心输入。它的表结构设计得比较特殊——用 "slot" 机制存储不同类型的统计值,避免了一个列只有一个统计表示的限制:
```sql
-- 查看某表某列的统计信息(通过 pg_stats 视图,它是 pg_statistic 的可读封装)
SELECT attname, n_distinct, null_frac, avg_width,
most_common_vals, most_common_freqs,
histogram_bounds, correlation
FROM pg_stats
WHERE tablename = 'orders' AND attname = 'status';底层 pg_statistic 的关键字段:
| 字段 | 含义 |
|---|---|
starelid |
表 OID |
staattnum |
列编号(pg_attribute.attnum) |
stanullfrac |
NULL 值比例(0 到 1) |
stawidth |
非 NULL 值的平均宽度(字节) |
stadistinct |
去重值数量(正数 = 精确估计,负数 = 其绝对值是总行数的比例) |
stakind1 ~ stakind5 |
5 个槽位各自存储的统计类型代码 |
stanumbersN |
对应槽位的 float4[] 数值 |
stavaluesN |
对应槽位的 anyarray 值 |
stakind 的类型代码定义在
src/include/catalog/pg_statistic.h:
#define STATISTIC_KIND_MCV 1 // Most Common Values + 频率
#define STATISTIC_KIND_HISTOGRAM 2 // 等深直方图(equi-depth histogram)
#define STATISTIC_KIND_CORRELATION 3 // 物理行序与逻辑值序的相关系数
#define STATISTIC_KIND_MCELEM 4 // 扩展统计的 MCV(多列)
#define STATISTIC_KIND_DECHIST 5 // 扩展统计的直方图(数组类型)
```text
三个核心统计类型:
**MCV(Most Common Values)**:存储最高频的值及其出现频率。如果查询条件是 `WHERE status = 'active'`,优化器先在 MCV 列表中查找 `'active'`,命中则直接取对应频率;未命中则用直方图估算。
**Histogram(等深直方图)**:将数据按值排序后分成 N 个桶,每个桶包含约等量的行(不是等宽)。存储的是每个桶的上界值。PG 的直方图是等深(equi-depth)不是等宽(equi-width)——这意味着在数据倾斜时,高频值的桶边界更窄,从而保持较好的分辨率。
**Correlation**:物理行序与列值逻辑序的皮尔逊相关系数,范围 $[-1, 1]$。如果值为 $1$ 或 $-1$,说明列值与物理存储顺序高度相关——这意味着 Index Scan 的随机 I/O 代价比随机假设要小,优化器会更倾向选索引扫描。此值由 `analyze.c` 中的 `compute_correlation()` 计算。
`stadistinct` 的负数表示法:当值为 -0.3 时,表示去重值数量约为 `reltuples × 0.3`。这是对"列中不同值数量随行数线性增长"这种情况的优化——当表增长时,不需要重新 ANALYZE 也能大致推算新的去重数。
---
## 二、ANALYZE 的采样流程
ANALYZE 的入口在 `src/backend/commands/analyze.c` 的 `do_analyze_rel()`。不是读全表,而是采样。
### 采样算法
PG 使用的是两阶段采样:
1. **页面采样**:随机选取 `targrows / rows_per_page` 个页面。`targrows` 默认是 `300 × default_statistics_target`(即 30000 行)。如果表有 100 个页面、期望采样 3000 行,则可能会选中约 30 个页面。
2. **行采样(Reservoir Sampling)**:在每个选中页面中,用 Vitter's Algorithm Z 做水库采样(reservoir sampling),保证每一行被选中的概率相等。
```c
// src/backend/commands/analyze.c, acquire_sample_rows() 简化流程
static int
acquire_sample_rows(Relation onerel, int elevel,
HeapTuple *rows, int targrows,
double *totalrows, double *totaldeadrows)
{
// 1. 获取表的总页面数
totalblocks = RelationGetNumberOfBlocks(onerel);
// 2. 计算采样页面数
// targrows 默认 = 300 * default_statistics_target
samplerows = targrows;
// 3. 循环:随机选取一个页面 → 读取所有行 → Reservoir Sampling 选入 rows[]
for (;;) {
targblock = random() % totalblocks; // 随机页面
heapam_scan_analyze_next_block(...); // 读取页面
// 对每一行,Vitter's Algorithm Z
if (rowstoskip < 0)
reservoir_sample_add(row, rows, samplerows);
}
// 4. 估算总行数
*totalrows = liverows * totalblocks / scanned_pages;
*totaldeadrows = deadrows * totalblocks / scanned_pages;
}采样精度问题
默认 default_statistics_target = 100
时,targrows = 30000。对于 1
亿行的表,采样比例仅为 0.03%。这意味着:
- 小表中高频值的频率可能被高估或低估
- 大表中低频值(“长尾”)很可能根本不会出现在样本中
- 数据分布不均匀时(如 Zipf 分布),少量采样无法捕捉完整分布
当表的行数超过
(300 × default_statistics_target) 时,ANALYZE
采样比例就已经低于 1/default_statistics_target
了。对于 PB 级大表,这个比例可能低至百万分之一。
统计信息写入
采样完成后,compute_stats()
计算每种统计类型(MCV、Histogram、Correlation)的值,然后通过
update_attstats() 写入
pg_statistic。写入是在一个事务内完成的——ANALYZE
的统计更新是原子的,要么全更新,要么全不更新(比如 ANALYZE
被 cancel)。
三、选择率估算:clauselist_selectivity 的调用链
有了统计信息,优化器下一步就是用它们估算每个查询条件的选择率。入口是
clauselist_selectivity()。
调用链全景
clauselist_selectivity(root, clauses, ...) // clausesel.c
→ clause_selectivity(root, clause, ...) // clausesel.c
→ restriction_selectivity(root, opno, args, ...) // plancat.c
→ OidFunctionCall4(oprrest, ...) // 调用 pg_operator.oprrest 函数
→ eqsel() / scalarineqsel() / neqsel() / ... // selfuncs.c
→ 读取 pg_statistic 的 MCV / histogram
→ 计算选择率
clauselist_selectivity() 对 WHERE
子句中的每个条件调用
clause_selectivity(),然后假设各条件之间独立,将选择率相乘。这个独立性假设是许多估算灾难的根源。
各类 xxxsel 函数的估算逻辑
以 eqsel()(等值条件选择率)为例,在
src/backend/utils/adt/selfuncs.c 中:
// selfuncs.c, eqsel() 的简化逻辑
Datum eqsel(PG_FUNCTION_ARGS) {
// 1. 查 MCV 列表:如果查询常量在 MCV 中,直接取对应频率
if (get_attstatsslot(&sslot, statsTuple,
STATISTIC_KIND_MCV, ...)) {
// mcv_match: 查找常量在 MCV 中的位置
selec = sslot.numbers[mcv_match]; // 该值的频率
}
// 2. 不在 MCV 中:使用直方图估算
else if (get_attstatsslot(&sslot, statsTuple,
STATISTIC_KIND_HISTOGRAM, ...)) {
selec = ineq_histogram_selectivity(...);
}
// 3. 只有 MCV 没有直方图:对非 MCV 值使用均匀假设
else {
selec = (1.0 - sum_of_mcv_freqs) /
(stadistinct - mcv_count);
}
// 4. 最后:乘以 (1 - stanullfrac) 排除 NULL
selec *= (1.0 - stanullfrac);
}
```text
`scalarineqsel()`(范围条件:`<`, `>`, `<=`, `>=`)的处理更复杂:
1. 先查 MCV,累加满足条件的 MCV 值的频率
2. 剩余未覆盖的行数部分,通过直方图插值估算
3. 直方图假设桶内均匀分布——在桶内做线性插值
```c
// selfuncs.c, ineq_histogram_selectivity() 的简化逻辑
// hist: [10, 20, 30, 40, 50] → 4 个桶
// 查询: WHERE x < 25
// → 桶 1 [10, 20] 完全在范围内 → +1/4
// → 桶 2 [20, 30] 部分在范围内 → (25-20)/(30-20) * 1/4 = 0.5 * 1/4
// → 总选择率 = 0.25 + 0.125 = 0.375scalarltsel() / scalargtsel()
还有一个特殊逻辑:如果条件值与直方图边界重合(如
WHERE id > max_id),选择率直接返回 0 或
1。这是由 get_actual_variable_range()
实现的——优化器会从索引中读取实际的 min/max
值,避免直方图边界不准。
独立性与多列陷阱
优化器对每个条件分别估算选择率,然后相乘。当列之间存在相关性时——例如
city = 'Beijing' AND district = 'Haidian',知道
city 就很大程度上知道
district——独立假设会严重低估选择率,导致优化器选择
Nested Loop(以为只返回几行)而非 Hash
Join(实际返回数百万行)。扩展统计(第六节)正是为解决这个问题而设计的。
四、代价常量:从选择率到执行成本
选择率算出后,代价模型将其转化为具体的成本数字。代价由两大部分组成:
代价常量的定义与物理意义
PG 的代价单位是抽象的 “1 个 seq page
读取的成本”。所有代价常量定义在
src/backend/optimizer/path/costsize.c 中:
// costsize.c,关键代价常量(PG 17)
double seq_page_cost = DEFAULT_SEQ_PAGE_COST; // 1.0
double random_page_cost = DEFAULT_RANDOM_PAGE_COST; // 4.0
double cpu_tuple_cost = DEFAULT_CPU_TUPLE_COST; // 0.01
double cpu_index_tuple_cost = DEFAULT_CPU_INDEX_TUPLE_COST; // 0.005
double cpu_operator_cost = DEFAULT_CPU_OPERATOR_COST; // 0.0025
double parallel_setup_cost = DEFAULT_PARALLEL_SETUP_COST; // 1000.0
double parallel_tuple_cost = DEFAULT_PARALLEL_TUPLE_COST; // 0.1
```text
| 常量 | 物理意义 |
|------|---------|
| `seq_page_cost` | 在顺序 scan 中读一个页面的代价。设为 1.0 作为基准。 |
| `random_page_cost` | 在随机位置读一个页面的代价。HDD 上寻道 + 旋转延迟约是顺序读的 4 倍;NVMe SSD 上这个比率接近 1.0。 |
| `cpu_tuple_cost` | 处理一行(检查可见性、解包 HeapTuple)的 CPU 代价 |
| `cpu_index_tuple_cost` | 处理一条索引项的 CPU 代价(比 tuple 小,因为只在 index page 中操作) |
| `cpu_operator_cost` | 一次 `=`、`<`、`>` 等运算符的 CPU 代价 |
| `effective_cache_size` | 优化器对总缓存(shared_buffers + OS page cache)的估算,用于判断 Index Scan 时有多少索引页面可能已在缓存中 |
### Seq Scan 的代价公式
`cost_seqscan()` 中对一个表做顺序扫描的代价:
$$C_{\text{seq}} = N_{\text{pages}} \times \text{seq\_page\_cost} + N_{\text{tuples}} \times \text{cpu\_tuple\_cost}$$
还有启动成本(startup cost),如 `cost_seqscan()` 中设置的 `startup_cost = 0`(Seq Scan 可以马上返回第一行)。
### Index Scan 的代价公式
`cost_index()` 更复杂,分三部分:
**索引页面访问**:假设 B-Tree 中满足条件的 `N_{\text{index\_tuples}}` 个索引项分布在 `N_{\text{index\_pages}}` 个索引页面中。使用 Mackert-Lohman 公式估算需要访问多少个不同的索引页面。
**表页面访问**:每个满足条件的索引项指向一个 heap tuple。关键假设是这些 heap 页面的访问顺序近似随机(除非 correlation 接近 1)。`cost_index()` 使用以下公式估算随机访问的页面数:
$$N_{\text{heap\_pages}} = \min(N_{\text{total\_heap\_pages}}, \text{indexSelectivity} \times N_{\text{total\_heap\_pages}})$$
其中 indexSelectivity 是索引扫描的选择率。对于小选择率(如 0.001),`cost_index()` 使用更复杂的近似模型——当选择率很小时,即使随机访问,一个页面也可能被多次命中(同一页面可能包含多个匹配的 tuple),所以实际需要读取的页面数小于 `selectivity × total_pages`。这就是 Mackert-Lohman 公式的作用。
**CPU 代价**:
$$C_{\text{index}} = C_{\text{startup}} + C_{\text{index\_pages}} \times \text{random\_page\_cost} + C_{\text{heap\_pages}} \times \text{random\_page\_cost} + N_{\text{index\_tuples}} \times \text{cpu\_index\_tuple\_cost} + N_{\text{output\_tuples}} \times \text{cpu\_tuple\_cost} + N_{\text{qual\_ops}} \times \text{cpu\_operator\_cost}$$
### random_page_cost 在 SSD 上的问题
默认 `random_page_cost = 4.0` 来自 HDD 时代——随机 I/O 的寻道和旋转延迟使其约为顺序 I/O 的 4 倍。但 NVMe SSD 没有机械寻道,随机和顺序读的延迟比接近 1.0 ~ 1.5。
当 `random_page_cost` 保持 4.0 时,Index Scan 的代价被大幅高估。优化器会偏向 Seq Scan,即使索引扫描实际快得多。很多人发现 "EXPLAIN 显示 Seq Scan,但加 `SET enable_seqscan = off` 后 Index Scan 快 10 倍"——根因就是 `random_page_cost` 没适配存储。
**调整建议**:在 NVMe SSD 上设为 1.0 ~ 1.5;SATA SSD 上设为 1.5 ~ 2.5;HDD RAID 上保持 3.0 ~ 4.0。可以用 `pg_test_fsync` 测量实际随机/顺序 I/O 比率。
### effective_cache_size 的作用
`effective_cache_size` 不分配任何内存。它在 `cost_index()` 中影响一个关键判断:**一个随机访问的 heap 页面是否已在 OS cache 中**:
$$P_{\text{cached}} = \min\left(1, \frac{\text{effective\_cache\_size}}{N_{\text{index\_pages}} + N_{\text{heap\_pages}}}\right)$$
缓存的页面用 `seq_page_cost` 计代价,而非 `random_page_cost`。如果 `effective_cache_size` 设得太小(比如默认的 4GB,而实际 OS page cache 有 128GB),优化器会高估 Index Scan 的 I/O 代价,错误地选择 Seq Scan。
---
## 五、扩展统计(CREATE STATISTICS):解决多列相关
`CREATE STATISTICS` 是 PG 10 引入的机制,用于解决"优化器假设列之间独立"的问题。
### 三种扩展统计类型
```sql
-- 创建多列统计对象
CREATE STATISTICS s1 (dependencies) ON a, b FROM t;
CREATE STATISTICS s2 (ndistinct) ON a, b FROM t;
CREATE STATISTICS s3 (mcv) ON a, b FROM t;dependencies(函数依赖):量化
a 的值对 b 的值的决定程度。例如
zip 对 city
有强依赖。dependencies 的类型被编译进
pg_statistic_ext_data,计算由
statext_dependencies_build()
完成(src/backend/statistics/dependencies.c)。依赖度的计算方式:统计每个
a 值对应的 b 值的去重数,如果每个
a 恰好对应一个 b,则依赖度为
1.0。优化器使用该值修正独立假设下的选择率估算。
ndistinct(多列去重数):GROUP BY a, b
的组数。单列去重数相乘会高估组数(如 zip 和
city 的乘积远大于实际 (zip, city)
的组数)。ndistinct
统计直接给出正确的多列组合去重数,优化器在做
GROUP BY 估算时用它替代单列去重的乘积。
mcv(多列 MCV):组合值
(a, b) 的频率列表。当查询条件同时涉及多列时(如
WHERE a = 1 AND b = 2),优化器可以在 MCV
中直接查找组合频率,而不需要调用独立性假设。
如何判断是否需要扩展统计
-- 查看某个查询中哪些列之间有相关性
-- EXPLAIN 中 "rows" 与 "actual rows" 差异大的列对,通常需要扩展统计
-- 查看已有扩展统计
SELECT stxname, stxkeys, stxddependencies, stxndistinct, stxdmcv
FROM pg_statistic_ext
JOIN pg_statistic_ext_data ON pg_statistic_ext.oid = pg_statistic_ext_data.stxoid
WHERE stxrelid = 'orders'::regclass;
```text
有用扩展统计的典型场景:
- 城市和邮政编码
- 产品类别和子类别
- 订单创建日期和发货日期(虽然不是函数依赖,但高度相关)
- `WHERE a = x AND b = y` 类型的多条件查询,且实际结果集远大于或远小于估算
创建扩展统计后需要执行 `ANALYZE` 才会生效。统计信息存储在 `pg_statistic_ext_data` 中,与 `pg_statistic_ext` 分离(PG 12 的 catalog 分离改进)。
---
## 六、统计信息不准:一个糟糕计划的解剖
下面构造一个生产中常见的场景:统计信息严重滞后导致优化器选错计划。
### 场景构造
```sql
-- 1. 建表,插入 100 万行均匀数据
CREATE TABLE events (
id SERIAL PRIMARY KEY,
event_type TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
INSERT INTO events (event_type)
SELECT 'view' FROM generate_series(1, 990000);
-- 创建索引
CREATE INDEX idx_event_type ON events(event_type);
ANALYZE events;
-- 此时统计信息:'view' 占 ~99%, 选择率估值正确
-- EXPLAIN 会正确选择 Seq Scan(几乎所有行都要返回)
EXPLAIN SELECT * FROM events WHERE event_type = 'view';
-- 预期:Seq Scan on events (rows=990000)
-- 2. 插入 10000 行 'purchase'(占总数据不到 1%)
INSERT INTO events (event_type)
SELECT 'purchase' FROM generate_series(1, 10000);
-- 但没有 ANALYZE!
-- pg_statistic 中只有 'view' 的统计
-- 优化器不知道有 'purchase' 这个值
-- 3. 查询 'purchase' 事件
EXPLAIN SELECT * FROM events WHERE event_type = 'purchase';
-- 优化器估算:没有 'purchase' 的 MCV → 用直方图或默认假设
-- 结果可能估算为 rows=1 或一个很小的值
-- → 选择了 Index Scan(以为只返回很少的行)
-- 实际返回:10000 行
-- 此时:10000 行的 Index Scan + heap fetch 可能比 Seq Scan 慢得多解释
eqsel() 查 MCV 时找不到
'purchase',会走到非 MCV
估算路径。在没有直方图的情况下(如果列中只有
'view' 一个值、去重数小于 MCV 槽位数,PG 只存
MCV 不存直方图),非 MCV 值的选择率估算为
(1.0 - sum_mcv_freqs) / (stadistinct - mcv_count)。如果
stadistinct = 1(因为 ANALYZE 时只有
'view'),这个公式产生除以 0,PG 会
clamp 到一个很小的默认值。于是优化器以为
'purchase' 只返回 1 行,选了 Index
Scan——而实际有 10000 行。10000 次随机读 vs
一次全表顺序扫描,前者可能慢一个数量级。
优化器为什么”选错”了
优化器没有错——它基于现有统计信息做出了完全合理的决策。错的是统计信息与数据现实之间的偏差。这就是为什么诊断要关注”统计信息准不准”而不是”优化器是不是有 bug”。
七、排查:统计信息漂移的诊断
统计信息漂移(stats drift)指的是数据分布已发生变化,但
pg_statistic
中的统计没有跟上。诊断按以下顺序进行。
第一步:检查 n_mod_since_analyze 和 last_analyze
SELECT
schemaname || '.' || relname AS table_name,
n_live_tup,
n_mod_since_analyze,
last_analyze,
last_autoanalyze,
ROUND(100.0 * n_mod_since_analyze / NULLIF(n_live_tup, 0), 2) AS pct_modified
FROM pg_stat_user_tables
WHERE n_mod_since_analyze > 0
ORDER BY n_mod_since_analyze DESC
LIMIT 20;
```text
| 信号 | 含义 | 建议 |
|------|------|------|
| `n_mod_since_analyze > n_live_tup × 0.1` | 超过 10% 数据已变更 | 手动 `ANALYZE` |
| `last_analyze IS NULL` 且表非空 | 该表从未被 ANALYZE | 立即 `ANALYZE` |
| `last_autoanalyze` 是很久以前 | autovacuum analyze 可能未触发 | 检查 autovacuum 配置 |
### 第二步:检查 autovacuum analyze 的触发条件
```sql
-- 检查表的 autovacuum 相关设置
SELECT
schemaname || '.' || relname AS table_name,
reloptions
FROM pg_class
WHERE relname = 'your_table' AND reloptions IS NOT NULL;
-- 全局阈值
SHOW autovacuum_analyze_scale_factor; -- 默认 0.1
SHOW autovacuum_analyze_threshold; -- 默认 50触发 auto-analyze 的条件:
n_mod_since_analyze > autovacuum_analyze_threshold + autovacuum_analyze_scale_factor × n_live_tup
即默认:变更行数 > 50 + 0.1 × 行数。最少 50 行或 10%。
第三步:insert-only 表的特殊问题
对于只插入不更新的表(如日志表、事件表),如果
autovacuum_analyze_scale_factor = 0.1(默认),auto-analyze
必须在表中插入 10% 的新行后才触发(相对于 ANALYZE
时已有的数据量)。在一个持续增长的 1
亿行日志表中,这需要插入 1000 万行才触发一次
ANALYZE——可能永远不会达到。结果是新插入的数据完全没有统计信息。
诊断 insert-only 表:
SELECT
schemaname || '.' || relname AS table_name,
n_live_tup,
n_tup_ins,
n_tup_upd,
n_tup_del,
last_autoanalyze,
CASE
WHEN n_tup_upd + n_tup_del = 0 AND last_autoanalyze IS NOT NULL
AND last_autoanalyze < now() - INTERVAL '1 day'
THEN 'insert-only table, may need more frequent ANALYZE'
END AS warning
FROM pg_stat_user_tables
WHERE n_live_tup > 100000
AND n_tup_ins > n_live_tup * 0.1;
```text
**解决方案**:对 insert-only 表降低 `autovacuum_analyze_scale_factor`,或者使用 cron 定期执行 `ANALYZE`:
```sql
ALTER TABLE events SET (autovacuum_analyze_scale_factor = 0.01);第四步:检查 default_statistics_target 是否过低
SHOW default_statistics_target;
-- 查看具体列的 settings
SELECT attname, attstattarget
FROM pg_attribute
WHERE attrelid = 'your_table'::regclass AND attstattarget >= 0;
```text
`default_statistics_target` 直接影响:
- MCV 列表的长度
- 直方图的桶数
默认值 100 对大多数场景足够。但对具有以下特征的列,应该调高:
- 去重值数量巨大(如用户 ID、订单号)
- 分布高度不均匀(如城市列中 90% 是 "Beijing",需要更多桶来区分剩余城市)
- 查询常用作过滤条件且 `EXPLAIN` 中 `rows` 估计偏差超过 10 倍
调高时注意:每个 `target` 值增加约 300 个采样行和相应的存储开销。对于 1000 列的表,将全局 `default_statistics_target` 设到 1000 会导致 ANALYZE 耗时和 `pg_statistic` 大小线性增长。
### 第五步:对比 EXPLAIN 估算与实际执行
这是最直接的诊断手段:
```sql
EXPLAIN (ANALYZE, BUFFERS, TIMING)
SELECT * FROM events WHERE event_type = 'purchase';关注输出中 rows(估算行数)和
actual rows(实际行数)的差异。如果偏差超过
5-10 倍,统计信息值得怀疑。用
SET enable_seqscan = off
强制走索引,对比实际执行时间(ANALYZE
能看到真实时间),判断优化器的选择是否真的是次优的。
八、auto_explain + auto_analyze 不触发的根因排查
auto_explain 和 auto_analyze
是两个独立机制,但常被一起配置。排查顺序:
auto_explain 配置检查
-- 确保 auto_explain 在 shared_preload_libraries 中
-- 检查 pg_settings 中的配置
SELECT name, setting, source
FROM pg_settings
WHERE name LIKE 'auto_explain%';
```text
`auto_explain` 必须通过 `shared_preload_libraries` 加载并重启才能生效。如果只通过 `SET` 或 `ALTER SYSTEM SET` 改了参数但没有加到 `shared_preload_libraries`,`auto_explain` 模块根本没有加载,日志中不会有任何 plan 输出。
核查:
```bash
# 检查 postgresql.conf
grep shared_preload_libraries /path/to/postgresql.conf
# 需要包含 auto_explain
# shared_preload_libraries = 'pg_stat_statements, auto_explain'auto_analyze 不触发的常见根因
根因 A:autovacuum_analyze_scale_factor 过大
前面已讨论。默认 0.1 意味着只有 10% 的数据变更才触发 auto-analyze。insert-only 表永远达不到。
根因 B:autovacuum 本身被禁用
-- 全局检查
SHOW autovacuum;
-- 表级检查
SELECT relname, reloptions
FROM pg_class
WHERE relname = 'your_table' AND reloptions::text LIKE '%autovacuum%';
```text
**根因 C:autovacuum worker 不足或全部繁忙**
```sql
-- 查看当前运行的 autovacuum worker
SELECT pid, query, state, backend_start
FROM pg_stat_activity
WHERE backend_type = 'autovacuum worker';
-- 最大 autovacuum worker 数
SHOW autovacuum_max_workers; -- 默认 3如果只有 3 个 worker 但有 50 张表需要处理,低优先级的表可能永远排不到。
根因 D:autovacuum 被限速过狠
SHOW autovacuum_vacuum_cost_delay; -- 默认 2ms
SHOW autovacuum_vacuum_cost_limit; -- 默认 200
```text
对于 auto-analyze,限速影响较小(ANALYZE 只是采样读,不来全表)。但极端限速仍然可能导致 worker 在某个表上停留过久。
---
## 九、临时表与统计信息的特殊行为
临时表(`CREATE TEMP TABLE`)的统计信息行为与普通表有本质差异:
### 临时表不受 autovacuum 管理
Autovacuum 只处理持久表(catalog 中 `relpersistence = 'p'`)。临时表的 `relpersistence = 't'`,autovacuum launcher 在遍历表列表时会跳过它们。这意味着:
- 临时表不会自动 ANALYZE
- 临时表不会自动 VACUUM
- `pg_stat_user_tables` 中**不会出现**临时表(临时表只在自己的 session 的 `pg_stat_user_tables` 中可见,对其他 session 不可见,也不会被 autovacuum 考虑)
### 临时表的 ANALYZE 必须显式执行
```sql
CREATE TEMP TABLE tmp_data AS
SELECT * FROM generate_series(1, 1000000) AS id;
-- 此时 pg_class 中 reltuples = -1(未知)
-- 优化器使用默认假设(reltuples = 1000 或类似硬编码值)
-- 必须手动执行
ANALYZE tmp_data;
-- 此时 pg_class 和 pg_statistic 才有数据
-- 注意:pg_statistic 中的统计只在创建了临时表的那个 session 中可见没有统计信息的临时表的行为
当 reltuples = -1(从未
ANALYZE)时,costsize.c 中的
set_baserel_size_estimates()
会触发逻辑去通过其他方式估算(如检查是否有 WHERE
条件配合索引信息)。如果完全无法估算,优化器使用硬编码的行数估计(通常是
BLCKSZ / (sizeof(int) × 2)
级别的小数字)。这会导致优化器选择 Nested Loop 或 Index
Scan(以为表很小),而实际临时表可能有百万行。
排查临时表的统计问题:
SELECT relname, reltuples, relpages
FROM pg_class
WHERE relpersistence = 't';
```text
如果 `reltuples = -1`,必须 `ANALYZE`。
---
## 十、统计信息诊断 SQL 工具箱
以下查询可以组合成一个诊断脚本:
```sql
-- 1. 哪些表的统计信息可能过期
SELECT
schemaname || '.' || relname AS table_name,
n_live_tup,
n_dead_tup,
n_mod_since_analyze,
last_analyze,
last_autoanalyze,
CASE
WHEN last_analyze IS NULL THEN 'NEVER_ANALYZED'
WHEN n_mod_since_analyze > n_live_tup * 0.1 THEN 'STALE'
WHEN last_analyze < now() - INTERVAL '7 days'
AND n_live_tup > 100000 THEN 'OLD'
ELSE 'OK'
END AS stats_status
FROM pg_stat_user_tables
ORDER BY n_mod_since_analyze DESC NULLS LAST;
-- 2. 哪些 EXPLAIN 估算与实际行数偏差大(需要 pg_stat_statements)
-- 如果安装了 pg_stat_statements 且开启了 track_planning:
SELECT
queryid,
LEFT(query, 80) AS query_preview,
calls,
mean_exec_time,
ROUND(shared_blks_read * 8192.0 / 1048576, 2) AS mb_read
FROM pg_stat_statements
WHERE mean_exec_time > 100 -- ms
ORDER BY mean_exec_time DESC
LIMIT 20;
-- 3. 检查是否有列从未 ANALYZE
SELECT
c.relname AS table_name,
a.attname AS col_name,
coalesce(s.stanullfrac, -1) AS null_frac
FROM pg_class c
JOIN pg_attribute a ON a.attrelid = c.oid
LEFT JOIN pg_statistic s ON s.starelid = c.oid AND s.staattnum = a.attnum
WHERE c.relkind = 'r'
AND a.attnum > 0
AND NOT a.attisdropped
AND s.starelid IS NULL
AND EXISTS (SELECT 1 FROM pg_stat_user_tables t WHERE t.relid = c.oid AND t.n_live_tup > 0);
-- 4. 哪些表有 autovacuum 设置覆盖
SELECT
schemaname || '.' || relname AS table_name,
reloptions
FROM pg_class
WHERE relkind = 'r' AND reloptions IS NOT NULL;
-- 5. 扩展统计对象列表
SELECT
stxnamespace::regnamespace::text AS schema,
stxrelid::regclass::text AS table_name,
stxname,
stxkeys,
stxkind
FROM pg_statistic_ext
ORDER BY stxnamespace, stxrelid, stxname;十一、关键要点
优化器的所有决策都依赖统计信息:
pg_class提供行数和页面数,pg_statistic提供每一列的数据分布(MCV、Histogram、Correlation)。统计数据不准,计划就不可能对。ANALYZE 是采样而非全量统计:默认采样约 30000 行,对大表来说精度有限。增大
default_statistics_target可以提高 MCV 列表和直方图的分辨率,但会增加 ANALYZE 的时间和pg_statistic的存储开销。选择率估算假设列之间独立:当列存在相关性时,估算可能差几个数量级。
CREATE STATISTICS填补了这个差距,但需要显式创建。代价常量需要适配硬件:
random_page_cost = 4.0在 NVMe SSD 上严重高估随机 I/O 代价,是许多人发现 “禁用 Seq Scan 快很多” 的根因。effective_cache_size不影响实际内存分配,但在 Index Scan vs Seq Scan 的决策中至关重要。统计信息漂移是沉默杀手:数据在变,统计不更新,计划越来越差。
n_mod_since_analyze是最直接的信号。insert-only 表需要特别注意——默认的autovacuum_analyze_scale_factor = 0.1对它们几乎不工作。临时表的统计信息是 session-local 的:autovacuum 不管临时表,必须手动 ANALYZE。未 ANALYZE 的临时表可能触发灾难性的计划选择。
上一章:查询解析与重写 下一章:查询规划器 — Join 顺序与路径生成
参考资料
源码(PG 17)
src/backend/optimizer/path/costsize.c:代价估算函数(cost_seqscan(),cost_index(),cost_nestloop(),cost_hashjoin(),cost_mergejoin())src/backend/optimizer/path/clausesel.c:选择率估算入口(clauselist_selectivity(),clause_selectivity())src/backend/utils/adt/selfuncs.c:各操作符的选择率函数(eqsel(),scalarineqsel(),neqsel()等)src/backend/commands/analyze.c:ANALYZE 实现(do_analyze_rel(),acquire_sample_rows(),compute_stats())src/include/catalog/pg_statistic.h:pg_statistic的结构定义和STATISTIC_KIND_*常量src/backend/statistics/:扩展统计模块(dependencies.c,mcv.c,mvdistinct.c,extended_stats.c)
官方文档
- PostgreSQL Documentation, Chapter 14: Performance
Tips(
random_page_cost调优建议) - PostgreSQL Documentation, Section 14.2: Statistics Used by the Planner
- PostgreSQL Documentation, Section 14.4: Planner Cost Constants
- PostgreSQL Documentation, Chapter 68: How the Planner Uses Statistics
- PostgreSQL Documentation,
CREATE STATISTICSreference page
论文
- Selinger, P. G. et al. Access Path Selection in a Relational Database Management System. SIGMOD 1979. — System R 代价模型的学术起源,PG 的代价模型直接继承自此论文。
- Mackert, L. F. & Lohman, G. M. R Optimizer
Validation and Performance Evaluation for Local Queries*.
SIGMOD 1986. — PG
cost_index()中使用的 Mackert-Lohman 公式的论文来源。 - Vitter, J. S. Random Sampling with a Reservoir. ACM TOMS, 1985. — PG ANALYZE 使用的 Reservoir Sampling 算法(Algorithm Z)。
工具与实验
pg_test_fsync:测量文件系统的 fsync 和 I/O 性能,帮助校准random_page_cost和wal_sync_method。pg_stat_statements:跟踪查询统计信息,配合EXPLAIN (ANALYZE)定位估算偏差大的查询。auto_explain:自动记录超过阈值的查询及其执行计划,帮助捕获间歇性的糟糕计划。
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【PG 内核】查询规划器 — Join 顺序与路径生成:优化器如何选中 Nested Loop
拆解 PostgreSQL 查询优化器的路径生成:make_one_rel() 从基表访问到 Join 路径的完整流程、四种扫描路径 (SeqScan/IndexScan/IndexOnlyScan/BitmapScan) 的创建条件、三种 Join 方式 (NestLoop/HashJoin/MergeJoin) 的代价比较与选择逻辑、动态规划到 GEQO 遗传算法的切换条件 (geqo_threshold)、并行路径的生成机制。配 EXPLAIN (ANALYZE, BUFFERS) 输出与 planner 内部决策的逐项对照实验。
【PG 内核】性能异常调查方法论:从现象到内核根因的五层调查链
不是工具箱罗列,而是一条按顺序推进的调查链:从 pg_stat_statements 定位可疑 queryid,到 EXPLAIN (ANALYZE, BUFFERS) 解剖执行计划,到 pg_stat_activity + wait_event 诊断等待类型,到 pg_locks + pg_blocking_pids() 追踪锁等待树,最后用 OS 层工具(iostat/perf/bpftrace)确认物理瓶颈。覆盖三个特殊场景:计划缓存的快慢切换、CPU 100% 无慢查询的 LWLock 自旋根因、命中率 99% 但 IO 打满的统计骗局。
【PG 内核】配置陷阱与生产最佳实践:11 个最危险的 GUC 和它们的正确设置
逐一拆解 11 个最容易被误解和配错的 PostgreSQL GUC 参数:shared_buffers 的 double buffering 反噬、work_mem 作为'每个操作'而非'每个查询'的内存炸弹、effective_cache_size 和 random_page_cost 如何误导优化器走向灾难计划、fsync=off 和 synchronous_commit=off 的数据丢失边界、huge_pages 在容器中的静默退化、maintenance_work_mem 不足导致 VACUUM 瘫痪、idle_in_transaction_session_timeout 为什么必须设、log_lock_waits 与 deadlock_timeout 的联动、以及 log_min_duration_statement 与 auto_explain 的日志洪水叠加。每条配查验 SQL 和 shell 命令——不是'设成 X 就好了',而是'通过什么视图和日志确认当前设置有问题'。
【PG 内核】PostgreSQL 内核机制深度拆解
从进程模型到磁盘页面、从 MVCC 到流复制——对 PostgreSQL 内核做完整的源码级拆解。不止步于源码分析:26 篇中 6 篇是运维实战——经典故障的根因与排查路径、性能调查的五层工具链、配置陷阱与恢复边界。面向想读懂 PG 内核源码、在生产环境排查过问题、准备给 PG 贡献代码的工程师。