土法炼钢兴趣小组的算法知识备份

【PG 内核】查询规划器 — 统计信息与代价模型:优化器为什么选错了索引

文章导航

分类入口
databasekernel
标签入口
#postgresql#pg-kernel#query-planner#statistics#cost-model#selectivity#analyze#histogram#mcv#stats-drift#create-statistics#random-page-cost#explain

目录

查询规划器 — 统计信息与代价模型:优化器为什么选错了索引

你建了索引,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_costrandom_page_cost 等 GUC)。本文拆解的就是这两样东西在源码中是如何产生、存储、计算,以及出错后怎么诊断。


一、pg_class 和 pg_statistic 里存了什么

优化器在做代价估算之前,首先要回答一个更基本的问题:这个查询会返回多少行? 这个数字叫选择率(selectivity),它决定了所有后续决策。而选择率的计算,完全依赖 pg_classpg_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%。这意味着:

当表的行数超过 (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.375

scalarltsel() / 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 的值的决定程度。例如 zipcity 有强依赖。dependencies 的类型被编译进 pg_statistic_ext_data,计算由 statext_dependencies_build() 完成(src/backend/statistics/dependencies.c)。依赖度的计算方式:统计每个 a 值对应的 b 值的去重数,如果每个 a 恰好对应一个 b,则依赖度为 1.0。优化器使用该值修正独立假设下的选择率估算。

ndistinct(多列去重数)GROUP BY a, b 的组数。单列去重数相乘会高估组数(如 zipcity 的乘积远大于实际 (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_explainauto_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;

十一、关键要点

  1. 优化器的所有决策都依赖统计信息pg_class 提供行数和页面数,pg_statistic 提供每一列的数据分布(MCV、Histogram、Correlation)。统计数据不准,计划就不可能对。

  2. ANALYZE 是采样而非全量统计:默认采样约 30000 行,对大表来说精度有限。增大 default_statistics_target 可以提高 MCV 列表和直方图的分辨率,但会增加 ANALYZE 的时间和 pg_statistic 的存储开销。

  3. 选择率估算假设列之间独立:当列存在相关性时,估算可能差几个数量级。CREATE STATISTICS 填补了这个差距,但需要显式创建。

  4. 代价常量需要适配硬件random_page_cost = 4.0 在 NVMe SSD 上严重高估随机 I/O 代价,是许多人发现 “禁用 Seq Scan 快很多” 的根因。effective_cache_size 不影响实际内存分配,但在 Index Scan vs Seq Scan 的决策中至关重要。

  5. 统计信息漂移是沉默杀手:数据在变,统计不更新,计划越来越差。n_mod_since_analyze 是最直接的信号。insert-only 表需要特别注意——默认的 autovacuum_analyze_scale_factor = 0.1 对它们几乎不工作。

  6. 临时表的统计信息是 session-local 的:autovacuum 不管临时表,必须手动 ANALYZE。未 ANALYZE 的临时表可能触发灾难性的计划选择。

上一章:查询解析与重写 下一章:查询规划器 — Join 顺序与路径生成


参考资料

源码(PG 17)

官方文档

论文

工具与实验

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-06-16 · database / kernel

【PG 内核】查询规划器 — Join 顺序与路径生成:优化器如何选中 Nested Loop

拆解 PostgreSQL 查询优化器的路径生成:make_one_rel() 从基表访问到 Join 路径的完整流程、四种扫描路径 (SeqScan/IndexScan/IndexOnlyScan/BitmapScan) 的创建条件、三种 Join 方式 (NestLoop/HashJoin/MergeJoin) 的代价比较与选择逻辑、动态规划到 GEQO 遗传算法的切换条件 (geqo_threshold)、并行路径的生成机制。配 EXPLAIN (ANALYZE, BUFFERS) 输出与 planner 内部决策的逐项对照实验。

2026-06-16 · database / kernel

【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 打满的统计骗局。

2026-06-16 · database / kernel

【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 就好了',而是'通过什么视图和日志确认当前设置有问题'。

2026-06-16 · database / kernel

【PG 内核】PostgreSQL 内核机制深度拆解

从进程模型到磁盘页面、从 MVCC 到流复制——对 PostgreSQL 内核做完整的源码级拆解。不止步于源码分析:26 篇中 6 篇是运维实战——经典故障的根因与排查路径、性能调查的五层工具链、配置陷阱与恢复边界。面向想读懂 PG 内核源码、在生产环境排查过问题、准备给 PG 贡献代码的工程师。


By .