PostgreSQL/openGauss pg_stats 视图从入门到精通:统计信息、执行计划与慢 SQL 优化实战

PostgreSQL/openGauss pg_stats 视图从入门到精通:统计信息、执行计划与慢 SQL 优化实战

  • [PostgreSQL/openGauss pg_stats 视图从入门到精通:统计信息、执行计划与慢 SQL 优化实战](#PostgreSQL/openGauss pg_stats 视图从入门到精通:统计信息、执行计划与慢 SQL 优化实战)
  • [八、pg_stats 核心字段详解](#八、pg_stats 核心字段详解)
    • [1. schemaname](#1. schemaname)
    • [2. tablename](#2. tablename)
    • [3. attname](#3. attname)
    • [4. null_frac:NULL 值比例](#4. null_frac:NULL 值比例)
      • [优化器如何使用 null_frac?](#优化器如何使用 null_frac?)
    • [5. n_distinct:不同值数量估算](#5. n_distinct:不同值数量估算)
      • [情况一:n_distinct 为正数](#情况一:n_distinct 为正数)
      • [情况二:n_distinct 为负数](#情况二:n_distinct 为负数)
    • [6. most_common_vals:最常见的值](#6. most_common_vals:最常见的值)
    • [7. most_common_freqs:最常见值的频率](#7. most_common_freqs:最常见值的频率)
    • [8. histogram_bounds:直方图边界](#8. histogram_bounds:直方图边界)
      • [为什么 salary 的直方图很有意义?](#为什么 salary 的直方图很有意义?)
    • [9. correlation:相关性](#9. correlation:相关性)
  • 九、内容逐列分析
  • [十、通过 EXPLAIN 理解 pg_stats 如何影响执行计划](#十、通过 EXPLAIN 理解 pg_stats 如何影响执行计划)
    • [1. 创建索引](#1. 创建索引)
    • [2. 高频值查询](#2. 高频值查询)
    • [3. 相对低频值查询](#3. 相对低频值查询)
    • [4. 高选择性查询](#4. 高选择性查询)
    • [5. 范围查询](#5. 范围查询)
  • [十一、EXPLAIN 和 EXPLAIN ANALYZE 的区别](#十一、EXPLAIN 和 EXPLAIN ANALYZE 的区别)
    • [1. EXPLAIN](#1. EXPLAIN)
    • [2. EXPLAIN ANALYZE](#2. EXPLAIN ANALYZE)
  • 十二、复现统计信息过期导致执行计划错误
  • 十三、如何判断统计信息是否准确?
    • [1. 看 pg_stats 的估算](#1. 看 pg_stats 的估算)
    • [2. 看真实数据分布](#2. 看真实数据分布)
    • [3. 对比估算与真实值](#3. 对比估算与真实值)
  • 十四、如何提高统计信息精度?
  • [十五、pg_stats 排查慢 SQL 的实用流程](#十五、pg_stats 排查慢 SQL 的实用流程)
  • [十六、pg_stats 常见误区](#十六、pg_stats 常见误区)
    • 误区一:有索引就一定走索引
    • [误区二:pg_stats 是实时的](#误区二:pg_stats 是实时的)
    • [误区三:n_distinct 永远表示具体数量](#误区三:n_distinct 永远表示具体数量)
    • [误区四:histogram_bounds 没什么用](#误区四:histogram_bounds 没什么用)
    • [误区五:correlation 可以忽略](#误区五:correlation 可以忽略)
  • 十七、生产环境建议
    • [1. 大批量导入后执行 ANALYZE](#1. 大批量导入后执行 ANALYZE)
    • [2. 大批量 UPDATE/DELETE 后执行 ANALYZE](#2. 大批量 UPDATE/DELETE 后执行 ANALYZE)
    • [3. 慢 SQL 优先看估算行数](#3. 慢 SQL 优先看估算行数)
    • [4. 数据倾斜字段提高统计目标](#4. 数据倾斜字段提高统计目标)
    • [5. 定期检查核心表统计信息](#5. 定期检查核心表统计信息)
  • [十八、完整实验 SQL 汇总](#十八、完整实验 SQL 汇总)
  • 十九、总结

PostgreSQL/openGauss pg_stats 视图从入门到精通:统计信息、执行计划与慢 SQL 优化实战

一、前言

在 PostgreSQL 或 openGauss 中,很多人学习 SQL 优化时,第一反应是:

text 复制代码
SQL 慢了,是不是没建索引?

但实际上,数据库优化器是否选择索引,并不只看有没有索引,还要看它认为这条 SQL 会返回多少数据。

而优化器判断数据量的核心依据之一,就是统计信息。

pg_stats 视图就是我们观察统计信息最常用的入口。

本文从零基础开始,通过一张测试表,完整讲解:

  • pg_stats 是什么
  • 为什么优化器需要统计信息
  • null_fracn_distinctmost_common_valshistogram_boundscorrelation 分别是什么意思
  • 如何通过 SQL 复现统计信息
  • 如何结合 EXPLAIN 理解执行计划
  • 为什么统计信息过期会导致慢 SQL
  • 生产环境中应该如何使用 pg_stats 排查问题

二、pg_stats 是什么?

pg_stats 是数据库中的一个系统视图。

它保存的是表中每一列的统计信息。

简单理解:

text 复制代码
pg_stats = 优化器眼中的数据画像

优化器在执行 SQL 前,并不会真正扫描整张表去计算:

text 复制代码
这个条件会返回多少行?
这个字段有多少种不同的值?
某个值是不是特别常见?
范围查询会命中多少数据?

如果每次执行 SQL 前都扫一遍全表,那数据库性能会非常差。

所以数据库会通过 ANALYZE 采样表数据,生成统计信息,然后保存在系统统计信息中。我们可以通过 pg_stats 来查看这些统计结果。


三、为什么 pg_stats 很重要?

假设有一张用户表:

sql 复制代码
SELECT *
FROM users
WHERE gender = 'M';

如果 gender='M' 占全表 90%,那么即使 gender 上有索引,数据库也可能不走索引。

因为通过索引找出 90% 的数据,再回表读取,成本可能比直接全表扫描还高。

但是如果:

sql 复制代码
SELECT *
FROM users
WHERE gender = 'F';

gender='F' 只占 1%,那么索引就很有价值。

问题来了:

数据库怎么知道 M 占 90%,F 占 1%?

答案就是:

sql 复制代码
pg_stats

四、创建测试表

下面我们创建一张测试表,用来学习 pg_stats

sql 复制代码
DROP TABLE IF EXISTS t_pg_stats_demo;

CREATE TABLE t_pg_stats_demo (
    id          serial PRIMARY KEY,
    city        text,
    gender      text,
    age         int,
    salary      numeric(10,2),
    status      text,
    created_at  date
);

字段说明:

字段 含义
id 主键,自增
city 城市
gender 性别
age 年龄
salary 薪资
status 状态
created_at 创建日期

五、插入测试数据

sql 复制代码
INSERT INTO t_pg_stats_demo(city, gender, age, salary, status, created_at)
SELECT
    CASE
        WHEN i <= 5000 THEN '北京'
        WHEN i <= 8000 THEN '上海'
        WHEN i <= 9500 THEN '广州'
        ELSE '深圳'
    END AS city,

    CASE
        WHEN i % 10 < 7 THEN 'M'
        ELSE 'F'
    END AS gender,

    CASE
        WHEN i <= 7000 THEN 25 + (i % 5)
        ELSE 40 + (i % 20)
    END AS age,

    CASE
        WHEN i <= 9000 THEN 8000 + (i % 1000)
        ELSE 30000 + (i % 5000)
    END AS salary,

    CASE
        WHEN i <= 8500 THEN '正常'
        WHEN i <= 9500 THEN '冻结'
        ELSE '注销'
    END AS status,

    current_date - (i % 365)
FROM generate_series(1, 10000) AS s(i);

这批数据的设计是有目的的:

字段 数据特点
city 城市分布不均匀
gender M 占 70%,F 占 30%
age 年轻人集中在 25-29 岁
salary 大部分是 8000-8999,少量是 30000 以上
created_at 分布在最近 365 天
id 每行唯一

六、收集统计信息

插入数据之后,需要执行:

sql 复制代码
ANALYZE t_pg_stats_demo;

ANALYZE 的作用是:

text 复制代码
采样表数据,生成统计信息,供优化器使用

如果不执行 ANALYZEpg_stats 中可能没有最新统计信息,优化器也可能无法准确估算 SQL 返回行数。


七、查看 pg_stats

执行:

sql 复制代码
SELECT
    schemaname,
    tablename,
    attname,
    null_frac,
    n_distinct,
    most_common_vals,
    most_common_freqs,
    histogram_bounds,
    correlation
FROM pg_stats
WHERE tablename = 't_pg_stats_demo'
ORDER BY attname;

常见输出字段如下:

字段 含义
schemaname schema 名称
tablename 表名
attname 字段名
null_frac NULL 值比例
n_distinct 不同值数量估算
most_common_vals 高频值
most_common_freqs 高频值出现比例
histogram_bounds 直方图边界
correlation 字段值与物理存储顺序的相关性

八、pg_stats 核心字段详解

1. schemaname

表示表所在的 schema。

例如:

text 复制代码
schemaname | gilmp

说明测试表位于 gilmp 这个 schema 下。

如果你查询不到结果,可能是 schema 不对。

可以这样查:

sql 复制代码
SELECT schemaname, tablename, attname
FROM pg_stats
WHERE tablename = 't_pg_stats_demo';

2. tablename

表示表名。

例如:

text 复制代码
tablename | t_pg_stats_demo

注意,pg_stats 里通常只显示当前用户有权限访问的表统计信息。


3. attname

表示字段名。

例如有:

text 复制代码
age
city
created_at
gender
id
salary
status

每一行统计信息对应表中的一个字段。


4. null_frac:NULL 值比例

null_frac 表示某一列中 NULL 值所占比例。

例如:

text 复制代码
null_frac = 0

表示该字段没有 NULL。

如果某列统计信息为:

text 复制代码
null_frac = 0.3

表示该字段大约 30% 的值是 NULL。

测试 NULL 场景:

sql 复制代码
UPDATE t_pg_stats_demo
SET city = NULL
WHERE id <= 1000;

ANALYZE t_pg_stats_demo;

SELECT attname, null_frac
FROM pg_stats
WHERE tablename = 't_pg_stats_demo'
  AND attname = 'city';

你会看到 citynull_frac 接近:

text 复制代码
0.1

因为 10000 行中有 1000 行被更新成了 NULL。

优化器如何使用 null_frac?

例如:

sql 复制代码
EXPLAIN
SELECT *
FROM t_pg_stats_demo
WHERE city IS NULL;

优化器会根据 null_frac 估算:

text 复制代码
city IS NULL 大概会返回多少行

如果 NULL 很少,索引可能有价值。

如果 NULL 很多,全表扫描可能更合适。


5. n_distinct:不同值数量估算

n_distinctpg_stats 中非常重要的字段。

它表示某列中不同值数量的估算。

情况一:n_distinct 为正数

示例:

text 复制代码
age
n_distinct = 25

表示优化器认为:

text 复制代码
age 这一列大约有 25 个不同值

再比如:

text 复制代码
gender
n_distinct = 2

说明:

text 复制代码
gender 这一列大约有 2 个不同值

通常就是:

text 复制代码
M 和 F

情况二:n_distinct 为负数

id 字段是:

text 复制代码
id
n_distinct = -1

这是 PostgreSQL/openGauss 中很经典的表示方式。

n_distinct 是负数时,含义不是具体数量,而是比例。

计算方式:

text 复制代码
不同值数量 ≈ 表总行数 × abs(n_distinct)

例如表有 10000 行:

text 复制代码
n_distinct = -1

则表示:

text 复制代码
不同值数量 ≈ 10000 × 1 = 10000

也就是说:

text 复制代码
id 基本每一行都不同

这正符合主键字段的特点。

再例如:

text 复制代码
n_distinct = -0.2

如果表有 10000 行,表示:

text 复制代码
不同值数量 ≈ 10000 × 0.2 = 2000

实例 salary

text 复制代码
salary
n_distinct = -0.2

表示薪资列的不同值数量大约占总行数的 20%。


6. most_common_vals:最常见的值

most_common_vals 表示该列中最常见的值,也叫 MCV。

MCV 是:

text 复制代码
Most Common Values

例如 gender 的统计信息类似:

text 复制代码
most_common_vals  | {M,F}
most_common_freqs | {.7,.3}

表示:

频率
M 70%
F 30%

这说明:

sql 复制代码
WHERE gender = 'M'

大概会返回 70% 的数据。

而:

sql 复制代码
WHERE gender = 'F'

大概会返回 30% 的数据。


7. most_common_freqs:最常见值的频率

most_common_freqsmost_common_vals 是一一对应的。

例如:

text 复制代码
most_common_vals  = {M,F}
most_common_freqs = {.7,.3}

对应关系是:

text 复制代码
M -> 0.7
F -> 0.3

也就是:

text 复制代码
M 占 70%
F 占 30%

这个字段对等值查询非常重要。

例如:

sql 复制代码
EXPLAIN
SELECT *
FROM t_pg_stats_demo
WHERE gender = 'M';

优化器看到 M 是高频值,就会估算返回行数较多。

如果查询:

sql 复制代码
EXPLAIN
SELECT *
FROM t_pg_stats_demo
WHERE gender = 'F';

优化器会估算返回行数较少。


8. histogram_bounds:直方图边界

histogram_bounds 用来描述数据分布。

它主要用于范围查询。

例如:

sql 复制代码
WHERE salary BETWEEN 8000 AND 9000

或者:

sql 复制代码
WHERE created_at >= '2026-01-01'

优化器会参考 histogram_bounds 判断范围条件大概命中多少数据。

例如从 2025-05-292026-05-28,说明数据库为日期字段建立了直方图边界。

salary 字段也有直方图边界,例如:

text 复制代码
8100.00
8110.00
8120.00
...
8999.00
34089.00
...
34999.00

这说明薪资数据不是均匀分布的。

大部分薪资集中在 8000-9000 左右,后面有少量高薪数据在 30000 以上。

为什么 salary 的直方图很有意义?

因为这两个查询命中比例完全不同:

sql 复制代码
SELECT *
FROM t_pg_stats_demo
WHERE salary BETWEEN 8000 AND 9000;

和:

sql 复制代码
SELECT *
FROM t_pg_stats_demo
WHERE salary BETWEEN 30000 AND 35000;

第一个范围可能命中大量数据。

第二个范围只命中少量数据。

如果没有直方图,优化器很难准确判断范围查询的选择率。


9. correlation:相关性

correlation 是很多初学者最容易忽略,但非常重要的字段。

它表示:

text 复制代码
字段值顺序和表中物理存储顺序的相关性

取值范围大致是:

text 复制代码
-1 到 1

含义如下:

correlation 含义
接近 1 字段值递增顺序和物理存储顺序高度一致
接近 0 字段值和物理存储顺序基本无关
接近 -1 字段值递减顺序和物理存储顺序高度一致

例如:

text 复制代码
id
correlation = 1

这非常合理。

因为 id 是自增主键,插入顺序和物理存储顺序基本一致。

所以:

sql 复制代码
WHERE id BETWEEN 1000 AND 2000

通常读取的数据块比较连续。

示例:

text 复制代码
created_at
correlation = -0.00705876

接近 0。

说明:

text 复制代码
created_at 的值和表的物理存储顺序几乎没有关系

如果对 created_at 做范围查询,即使有索引,也可能产生大量随机 IO。


九、内容逐列分析

1. age 字段分析

age 字段:

text 复制代码
null_frac = 0
n_distinct = 25
most_common_vals = {25,26,27,28,29,40,...,59}
most_common_freqs = {.14,.14,.14,.14,.14,.015,...}
correlation = .700078

说明:

  1. age 没有 NULL。
  2. age 共有大约 25 个不同值。
  3. 25 到 29 岁是高频年龄,每个大约占 14%。
  4. 40 到 59 岁每个占比约 1.5%。
  5. correlation=0.700078,说明年龄和物理存储顺序有一定正相关。

这和我们造数逻辑一致:

sql 复制代码
CASE
    WHEN i <= 7000 THEN 25 + (i % 5)
    ELSE 40 + (i % 20)
END

前 7000 行集中在 25-29 岁,所以这几个年龄是高频值。

验证真实分布

sql 复制代码
SELECT age, count(*) AS cnt
FROM t_pg_stats_demo
GROUP BY age
ORDER BY cnt DESC, age;

你应该能看到:

text 复制代码
25-29 岁数量明显更多
40-59 岁数量相对较少

2. gender 字段分析

gender 字段:

text 复制代码
n_distinct = 2
most_common_vals = {M,F}
most_common_freqs = {.7,.3}
correlation = .57955

说明:

text 复制代码
gender 只有两个值:M 和 F

并且:

gender 占比
M 70%
F 30%

这和造数逻辑一致:

sql 复制代码
CASE
    WHEN i % 10 < 7 THEN 'M'
    ELSE 'F'
END

因为 0-6 是 7 个数,占 70%。

验证真实分布

sql 复制代码
SELECT gender, count(*) AS cnt,
       round(count(*) * 100.0 / sum(count(*)) over (), 2) AS pct
FROM t_pg_stats_demo
GROUP BY gender
ORDER BY cnt DESC;

3. id 字段分析

id 字段:

text 复制代码
null_frac = 0
n_distinct = -1
histogram_bounds = {1,100,200,...,10000}
correlation = 1

说明:

  1. id 没有 NULL。
  2. n_distinct=-1 表示基本每行唯一。
  3. histogram_bounds 从 1 到 10000,说明 id 分布均匀。
  4. correlation=1 表示 id 顺序和物理存储顺序完全一致。

这是主键字段最理想的统计状态。

适合的查询

sql 复制代码
SELECT *
FROM t_pg_stats_demo
WHERE id = 100;

或者:

sql 复制代码
SELECT *
FROM t_pg_stats_demo
WHERE id BETWEEN 100 AND 200;

这类查询通常适合使用主键索引。


4. created_at 字段分析

created_at 字段:

text 复制代码
n_distinct = 365
histogram_bounds = {"2025-05-29", ..., "2026-05-28"}
correlation = -0.00705876

说明:

  1. created_at 大约有 365 个不同值。
  2. 日期分布覆盖一年左右。
  3. correlation 接近 0,说明日期和物理存储顺序基本无关。

这和造数逻辑有关:

sql 复制代码
current_date - (i % 365)

因为 i % 365 循环变化,所以日期不是随着 id 单调递增或递减,而是周期性分布。

适合测试范围查询

sql 复制代码
EXPLAIN
SELECT *
FROM t_pg_stats_demo
WHERE created_at BETWEEN current_date - 30 AND current_date;

然后创建索引:

sql 复制代码
CREATE INDEX idx_demo_created_at
ON t_pg_stats_demo(created_at);

ANALYZE t_pg_stats_demo;

再次查看执行计划:

sql 复制代码
EXPLAIN
SELECT *
FROM t_pg_stats_demo
WHERE created_at BETWEEN current_date - 30 AND current_date;

如果返回数据比例较高,优化器可能仍然选择全表扫描。

这不是索引无效,而是优化器认为:

text 复制代码
走索引不划算

5. salary 字段分析

salary 字段:

text 复制代码
n_distinct = -0.2
most_common_vals = {8000.00,8001.00,...}
most_common_freqs = {.0009,.0009,...}
histogram_bounds = {8100.00,8110.00,...,8999.00,34089.00,...,34999.00}
correlation = .352228

说明:

  1. salary 的不同值比较多。
  2. n_distinct=-0.2 表示不同值数量约占总行数 20%。
  3. 大部分常见薪资集中在 8000 附近。
  4. 直方图显示存在一段明显的高薪区间。
  5. correlation=0.352228,说明薪资和物理存储顺序有一定相关性,但不强。

这类字段很适合观察:

sql 复制代码
WHERE salary BETWEEN ...

对比两个范围查询

sql 复制代码
EXPLAIN
SELECT *
FROM t_pg_stats_demo
WHERE salary BETWEEN 8000 AND 9000;
sql 复制代码
EXPLAIN
SELECT *
FROM t_pg_stats_demo
WHERE salary BETWEEN 30000 AND 35000;

第一个范围会命中大量普通薪资。

第二个范围会命中较少高薪数据。

优化器会参考 histogram_bounds 来估算返回行数。


6. city 和 status 字段分析

city 字段显示:

text 复制代码
n_distinct = 1
most_common_vals = {??}
most_common_freqs = {1}
correlation = 1

status 字段也类似:

text 复制代码
n_distinct = 1
most_common_vals = {??}
most_common_freqs = {1}

这说明当前统计信息中,优化器认为:

text 复制代码
city 只有一个不同值
status 也只有一个不同值

这和我们最初设计的数据分布不一致。

因为理论上,city 应该有:

text 复制代码
北京、上海、广州、深圳

status 应该有:

text 复制代码
正常、冻结、注销

如果你的实际输出是 {??},常见原因有两个:

原因一:客户端字符集显示问题

中文在 gsql、psql 或终端中显示成了 ??

可以检查客户端编码:

sql 复制代码
SHOW client_encoding;

PostgreSQL 中可以设置:

sql 复制代码
SET client_encoding = 'UTF8';

如果是 openGauss,也需要确认数据库字符集、客户端字符集、终端字符集是否一致。

原因二:插入时中文已经被转换成了 ??

如果插入数据时字符集不正确,可能数据库里真实存储的就是 ??

验证方法:

sql 复制代码
SELECT city, count(*)
FROM t_pg_stats_demo
GROUP BY city
ORDER BY count(*) DESC;

如果结果只有:

text 复制代码
?? | 10000

说明不是显示问题,而是数据本身已经变成了 ??

原因三:之前做过 UPDATE 测试

例如执行过:

sql 复制代码
UPDATE t_pg_stats_demo
SET city = '深圳';

然后又执行了:

sql 复制代码
ANALYZE t_pg_stats_demo;

那么 pg_stats 中就会显示:

text 复制代码
n_distinct = 1
most_common_freqs = {1}

这是正常的,因为所有行确实都变成了同一个城市。


十、通过 EXPLAIN 理解 pg_stats 如何影响执行计划

1. 创建索引

sql 复制代码
CREATE INDEX idx_demo_gender ON t_pg_stats_demo(gender);
CREATE INDEX idx_demo_age ON t_pg_stats_demo(age);
CREATE INDEX idx_demo_salary ON t_pg_stats_demo(salary);
CREATE INDEX idx_demo_created_at ON t_pg_stats_demo(created_at);

ANALYZE t_pg_stats_demo;

2. 高频值查询

sql 复制代码
EXPLAIN
SELECT *
FROM t_pg_stats_demo
WHERE gender = 'M';

因为 M 占 70%,优化器可能认为:

text 复制代码
返回数据太多

所以即使有索引,也可能选择:

text 复制代码
Seq Scan

也就是全表扫描。


3. 相对低频值查询

sql 复制代码
EXPLAIN
SELECT *
FROM t_pg_stats_demo
WHERE gender = 'F';

因为 F 占 30%,相比 M 少一些。

如果表足够大,优化器可能更倾向使用索引。

不过在 10000 行的小表中,仍然可能选择全表扫描。

这是因为:

text 复制代码
小表全表扫描成本很低

所以测试索引效果时,最好把数据量放大到几十万或几百万行。


4. 高选择性查询

sql 复制代码
EXPLAIN
SELECT *
FROM t_pg_stats_demo
WHERE id = 5000;

由于 id 是唯一值,优化器知道这个条件大概率只返回 1 行。

所以通常会使用主键索引:

text 复制代码
Index Scan

5. 范围查询

sql 复制代码
EXPLAIN
SELECT *
FROM t_pg_stats_demo
WHERE salary BETWEEN 8000 AND 9000;

这个范围命中大量数据。

再执行:

sql 复制代码
EXPLAIN
SELECT *
FROM t_pg_stats_demo
WHERE salary BETWEEN 30000 AND 35000;

这个范围命中少量数据。

你可以观察两个执行计划中的:

text 复制代码
rows=...

这个 rows 就是优化器估算的返回行数。

它不是实际返回行数,而是根据统计信息估算出来的。


十一、EXPLAIN 和 EXPLAIN ANALYZE 的区别

1. EXPLAIN

sql 复制代码
EXPLAIN
SELECT *
FROM t_pg_stats_demo
WHERE salary BETWEEN 30000 AND 35000;

只显示计划,不真正执行 SQL。

适合查看优化器的预估。


2. EXPLAIN ANALYZE

sql 复制代码
EXPLAIN ANALYZE
SELECT *
FROM t_pg_stats_demo
WHERE salary BETWEEN 30000 AND 35000;

会真正执行 SQL,并显示实际执行情况。

重点观察:

text 复制代码
rows=估算行数
actual rows=实际行数

如果两者差距很大,说明统计信息可能不准。

例如:

text 复制代码
rows=10
actual rows=5000

这就是典型问题。

优化器以为只返回 10 行,结果实际返回 5000 行,执行计划就可能选错。


十二、复现统计信息过期导致执行计划错误

这是生产环境中非常常见的问题。

第一步:查看原始统计信息

sql 复制代码
ANALYZE t_pg_stats_demo;

SELECT attname, n_distinct, most_common_vals, most_common_freqs
FROM pg_stats
WHERE tablename = 't_pg_stats_demo'
  AND attname = 'gender';

第二步:查看执行计划

sql 复制代码
EXPLAIN
SELECT *
FROM t_pg_stats_demo
WHERE gender = 'F';

第三步:大量修改数据,但不执行 ANALYZE

sql 复制代码
UPDATE t_pg_stats_demo
SET gender = 'F'
WHERE id <= 9000;

此时真实数据已经变了。

F 已经变成高频值。

但是统计信息还没更新。


第四步:再次查看执行计划

sql 复制代码
EXPLAIN
SELECT *
FROM t_pg_stats_demo
WHERE gender = 'F';

这时优化器仍然可能按照旧统计信息估算。

也就是说:

text 复制代码
真实数据已经变了
但优化器还不知道

第五步:重新收集统计信息

sql 复制代码
ANALYZE t_pg_stats_demo;

第六步:查看新的统计信息

sql 复制代码
SELECT attname, n_distinct, most_common_vals, most_common_freqs
FROM pg_stats
WHERE tablename = 't_pg_stats_demo'
  AND attname = 'gender';

此时你会看到 F 的占比明显上升。


第七步:再次查看执行计划

sql 复制代码
EXPLAIN
SELECT *
FROM t_pg_stats_demo
WHERE gender = 'F';

这时执行计划可能发生变化。

这个实验说明:

text 复制代码
统计信息不是实时更新的
大批量数据变更后,需要及时 ANALYZE

十三、如何判断统计信息是否准确?

1. 看 pg_stats 的估算

sql 复制代码
SELECT
    attname,
    null_frac,
    n_distinct,
    most_common_vals,
    most_common_freqs
FROM pg_stats
WHERE tablename = 't_pg_stats_demo'
ORDER BY attname;

2. 看真实数据分布

例如查看 gender

sql 复制代码
SELECT gender, count(*)
FROM t_pg_stats_demo
GROUP BY gender
ORDER BY count(*) DESC;

查看 city

sql 复制代码
SELECT city, count(*)
FROM t_pg_stats_demo
GROUP BY city
ORDER BY count(*) DESC;

查看 age

sql 复制代码
SELECT age, count(*)
FROM t_pg_stats_demo
GROUP BY age
ORDER BY count(*) DESC, age;

3. 对比估算与真实值

如果 pg_stats 中:

text 复制代码
most_common_freqs = {.7,.3}

但真实数据是:

text 复制代码
M = 10%
F = 90%

说明统计信息过期了。

解决方法:

sql 复制代码
ANALYZE t_pg_stats_demo;

十四、如何提高统计信息精度?

默认情况下,数据库不会统计所有数据,而是采样。

如果某些列数据分布非常倾斜,默认采样可能不够准确。

可以提高某个字段的统计目标。

sql 复制代码
ALTER TABLE t_pg_stats_demo
ALTER COLUMN salary SET STATISTICS 1000;

然后重新分析:

sql 复制代码
ANALYZE t_pg_stats_demo;

查看统计信息:

sql 复制代码
SELECT attname, most_common_vals, most_common_freqs, histogram_bounds
FROM pg_stats
WHERE tablename = 't_pg_stats_demo'
  AND attname = 'salary';

SET STATISTICS 的值越大,统计信息越细,但 ANALYZE 成本也越高。

一般不建议全库随便调大。

适合针对:

text 复制代码
严重数据倾斜的字段
经常作为查询条件的字段
慢 SQL 中估算行数严重错误的字段

十五、pg_stats 排查慢 SQL 的实用流程

当遇到慢 SQL 时,可以按下面流程排查。

第一步:查看执行计划

sql 复制代码
EXPLAIN ANALYZE
SELECT *
FROM t_pg_stats_demo
WHERE salary BETWEEN 30000 AND 35000;

重点看:

text 复制代码
rows
actual rows

如果差距很大,说明优化器估算不准。


第二步:查看相关字段统计信息

sql 复制代码
SELECT
    attname,
    null_frac,
    n_distinct,
    most_common_vals,
    most_common_freqs,
    histogram_bounds,
    correlation
FROM pg_stats
WHERE tablename = 't_pg_stats_demo'
  AND attname IN ('salary');

第三步:查看真实数据分布

sql 复制代码
SELECT
    min(salary),
    max(salary),
    count(*)
FROM t_pg_stats_demo;
sql 复制代码
SELECT salary, count(*)
FROM t_pg_stats_demo
GROUP BY salary
ORDER BY count(*) DESC
LIMIT 20;

第四步:重新收集统计信息

sql 复制代码
ANALYZE t_pg_stats_demo;

第五步:必要时提高统计精度

sql 复制代码
ALTER TABLE t_pg_stats_demo
ALTER COLUMN salary SET STATISTICS 1000;

ANALYZE t_pg_stats_demo;

第六步:重新查看执行计划

sql 复制代码
EXPLAIN ANALYZE
SELECT *
FROM t_pg_stats_demo
WHERE salary BETWEEN 30000 AND 35000;

如果估算行数和实际行数明显接近,说明统计信息改善有效。


十六、pg_stats 常见误区

误区一:有索引就一定走索引

错误。

优化器是否走索引,要看成本。

如果查询返回大量数据,全表扫描可能更快。


误区二:pg_stats 是实时的

错误。

pg_stats 来自 ANALYZE

数据大量变化后,如果没有重新分析,统计信息可能是旧的。


误区三:n_distinct 永远表示具体数量

错误。

n_distinct 是正数时,表示估算的不同值数量。

n_distinct 是负数时,表示不同值数量占总行数的比例。


误区四:histogram_bounds 没什么用

错误。

它对范围查询非常重要。

例如:

sql 复制代码
WHERE salary BETWEEN 8000 AND 9000
sql 复制代码
WHERE created_at >= '2026-01-01'

这类条件都需要直方图辅助估算。


误区五:correlation 可以忽略

错误。

correlation 会影响索引扫描成本。

如果字段和物理存储顺序高度相关,索引范围扫描可能很高效。

如果字段随机分布,索引范围扫描可能产生大量随机 IO。


十七、生产环境建议

1. 大批量导入后执行 ANALYZE

sql 复制代码
ANALYZE table_name;

例如:

sql 复制代码
ANALYZE orders;

2. 大批量 UPDATE/DELETE 后执行 ANALYZE

sql 复制代码
ANALYZE table_name;

尤其是状态字段、时间字段、分区字段发生大规模变化后。


3. 慢 SQL 优先看估算行数

sql 复制代码
EXPLAIN ANALYZE
SELECT ...

重点看:

text 复制代码
rows
actual rows

如果估算和实际差距巨大,不要急着建索引,先看统计信息。


4. 数据倾斜字段提高统计目标

sql 复制代码
ALTER TABLE table_name
ALTER COLUMN column_name SET STATISTICS 1000;

ANALYZE table_name;

5. 定期检查核心表统计信息

例如核心业务表:

sql 复制代码
SELECT
    schemaname,
    tablename,
    attname,
    null_frac,
    n_distinct,
    most_common_vals,
    most_common_freqs,
    correlation
FROM pg_stats
WHERE tablename IN ('orders', 'users', 'payments')
ORDER BY tablename, attname;

十八、完整实验 SQL 汇总

下面是一套可以直接复现的 SQL。

sql 复制代码
DROP TABLE IF EXISTS t_pg_stats_demo;

CREATE TABLE t_pg_stats_demo (
    id          serial PRIMARY KEY,
    city        text,
    gender      text,
    age         int,
    salary      numeric(10,2),
    status      text,
    created_at  date
);

INSERT INTO t_pg_stats_demo(city, gender, age, salary, status, created_at)
SELECT
    CASE
        WHEN i <= 5000 THEN '北京'
        WHEN i <= 8000 THEN '上海'
        WHEN i <= 9500 THEN '广州'
        ELSE '深圳'
    END AS city,

    CASE
        WHEN i % 10 < 7 THEN 'M'
        ELSE 'F'
    END AS gender,

    CASE
        WHEN i <= 7000 THEN 25 + (i % 5)
        ELSE 40 + (i % 20)
    END AS age,

    CASE
        WHEN i <= 9000 THEN 8000 + (i % 1000)
        ELSE 30000 + (i % 5000)
    END AS salary,

    CASE
        WHEN i <= 8500 THEN '正常'
        WHEN i <= 9500 THEN '冻结'
        ELSE '注销'
    END AS status,

    current_date - (i % 365)
FROM generate_series(1, 10000) AS s(i);

ANALYZE t_pg_stats_demo;

SELECT
    schemaname,
    tablename,
    attname,
    null_frac,
    n_distinct,
    most_common_vals,
    most_common_freqs,
    histogram_bounds,
    correlation
FROM pg_stats
WHERE tablename = 't_pg_stats_demo'
ORDER BY attname;

创建索引:

sql 复制代码
CREATE INDEX idx_demo_gender ON t_pg_stats_demo(gender);
CREATE INDEX idx_demo_age ON t_pg_stats_demo(age);
CREATE INDEX idx_demo_salary ON t_pg_stats_demo(salary);
CREATE INDEX idx_demo_created_at ON t_pg_stats_demo(created_at);

ANALYZE t_pg_stats_demo;

查看执行计划:

sql 复制代码
EXPLAIN
SELECT *
FROM t_pg_stats_demo
WHERE gender = 'M';

EXPLAIN
SELECT *
FROM t_pg_stats_demo
WHERE gender = 'F';

EXPLAIN
SELECT *
FROM t_pg_stats_demo
WHERE salary BETWEEN 8000 AND 9000;

EXPLAIN
SELECT *
FROM t_pg_stats_demo
WHERE salary BETWEEN 30000 AND 35000;

EXPLAIN
SELECT *
FROM t_pg_stats_demo
WHERE created_at BETWEEN current_date - 30 AND current_date;

查看真实分布:

sql 复制代码
SELECT gender, count(*)
FROM t_pg_stats_demo
GROUP BY gender
ORDER BY count(*) DESC;

SELECT age, count(*)
FROM t_pg_stats_demo
GROUP BY age
ORDER BY count(*) DESC, age;

SELECT city, count(*)
FROM t_pg_stats_demo
GROUP BY city
ORDER BY count(*) DESC;

SELECT status, count(*)
FROM t_pg_stats_demo
GROUP BY status
ORDER BY count(*) DESC;

复现统计信息过期:

sql 复制代码
ANALYZE t_pg_stats_demo;

SELECT attname, most_common_vals, most_common_freqs
FROM pg_stats
WHERE tablename = 't_pg_stats_demo'
  AND attname = 'gender';

UPDATE t_pg_stats_demo
SET gender = 'F'
WHERE id <= 9000;

EXPLAIN
SELECT *
FROM t_pg_stats_demo
WHERE gender = 'F';

ANALYZE t_pg_stats_demo;

SELECT attname, most_common_vals, most_common_freqs
FROM pg_stats
WHERE tablename = 't_pg_stats_demo'
  AND attname = 'gender';

EXPLAIN
SELECT *
FROM t_pg_stats_demo
WHERE gender = 'F';

十九、总结

pg_stats 是 PostgreSQL/openGauss SQL 优化中非常重要的视图。

它不是业务数据表,而是优化器使用的数据画像。

它告诉优化器:

text 复制代码
字段有没有 NULL
字段有多少不同值
哪些值最常见
常见值占比是多少
范围数据如何分布
字段顺序和物理存储顺序是否相关

真正理解 pg_stats 后,再看执行计划就不会只停留在:

text 复制代码
为什么不走索引?

而是会进一步分析:

text 复制代码
优化器为什么认为不该走索引?
它估算的返回行数是否准确?
统计信息是否过期?
字段数据是否倾斜?
是否需要 ANALYZE?
是否需要提高统计目标?

一句话总结:

text 复制代码
索引决定数据库有没有可能快;
统计信息决定优化器知不知道该怎么快。

所以,学习 SQL 优化,不能只学索引,也必须学会看 pg_stats

若有转载,请标明出处:https://blog.csdn.net/CharlesYuangc/article/details/161489067

相关推荐
南极企鹅2 小时前
MySQL间隙锁&临键锁
数据库·sql·mysql
TDengine (老段)3 小时前
TDengine 压缩编码机制 — 双层压缩架构与类型特化算法
大数据·数据库·物联网·算法·时序数据库·tdengine·涛思数据
苏渡苇4 小时前
Redis 持久化——RDB 快照 vs AOF 日志
数据库·redis·缓存·redis持久化·aof vs rdb
l1t4 小时前
DeepSeek总结的使用 PEG 实现运行时可扩展的 SQL 解析器
数据库·sql
这个DBA有点耶4 小时前
COUNT进阶(续):超大表去重计数的极致优化
数据库·架构·代码规范
爱喝水的鱼丶5 小时前
SAP-ABAP:SAP 简单报表输出开发系列(共6篇) 第四篇:SAP 报表异常处理机制:数据校验与消息提示规范落地
开发语言·数据库·学习·算法·sap·abap
_1_75 小时前
SQL SERVER闪退问题解决
数据库·sqlserver
ZengLiangYi5 小时前
sql.js WASM 深度解析
javascript·数据库·后端
一 乐5 小时前
人口老龄化社区服务与管理平台|基于springboot+vue的人口老龄化社区服务与管理平台(源码+数据库+文档)
java·数据库·vue.js·spring boot·论文·毕设·人口老龄化社区服务与管理平台