同一条订单查询昨天只循环内表几百次,今天却循环几十万次。SQL、索引和参数没改,变化只是活动订单从均匀分布变成"省份与城市强相关"。
优化器先估算每个节点会输出多少行,再比较路径成本。第一处基数误差会沿 Nested Loop、排序和 Hash 容量继续放大;强制换 Join 只能遮住症状。

用一百组相关值制造稳定的百倍误差
在隔离测试库执行:
sql
DROP TABLE IF EXISTS customer_geo;
CREATE TABLE customer_geo (
id bigint PRIMARY KEY,
province text NOT NULL,
city text NOT NULL,
payload text NOT NULL
);
INSERT INTO customer_geo
SELECT
g,
'P' || lpad((g % 100)::text, 2, '0'),
'C' || lpad((g % 100)::text, 2, '0'),
repeat('x', 100)
FROM generate_series(1, 1000000) AS g;
ANALYZE customer_geo;
每个省份约占 1%,每个城市也约占 1%,但 P42 永远与 C42 同时出现。查询:
sql
EXPLAIN (ANALYZE, BUFFERS, SETTINGS)
SELECT id
FROM customer_geo
WHERE province = 'P42'
AND city = 'C42';
在只有单列统计时,优化器可能近似按独立条件相乘:1% × 1% = 0.01%,估算约 100 行;实际约 10000 行,形成约百倍低估。具体数字受 ANALYZE 随机采样影响,判断重点是 rows 方向和数量级。
这一步证明单列统计无法表达列间相关性;它不能证明实际慢 SQL 一定由相关性造成,也不能证明某种 Join 必然被选择。
扩展统计补上单表多列关系
sql
CREATE STATISTICS customer_geo_corr (dependencies, mcv, ndistinct)
ON province, city
FROM customer_geo;
ANALYZE customer_geo;
EXPLAIN (ANALYZE, BUFFERS, SETTINGS)
SELECT id
FROM customer_geo
WHERE province = 'P42'
AND city = 'C42';
dependencies描述列间函数依赖,适合相关等值条件;mcv记录多列常见值组合,能表达"组合特别常见或根本不存在";ndistinct改善多列 distinct 估算,常影响 GROUP BY。
创建统计对象本身不会立即产生数据,必须再次 ANALYZE。扩展统计也不创建索引,不改变数据访问能力;它改变的是估算依据。
检查统计对象:
sql
SELECT statistics_name, attnames, kinds
FROM pg_stats_ext
WHERE statistics_name = 'customer_geo_corr';
SELECT statistics_name, most_common_vals, most_common_freqs
FROM pg_stats_ext
WHERE statistics_name = 'customer_geo_corr';
扩展统计目前主要用于单表条件和聚合估算,并不普遍解决表与表之间的 join selectivity。若误差首次出现在 join 节点,不能机械创建两张表各自的扩展统计后宣称修复。
函数依赖统计还有一个容易误用的边界:它主要帮助等值条件和 IN 常量条件,不是任意范围谓词的通用相关性模型;并且它是统计推断,不像唯一约束那样强制数据正确。若 province 只是"通常决定 city",新增异常组合后依赖强度会变化,下一次 ANALYZE 才会反映新分布。
为什么昨天快、今天慢
SQL 文本不变,优化器输入仍可能变化:
| 变化 | 它影响什么 | 应查证据 |
|---|---|---|
| 数据分布变化 | 选择率、热点和相关性 | pg_stats、业务分布快照 |
| 统计陈旧 | planner 仍看旧世界 | last_analyze、变更量 |
| 采样不足 | MCV/直方图漏掉热点 | statistics target、重复 ANALYZE 对照 |
| 表/索引尺寸变化 | seq/random I/O 成本 | relation size、计划成本 |
| 参数倾斜 | custom plan 的选择率不同 | 同会话同语句不同参数 |
| generic plan | 计划看不到本次值 | pg_prepared_statements |
work_mem/并行变化 |
Hash、Sort 和 Gather 成本 | SETTINGS、temp、workers |
| 缓存与存储变化 | 实际 I/O 时间 | BUFFERS、I/O timing、系统指标 |
因此"计划突然变了"与"计划没变但执行突然慢"也要分开。前者先比较 plan shape 和估算输入,后者还要查缓存、锁、I/O、数据量与循环次数。
读 EXPLAIN 的顺序:从叶子找第一处分叉
text
第一处 estimated rows 与 actual rows 明显分叉
↓
该节点的过滤条件、参数与统计对象
↓
误差是否被上层 loops 乘法放大
↓
Buffers、temp、I/O、Memory、workers
↓
最后才讨论 Join、索引或参数
例如:
text
Index Scan 估 10 行,实际 10000 行
Nested Loop loops 估 10,实际 10000
内表每次只读 3 个 buffer
最终仍变成约 30000 次 buffer 访问
Nested Loop 不一定选错;在"外表只有 10 行"这个错误前提下,它可能是成本最低的合理决策。根因是提供给 Join 搜索的行数世界错了。
EXPLAIN ANALYZE 不是无风险只读
sql
EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS)
SELECT ...;
ANALYZE 会真实执行语句。对 SELECT 也可能消耗大量 CPU/I/O、持有锁和污染缓存;对 UPDATE/DELETE 则会真的修改数据,除非放入可回滚事务且没有不可回滚副作用。生产优先取得已有慢计划、在副本或测试库复现,并设置超时与观测范围。
只看估算可用:
sql
EXPLAIN (COSTS, VERBOSE, SETTINGS)
SELECT ...;
它安全但没有 actual rows,无法验证基数假设。
单列 statistics target 什么时候有用
热点值没进入 MCV、直方图过粗时,可以只提高关键列:
sql
ALTER TABLE customer_geo
ALTER COLUMN province SET STATISTICS 1000;
ANALYZE customer_geo;
更高 target 增加 ANALYZE 时间、统计存储和规划开销。它能提高单列分布精度,却仍不能自动表达 province 与 city 的关系;相关性问题应使用扩展统计。两种手段面对不同信息缺口。
为什么关闭 Nested Loop 只能做对照
sql
BEGIN;
SET LOCAL enable_nestloop = off;
EXPLAIN (ANALYZE, BUFFERS, SETTINGS) SELECT ...;
ROLLBACK;
若替代计划更快,只证明当前成本模型没有选中它,不能证明 Nested Loop 是根因。全局关闭会伤害真正的小外表、索引点查和低延迟 OLTP。
同理,把 random_page_cost 改到极端值可能改变计划,却是在重写整个实例的成本世界。除非有存储基准和完整工作负载验证,不应把它当单 SQL 修复。
生产修复顺序
- 保存慢时计划、参数、设置、统计更新时间与业务影响。
- 找第一处 rows 分叉,确认它发生在 base relation、表达式、聚合还是 join。
- 统计陈旧则先 ANALYZE;热点采样不足才调 target;多列相关才建扩展统计。
- 用同一数据快照比较修复前后估算、Buffers、时间和计划稳定性。
- 小范围部署统计或 SQL 变更,观察同 workload 的 p95/p99 与规划 CPU。
- 修复业务分布变化后的统计维护和告警,而不是只固定一个计划。
统计更新可能使其他 SQL 重新规划。灰度要覆盖共享这些列和表的查询,停止条件包括其他关键 SQL 尾延迟恶化、规划 CPU 异常或新计划抖动。删除统计对象可以回退其影响,但不能瞬间恢复旧计划缓存和缓存热度。
证据边界
| 证据 | 能证明 | 不能证明 |
|---|---|---|
| rows 首次分叉 | 找到估算误差起点 | 它一定是全部耗时来源 |
| 扩展统计后估算接近 | 多列信息改善该条件估算 | join 估算和所有参数都改善 |
| 禁用 Nested Loop 更快 | 存在更快替代路径 | 应全局禁用 Nested Loop |
| ANALYZE 后计划恢复 | 统计陈旧或采样是重要因素 | 数据未来不会再次漂移 |
| 单次执行快 | 当前缓存与参数下表现好 | 高并发和全参数域稳定 |
面试表达主线
PostgreSQL 计划由基数估算驱动。排障先从叶子找第一处 estimated/actual rows 分叉,再看误差如何被 loops 放大;单列热点用 statistics target,多列相关用扩展统计,参数倾斜检查 custom/generic plan。禁用 Join 只能作为替代路径对照。
实验清理
sql
DROP STATISTICS IF EXISTS customer_geo_corr;
DROP TABLE IF EXISTS customer_geo;