PostgreSQL 统计信息与查询计划器
PG 的查询计划器为什么选 Seq Scan 不选 Index Scan?因为它看"统计信息"估算哪种更快——统计信息不准,计划就错。本文讲清 PG 统计信息怎么收集(ANALYZE)、它怎么影响计划器决策、什么时候统计过时导致慢查询,以及手动干预(ANALYZE/统计目标/扩展统计)。
本文要点
- 主题聚焦:DevOps
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
PostgreSQL 统计信息与查询计划器
干 DBA 干久了你会发现一个反直觉的事实:很多慢查询的根因,不是缺索引,也不是 SQL 写得烂,而是统计信息过时了。
你加了一个索引,查询还是慢;你重写了 SQL,计划器还是不听话地选了 Seq Scan。最后你 ANALYZE 一下,计划瞬间变好。这种"灵异事件"几乎每个 PostgreSQL 工程师都遇到过。
这篇文章不打算把查询计划器的论文从头讲一遍。它要回答的是一个更实用、也更致命的问题:计划器的决策是建立在什么之上的?什么时候这个基础会崩塌,又该怎么修复甚至主动调教它?
理解了"统计信息",你就理解了 PostgreSQL 性能调优的一半。
一、查询计划器:为每条 SQL 选一条"最划算"的执行路径
你写一条 SQL,比如:
SELECT * FROM orders WHERE customer_id = 12345 AND created_at > '2025-01-01';
PostgreSQL 不会直接去执行它。这条 SQL 到了数据库里,会经过两个阶段:
- 解析器(Parser):检查语法、解析表名列名、生成一棵解析树。
- 计划器(Planner / Optimizer):基于代价模型(Cost-based Optimizer, CBO),生成一个它认为"代价最低"的执行计划。
执行计划长什么样?就是 EXPLAIN 出来的那棵树:
Hash Join (cost=10.50..5200.33 rows=320 width=120)
Hash Cond: (orders.customer_id = customers.id)
-> Seq Scan on orders (cost=0.00..4800.00 rows=4200 width=80)
Filter: (created_at > '2025-01-01'::date)
-> Hash (cost=8.00..8.00 rows=200 width=40)
-> Seq Scan on customers (cost=0.00..8.00 rows=200 width=40)
这棵树里的每一个节点——Seq Scan、Index Scan、Hash Join、Nested Loop、Sort、Aggregate——都是计划器在"多个候选方案"中挑出来的。比如扫一张 1000 万行的表,计划器要决定:
- 全表扫(Seq Scan),还是走索引(Index Scan / Bitmap Index Scan)?
- 两个表 JOIN,用 Hash Join 还是 Nested Loop?哪个表做"外表"?
- 要不要物化(Materialize)、要不要排序(Sort)后再 Merge Join?
这些选择的依据,是一个叫 cost model 的代价公式,大致长这样:
cost = seq_page_cost * 顺序读页数
+ random_page_cost * 随机读页数
+ cpu_tuple_cost * 处理行数
+ ...
公式里那些页数、行数、选择率(selectivity),没有一个是从天上掉下来的,全部来自统计信息。
也就是说:计划器的所有"智慧",本质上是统计信息的下游产物。统计信息一旦不准,计划器就变成了一个算术很好但眼睛瞎的人——它仍然在认真地"算最低代价",但它算的是错的世界。
这就是为什么"统计信息"是整个性能体系的地基。
二、统计信息:计划器的"决策依据"
PostgreSQL 不维护精确的"每列值分布"。它存的是估算值,放在一组系统表里。看懂这几个表,你就看懂了计划器的"思维底料"。
1. pg_class.reltuples —— 每张表的行数估算
SELECT relname, reltuples, relpages
FROM pg_class
WHERE relname IN ('orders', 'customers');
relname | reltuples | relpages
-----------+------------+----------
orders | 1.2e+07 | 240000
customers | 85000 | 1200
reltuples 是这张表大概有多少行的估算值,relpages 是占了多少磁盘页(8KB 一页)。这两个数字由 ANALYZE / VACUUM 写入。
计划器判断"是不是该走索引"时,第一眼看的就是 reltuples。一个朴素的规则:如果某谓词的选择率高(命中行数多),走索引反而更慢(随机 IO 贵),不如 Seq Scan。
2. pg_statistic —— 真正的统计底表
pg_class 只告诉你"这张表多少行",但计划器还要知道"每列长什么样"。这个信息存在 pg_statistic 里——每列每条记录,包含:
stanullfrac:NULL 值占比。stadistinct:这列有多少个不同的值(distinct),或一个比例。most_common_vals(MCV) +most_common_freqs:最常见值和它的频率(直方图里那个"尖峰")。histogram_bounds:等高直方图,用于估算"某值落在哪个桶"的选择率。correlation:物理行顺序和列值顺序的相关性。这个直接影响 Index Scan 的代价评估——物理上有序的列,走索引读取更便宜。
3. pg_stats —— 给人看的视图
pg_statistic 是内部表,直接看很反人类。PostgreSQL 提供了 pg_stats 视图,这是诊断统计信息问题的第一工具:
SELECT
attname AS column,
n_distinct,
most_common_vals AS mcv,
most_common_freqs AS freq,
correlation
FROM pg_stats
WHERE tablename = 'orders' AND attname IN ('customer_id', 'status', 'created_at');
column | n_distinct | mcv | freq | correlation
------------+------------+-----------------------+-----------------------+-------------
customer_id | 85000 | {1024,88,1025} | {0.0033,0.0029,0.0028}| 0.02
status | 4 | {paid,shipped,pending}| {0.61,0.22,0.13} | 0.15
created_at | -1 | | | 0.98
读懂这几行,你就读懂了计划器眼里的这张表:
customer_id有约 85000 个不同值(外键,分布均匀),correlation=0.02说明物理上接近无序——走索引随机读会很贵,计划器会谨慎。status只有 4 个值,paid占 61%——WHERE status='paid'命中大半张表,计划器一定不会走索引。created_at的n_distinct=-1表示"几乎每行都不同",correlation=0.98说明插入是按时间顺序的——WHERE created_at > X这种范围查询,走索引特别划算。
你看,计划器不是在"猜",它是在读这些数字做算术。你给它的数字对不对,直接决定它的算术对不对。
三、ANALYZE:统计信息是怎么收集的
统计信息不会自己长出来。它是 ANALYZE 命令的产物。
1. ANALYZE 是"采样",不是"全表扫"
新手最常有的误解:"ANALYZE 会扫全表,会很慢,不能随便跑。"
错的。 ANALYZE 采样。它默认从表的随机位置读若干个 8KB 页(默认 300 × default_statistics_target 这样的量级),从中取出若干行,估计直方图、MCV、distinct 等。一张几亿行的大表,ANALYZE 可能只扫了不到 1% 的数据,几百毫秒就完了。
ANALYZE orders; -- 采这张表的所有列
ANALYZE orders(customer_id); -- 只采某一列(更快)
这跟 VACUUM 完全是两件事。VACUUM 回收死元组(MVCC 的副作用),是真扫描;ANALYZE 只是刷统计。VACUUM ANALYZE 把两者合一,但你可以分别跑。
2. autovacuum:后台自动跑
PostgreSQL 有个后台工作进程 autovacuum,会按阈值自动触发 ANALYZE 和 VACUUM。触发条件大致是:
触发 ANALYZE 当:表上的修改行数 > 插入/更新/删除的阈值
(默认 scale_factor=10% + base_threshold=50)
意思是:一张 1000 万行的表,被改了 100 万行,autovacuum 就会自动 ANALYZE 一次。
这是好事,但它有"盲区",也是出事的源头:
- autovacuum 的优先级是"清死元组"为主,ANALYZE 是顺带的。如果系统繁忙、autovacuum 没排上号,统计就会滞后。
- 大批量
COPY/INSERT导入后,统计可能严重滞后。autovacuum 是"事后触发",不是"事前触发"。你刚导完 1000 万行就立刻查,autovacuum 可能还没反应过来。 - 冷门列、小列的统计也可能不够新。
3. 什么时候该手动 ANALYZE
这是工程上的硬规则,记一下:
-- 场景:大批量数据导入后
COPY orders FROM '/data/orders.csv' WITH (FORMAT csv);
INSERT INTO report (...) SELECT ... FROM raw WHERE ...;
-- → 立刻手动 ANALYZE,别等 autovacuum
ANALYZE orders;
ANALYZE report;
-- 场景:刚建了索引、刚改了表结构
CREATE INDEX idx_orders_status ON orders(status);
-- → 索引会自动登记到系统表,但相关统计建议刷新一下
-- 场景:上线一个新查询前,确认统计新鲜
VACUUM (ANALYZE, VERBOSE) orders;
我的工程习惯是:任何大批量 ETL 脚本的最后一步,强制 ANALYZE 关键表。 不要把"统计过时"作为隐患留给线上查询。
四、统计过时:计划器决策崩坏的开始
现在来说说真正要命的部分。统计信息一旦过时,会发生什么?
计划器会认真、严谨地,选出一条错误的最优路径。这是最隐蔽的故障——它不报错,只是慢。
1. 经典症状一:明明有索引,却选了 Seq Scan
SELECT * FROM orders WHERE customer_id = 12345;
orders 表 2000 万行,customer_id 上有 B-tree 索引。按理说走 Index Scan 应该几十毫秒。但执行计划却显示:
Seq Scan on orders (cost=0.00..380000.00 rows=8000000 width=120)
Filter: (customer_id = 12345)
为什么?因为统计信息是 6 个月前写的,那时这张表只有 10 万行,计划器"记得"customer_id=12345 命中 40000 行(40%),于是正确地选择了 Seq Scan。
但今天表已经 2000 万行了,那个老统计还是告诉它"40%"。计划器算出"要扫 800 万行,走索引还得随机读 800 万次,不如直接顺序扫"——算术是对的,前提是错的。
ANALYZE 一下,统计刷新到"命中约 200 行",计划立刻变成 Index Scan,几毫秒搞定。
2. 经典症状二:JOIN 方式选错
SELECT c.name, o.amount
FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE c.region = 'EU';
本来 c.region='EU' 只命中 200 行,计划器应该选 Hash Join:把 200 行的 customers 建个哈希表,再扫 orders 匹配,飞快。
但如果 customers 的统计过时(或刚导入),计划器以为 region='EU' 命中 50000 行,它就会反手选 Nested Loop——50000 次外表循环,每次都去 orders 上找索引。结果就是一场慢得想砸键盘的查询。
3. 经典症状三:估行数离谱导致"物化 / 排序"乱来
EXPLAIN 里的 rows=... 是估算值,不是实际值。如果估算成 10 行,计划器觉得"排个序吧反正小";实际是 10 万行,排序直接 OOM 或落临时文件。
以上三种症状,根因都是同一个:统计信息不反映当前数据现实。
这也是为什么诊断一切慢查询的第一步,永远是:看 EXPLAIN,然后刷新统计再看一次。 别上来就加索引。
五、统计目标(default_statistics_target):精度与开销的权衡
到这里你应该有个疑问:ANALYZE 既然是采样,那采多一点不是更准吗?
是的。default_statistics_target 控制的就是采样桶的数量,默认是 100。意味着直方图存 100 个桶,MCV 存约 100 个最常见值。
- 值越大 → 统计越精确 → 计划器决策越准
- 但
ANALYZE越慢,pg_statistic越大,规划阶段也更慢
1. 全局调
ALTER SYSTEM SET default_statistics_target = 500; -- 100 → 500
SELECT pg_reload_conf();
100 在大多数场景够用。我通常在复杂分析库、分布严重倾斜的列多的库,调到 200–500。不建议无脑拉到 1000+,会让 ANALYZE 在大表上明显变慢,而收益递减。
2. 单列调(推荐的做法)
更好的做法是只给"分布特别关键、特别倾斜"的那一列加目标,其他列保持默认:
-- 这列分布严重倾斜、查询极频繁,给它单独加大统计精度
ALTER TABLE orders ALTER COLUMN customer_id SET STATISTICS 1000;
-- 别忘了重新采样生效
ANALYZE orders(customer_id);
典型的该加目标的列:
- 高基数 + 查询谓词常出现的列(
user_id、order_id) - 分布严重倾斜的列(90% 是 status=paid,10% 是别的)
- 被频繁用于 JOIN 的列
3. 一个判断经验
如果你 EXPLAIN 看到某个谓词的 rows 估算和实际严重不符,先别怀疑索引,先试:
ALTER TABLE t ALTER COLUMN col SET STATISTICS 500;
ANALYZE t(col);
再跑一次。很多时候问题就消失了。这就是"统计目标"最实际的用途——给关键的少数列喂更精确的数据。
六、扩展统计(CREATE STATISTICS):突破"单列假设"
这是统计信息体系里最容易被忽略、却又最有威力的一块。
1. 默认统计只看"单列"
pg_statistic 是逐列存的。计划器估算 WHERE a = 1 和 WHERE b = 2 没问题,但当出现:
SELECT * FROM users WHERE country = 'CN' AND language = 'zh';
计划器会假设 country 和 language 独立,用乘法算选择率:
selectivity(country='CN') * selectivity(language='zh')
但如果这两列高度相关(CN 的用户几乎全是 zh),这个估算就严重偏低。计划器以为只命中几十行,实际命中几百万行——Index Scan 选择率算错,整个计划可能崩盘。
2. CREATE STATISTICS:告诉计划器"这几列相关"
PostgreSQL 10+ 提供扩展统计,让计划器理解列间关系:
-- 函数依赖:a 决定 b(country → language)
CREATE STATISTICS st_country_language (dependencies)
ON country, language FROM users;
-- 多列 MCV:联合分布直方图
CREATE STATISTICS st_country_language_mcv (mcv)
ON country, language FROM users;
-- NDV:几列组合的 distinct 数
CREATE STATISTICS st_user_ndistinct (ndistinct)
ON user_id, org_id, team_id FROM events;
-- 统计创建后必须 ANALYZE 才生效
ANALYZE users;
ANALYZE events;
创建后,计划器对 country='CN' AND language='zh' 的行数估算会从"独立假设"变成"基于真实联合分布",估算立刻准确。
3. 真实世界的价值场景
什么时候该建扩展统计?看你的慢查询日志:
- 多列 AND 查询且列间有相关性(国家+语言、性别+职业、商品类目+品牌)
- JOIN 键的组合选择性与单列选择性差异很大
- 函数依赖明显的列(
zip_code → city、org_id → department)
但有一点要清醒:扩展统计只对 AND、对等值/范围条件生效,不对 OR 生效;它会增加 ANALYZE 开销。 不是越多越好,是"分布真的很相关"才建。
我的建议是:把 CREATE STATISTICS 当作"当默认统计明显算错选择率时的精修工具",而不是"全面铺开的默认配置"。
七、实战诊断:用 EXPLAIN ANALYZE 拆穿"统计幻觉"
讲这么多,最后落到一个能立刻上手的诊断流程。这是一个我反复在团队里推的 SOP。
1. 永远用 EXPLAIN (ANALYZE, BUFFERS)
EXPLAIN 只给估算;EXPLAIN ANALYZE 会真跑一遍并给实际行数和耗时。这才是诊断统计问题的硬证据:
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders
WHERE customer_id = 12345 AND status = 'paid';
Index Scan using idx_orders_customer on orders (cost=0.43..123.50 rows=1 width=120) (actual time=0.028..0.028 rows=0 loops=1)
Index Cond: (customer_id = 12345)
Filter: (status = 'paid')
Buffers: shared hit=3
Planning Time: 0.18 ms
Execution Time: 0.04 ms
关注两行:
rows=1(估算) vsactual rows=0(实际)——这里很准,统计没问题。Planning Time和Execution Time——哪个慢一目了然。
2. 估行数 vs 实际行数:判断统计过时的金标准
核心心法:估算行数和实际行数差一个数量级以上,几乎可以断定统计信息有问题。
看一个反面教材:
Seq Scan on orders (cost=0.00..420000.00 rows=500 width=120) (actual time=12.0..9800.0 rows=850000 loops=1)
Filter: (status = 'pending')
Planning Time: 0.5 ms
Execution Time: 9820 ms
- 计划器以为
status='pending'命中 500 行 - 实际命中 85 万行——差了 1700 倍
这是教科书级的统计过时。要么统计没刷新,要么 status 这种低基数列根本没存进 MCV,分布也变了。修复路径:
-- 1. 刷新统计
ANALYZE orders;
-- 2. 如果列很关键、估算还是不准,加大目标
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 500;
ANALYZE orders(status);
-- 3. 重新看计划
EXPLAIN (ANALYZE, BUFFERS) SELECT ... ;
3. 一套完整的诊断 SOP
我把它压缩成五步,贴在团队 Wiki 上:
-- 步骤 1:跑计划,看估行 vs 实际行
EXPLAIN (ANALYZE, BUFFERS) <你的慢查询>;
-- 步骤 2:差很大?看统计是否新鲜
SELECT relname, last_analyze, last_autoanalyze, n_mod_since_analyze
FROM pg_stat_user_tables
WHERE relname = 'orders';
-- n_mod_since_analyze 大 → 该 ANALYZE 了
-- 步骤 3:刷新 + 必要时加大统计目标
ANALYZE orders;
-- 或对关键列单独:
-- ALTER TABLE orders ALTER COLUMN col SET STATISTICS 500;
-- ANALYZE orders(col);
-- 步骤 4:如果多列 AND 估算离谱,考虑扩展统计
-- CREATE STATISTICS ... (dependencies|mcv|ndistinct) ON col1, col2 FROM orders;
-- ANALYZE orders;
-- 步骤 5:重跑计划,对比改进
EXPLAIN (ANALYZE, BUFFERS) <你的慢查询>;
两个实战细节值得提一句:
-
pg_stat_user_tables.n_mod_since_analyze是判断"该不该 ANALYZE"的硬指标。如果一张表自上次 ANALYZE 后改了 50% 以上的行,统计几乎肯定该刷新了。 -
EXPLAIN ANALYZE会真实执行查询(包括UPDATE/DELETE)。要安全地看执行计划,把EXPLAIN ANALYZE包在事务里然后ROLLBACK:sql BEGIN; EXPLAIN ANALYZE UPDATE orders SET status='paid' WHERE id=1; ROLLBACK;
小结
- 计划器是"基于代价"的,而代价公式的每个输入都来自统计信息。统计信息是地基,地基塌了,计划器算得再认真也是错的。
- 统计信息不是精确值,是采样估算。
pg_class.reltuples给行数,pg_statistic(通过pg_stats视图可读)给列的分布、MCV、直方图、相关性。 - ANALYZE 是采样子刷新,不扫全表,很快。 autovacuum 会自动跑,但批量导入后请手动
ANALYZE,别依赖它的事后触发。 - 统计过时的症状是"估行数 vs 实际行数差几个数量级":明明有索引却走 Seq Scan、JOIN 方式选错、不该排序的疯狂排序——先
EXPLAIN ANALYZE,先刷新统计,再考虑别的。 - 统计目标和扩展统计是精修工具:给关键倾斜列单独加大
STATISTICS;给高相关的多列建CREATE STATISTICS,突破单列独立假设。两者都用在该用的地方,不滥用。
一句话总结:PostgreSQL 的查询计划器是个聪明的算盘精——但它的算盘珠子,是你用统计信息一粒一粒喂给它的。珠子喂错,算得再快也是错的。
Related
相关推荐
文章
Docker 部署 Next.js 社区项目的注意事项
围绕 DevOps、Next.js 的技术趋势、工程实践和独立开发视角。
文章
OpenTelemetry 如何帮助你观察 AI 应用?
围绕 DevOps、AI 的技术趋势、工程实践和独立开发视角。
文章
长运行工具调用:Operation、Poll 与 Cancel 的终态协议
初始请求超时后,工具可能仍在服务端运行;发出取消也不等于副作用已停止。本文为单次长运行工具调用建立 operation 身份、轮询水位、取消竞态和唯一终态接受点。
文章
Agent 记忆删除传播:从用户遗忘请求到可验证清除
删除主表一行不会清掉向量、摘要、缓存和恢复后的备份。本文用删除状态机、代际墓碑、派生血缘与端到端 canary 验证真正遗忘。