PostgreSQL 统计信息、基数估算、Extended Statistics 与计划稳定性
从业务尾延迟出发,沿统计信息、基数估算、Extended Statistics、参数计划与 pgx 执行模式,建立可诊断、可验证、可回退的计划稳定性方法。
第 7 章:从统计信息到计划稳定性——一条贯穿高性能、高并发与高可用的因果链
技术基线:PostgreSQL 18,兼顾 PostgreSQL 14—18。Go 示例使用
github.com/jackc/pgx/v5与pgxpool。
1. 本章只讲一件事:优化器先猜“有多少行”,其他都是后果
先看一条多租户订单查询:
SELECT id, amount, created_at
FROM orders
WHERE tenant_id = $1
AND status = $2
ORDER BY created_at DESC
LIMIT 100;
它在线上可能出现三种看似互不相关的故障:
- 夜间批量导入后,原本几毫秒的查询突然开始全表扫描;
- 小租户一直很快,头部租户却在同一 SQL 上慢几个数量级;
- 同一条连接前几次执行正常,运行一段时间后 P99 突然恶化。
这些现象最终都指向同一个问题:优化器认为某个节点只会输出很少的行,但实际输出了很多行,或者反过来。
PostgreSQL 的计划形成链路可以压缩成下面一条因果链:
真实数据分布
↓ ANALYZE 抽样
统计信息
↓ 选择率估算
基数估算:预计有多少行
↓ 成本比较
扫描方式、Join 顺序、Join 算法、聚合与排序策略
↓ 执行
CPU、内存、I/O、临时文件、锁持有时间、连接占用时间
↓ 并发放大
连接池排队、重试、尾延迟、复制延迟
↓ 故障切换或升级
业务性能 RTO 是否可控
因此,本章不把 ANALYZE、pg_stats、Extended Statistics、Prepared Statement、pgx 和 PgBouncer 当成分散知识点,而是沿着一条主线回答五个问题:
- 优化器怎样估算行数?
- 统计信息为什么会失真?
- 单列统计为什么无法表达多列关系?
- 即使统计正确,为什么参数化查询仍可能选错计划?
- 如何把修复落实到高性能、高并发和高可用的生产闭环?
贯穿全章的判断原则只有一句:
先找到计划树中最早出现的严重基数误差,再修复产生误差的那一层。不要从最慢的顶层节点倒推根因,也不要先用 GUC 或索引把错误计划“压住”。
2. 第一环:基数估算为什么是整个计划的支点
2.1 rows 不是附属信息,而是计划选择的输入
在 EXPLAIN 中,每个节点都有预计输出行数;在 EXPLAIN ANALYZE 中,还会出现实际输出行数:
Index Scan ... rows=20 ... actual rows=50000 loops=1
这里最重要的不是节点叫 Index Scan 还是 Seq Scan,而是:
预计 20 行,实际 50,000 行
一旦输入行数错了,后续判断会连续失真:
- 优化器可能认为 Index Scan 只需做少量随机访问,实际却读取大半张表;
- 优化器可能认为 Nested Loop 外层只有几十行,实际却执行内层索引探测几十万次;
- 优化器可能低估 Hash Join 或 HashAggregate 的输入,使哈希表分批并落盘;
- 优化器可能高估结果集,放弃本来很便宜的索引路径;
- 优化器可能错误判断是否值得启动并行 worker。
所以“有索引为什么不用”“为什么选择 Nested Loop”“为什么排序落盘”常常都不是第一问题。第一问题是:优化器以为会有多少行?
2.2 用 Q-error 衡量估算偏差
可以用 Q-error 表达估算与实际的倍数差:
Q-error = max(actual_rows / estimated_rows,
estimated_rows / actual_rows)
例如:
estimated rows = 100
actual rows = 100,000
Q-error = 1,000
Q-error 不是越接近 1 就一定越快,也没有适用于所有节点的统一告警阈值。但数量级误差通常足以改变扫描方式、Join 顺序和内存策略。
2.3 错误会沿计划树乘法传播
考虑一个 Nested Loop:
外层:预计 10 行,实际 10,000 行
内层:每次预计 1 行,实际 20 行
优化器预想的工作量大约是:
10 × 1 = 10 次内层结果
真实工作量则可能是:
10,000 × 20 = 200,000 次内层结果
顶层节点可能最慢,但它只是承担了下层误差的后果。因此诊断时必须:
- 从计划树叶子节点开始;
- 比较每个节点的
Plan Rows与Actual Rows; - 结合
loops计算累计工作量; - 找到第一个出现数量级偏差的节点。
这一步把后续排障分成两类:
- 扫描节点先错: 优先检查表行数、列统计、相关列、参数可见性;
- 扫描节点正确,Join 才开始错: 优先检查 Join key 的不同值数、唯一性、Join 前过滤、跨表分布和数据模型。
2.4 贯穿三个工程维度
| 维度 | 基数估算错误如何演化 |
|---|---|
| 高性能 | 读放大、随机 I/O、重复循环、临时文件、规划或执行 CPU 增长 |
| 高并发 | 单查询占用连接更久,池等待上升,锁交叠时间变长,重试形成放大 |
| 高可用 | 新主库、恢复库或升级实例即使数据正确,也可能因计划失真无法及时承载流量 |
后面所有知识点都只是为了回答:导致这个基数误差的统计模型在哪里失真?
3. 第二环:统计信息是优化器眼中的“压缩数据模型”
3.1 为什么 PostgreSQL 不直接数真实行数
规划阶段如果为了每条 SQL 扫描整表并统计真实分布,规划成本会远高于查询本身。PostgreSQL 因此让 ANALYZE 对表进行抽样,把真实数据压缩成少量统计信息。
这意味着统计信息具有三个天然属性:
- 近似: 它不是精确
count(*),连续两次 ANALYZE 可能略有差异; - 滞后: DML 改变真实数据后,统计不会逐行同步更新;
- 有损: 它只保留高频值、直方图、不同值数等摘要,不记录完整分布。
所以,统计问题不只有“有没有 ANALYZE”。还要继续问:
统计是否足够新?
统计目标是否足以表达尖峰和长尾?
单列摘要是否能够表达查询中的联合分布?
规划时是否看得见本次参数?
3.2 三层统计模型
PostgreSQL 与本章相关的统计可以分为三层:
| 层次 | 典型对象 | 回答的问题 |
|---|---|---|
| 表级 | pg_class.reltuples、relpages | 表大约有多少行、多少页 |
| 单列 | pg_stats | 某一列的 NULL、不同值、高频值、范围和物理相关度 |
| 多列 | pg_stats_ext | 同一张表中几列是否相关、组合数是多少、哪些值组合高频 |
优化器先用表级统计确定输入规模,再用单列或多列统计计算谓词选择率,最后得到各计划节点的预计行数。
3.3 reltuples 和 relpages 为什么只能解决“表有多大”
SELECT
c.oid::regclass AS relation,
c.reltuples,
c.relpages,
pg_relation_size(c.oid) AS heap_bytes,
pg_total_relation_size(c.oid) AS total_bytes
FROM pg_class AS c
WHERE c.oid = 'public.orders'::regclass;
reltuples 和 relpages 在 VACUUM、ANALYZE、部分 DDL 或索引构建时更新,因此是近似值。优化器还会结合当前关系文件大小对旧行数进行缩放。
但文件增长只能告诉优化器“表变大了”,不能告诉它“新数据的 status='chargeback' 从 0.5% 变成了 60%”。
这解释了一个常见事故:
批量导入后 reltuples 看起来并不离谱
↓
团队误以为统计已经可用
↓
旧 MCV 仍把新高频值当作稀有值
↓
查询继续选择索引随机访问
↓
读放大和 P99 同时爆发
3.4 统计新鲜度:先看“变化是否已经超过模型有效期”
SELECT
relid::regclass AS relation,
n_live_tup,
n_mod_since_analyze,
round(
100.0 * n_mod_since_analyze / GREATEST(n_live_tup, 1),
2
) AS modified_pct,
last_analyze,
last_autoanalyze,
analyze_count,
autoanalyze_count
FROM pg_stat_user_tables
ORDER BY modified_pct DESC, n_mod_since_analyze DESC;
这些值是累计统计系统中的估计,不是审计级精确计数。它们用于发现“模型可能已经过期”,不能机械地把某个百分比当作全局阈值。
对普通表,可以用下面的近似公式理解自动 ANALYZE 的触发门槛:
触发修改行数
≈ autovacuum_analyze_threshold
+ autovacuum_analyze_scale_factor × 表行数估计
大表的关键风险是:即使只修改很小比例,也可能彻底改变某个关键值的分布;而默认比例门槛仍可能要求积累大量修改后才触发。
生产中的合理做法不是关闭自动维护,而是按表调整:
ALTER TABLE public.orders SET (
autovacuum_analyze_threshold = 10000,
autovacuum_analyze_scale_factor = 0.01
);
这个值只是示例。需要根据表规模、每日变更量、分布变化速度、查询 SLO、维护 I/O 和 worker 容量验证。
3.5 手工 ANALYZE 的正确位置
ANALYZE public.orders;
ANALYZE public.orders (tenant_id, status, created_at);
-- PostgreSQL 16+
ANALYZE (
VERBOSE,
BUFFER_USAGE_LIMIT '128MB'
) public.orders;
手工 ANALYZE 最适合放在以下确定性事件之后:
- 大批量
COPY或批量 INSERT; - 分区装载、交换或大规模回填;
- 逻辑订阅初始同步或大批 Apply;
- 大版本升级后补齐未保留统计;
- 新建 Extended Statistics 之后。
这一步同时连接三个维度:
- 高性能: 避免旧分布驱动错误计划;
- 高并发: 避免放量后慢查询占满连接,再与 autoanalyze 争夺 I/O;
- 高可用: 把“数据已恢复”推进到“实例已具备稳定承载能力”。
但 ANALYZE 本身也会消耗 CPU、内存带宽和读 I/O,因此不应在高峰对所有大表无差别执行高目标分析。
4. 第三环:单列统计怎样变成选择率
现在回到订单查询。假设优化器正在估算:
WHERE status = 'chargeback'
它不会扫描订单表,而是读取 pg_stats:
SELECT
attname,
null_frac,
n_distinct,
most_common_vals,
most_common_freqs,
histogram_bounds,
correlation
FROM pg_stats
WHERE schemaname = 'public'
AND tablename = 'orders';
4.1 null_frac:先扣掉不可能匹配的 NULL
null_frac 表示 NULL 行的比例。对普通等值谓词:
status = 'chargeback'
NULL 不会匹配;对:
status IS NULL
null_frac 则直接提供基础选择率。
4.2 n_distinct:尾部分布的分母
n_distinct 的含义:
- 正数:估算的固定不同值个数;
- 负数:绝对值乘当前表行数,表示不同值数随表规模增长;
-1:不同值数大约等于行数,常见于近似唯一列。
例如表有 1,000,000 行,n_distinct=-0.5,优化器会把不同值数理解为约 500,000。
4.3 MCV:让尖峰值脱离“平均值池”
most_common_vals 与 most_common_freqs 按位置对应。若常量出现在 MCV 中:
selectivity(status = 'chargeback')
≈ 对应的 most_common_freqs
这使优化器可以区分:
paid → 99%
chargeback → 0.5%
而不是把两个值都按 1 / n_distinct 处理。
4.4 未命中 MCV 的值如何估算
可用下面的直觉公式理解尾部平均化:
剩余非空概率质量
= 1 - null_frac - Σ(MCV frequencies)
剩余不同值数
≈ n_distinct - MCV 条目数
未知普通值选择率
≈ 剩余非空概率质量 / 剩余不同值数
例如:
null_frac = 0.10
n_distinct = 1000
MCV 条目数 = 20
MCV 频率之和 = 0.40
某个值不在 MCV 中时:
选择率 ≈ (1 - 0.10 - 0.40) / (1000 - 20)
≈ 0.0005102
若表有 1,000,000 行,则估算约 510 行。
真实实现还会处理采样误差、负 n_distinct、唯一性和类型专用逻辑。这个公式的价值不是复刻源码,而是说明:统计目标太低时,没进入 MCV 的尖峰值会被错误地平均到长尾中。
4.5 直方图:范围谓词的压缩模型
histogram_bounds 在排除 MCV 后,把剩余值划分为近似等频桶。范围查询例如:
WHERE created_at >= now() - interval '1 day'
主要依赖:
- 范围内 MCV 的概率质量;
- 常量在直方图桶中的位置;
- NULL 比例;
- 类型的比较和插值逻辑。
直方图是近似等频,不是等宽。时间序列在最近一小时突然形成尖峰时,旧直方图即使覆盖了时间上界,也可能严重低估最新数据。
4.6 correlation:描述物理局部性,不描述业务列关系
correlation 衡量某一列的逻辑顺序与堆中物理位置的一致程度,约在 -1 到 1 之间。
- 接近
1或-1:沿索引读取较多行时,Heap 访问更接近顺序 I/O; - 接近
0:Heap Fetch 更随机,Index Scan 成本更高。
它不是 tenant_id 与 status 的业务相关性。两列之间的关系需要 Extended Statistics。
4.7 什么时候提高列级统计目标
当关键尖峰未进入 MCV、直方图过粗或 n_distinct 波动明显时,应优先按列提高,而不是全局提高:
ALTER TABLE public.orders
ALTER COLUMN tenant_id SET STATISTICS 500;
ALTER TABLE public.orders
ALTER COLUMN status SET STATISTICS 1000;
ANALYZE public.orders (tenant_id, status);
提高目标通常意味着更大样本、更多 MCV 和更细直方图,同时也增加 ANALYZE 的 CPU、内存和 I/O 成本。
判断是否值得保留,应看完整闭环:
统计目标提高
↓
关键参数的 estimate 更接近 actual
↓
计划或资源使用更合理
↓
P95/P99 在真实并发下改善
↓
ANALYZE 维护成本可接受
如果第一步改善但计划和尾延迟没有变化,或者问题本来是多列相关、跨表 Join、缺索引或锁等待,就不应继续盲目提高目标。
5. 第四环:单列统计的边界——相关列为什么会被错误相乘
订单查询不只过滤 status,还过滤 tenant_id:
WHERE tenant_id = 42
AND status = 'chargeback'
假设:
tenant_id = 42 占全表 5%
status = chargeback 占全表 1%
在缺少多列统计时,优化器通常近似假设两者独立:
s(A AND B) ≈ s(A) × s(B)
≈ 5% × 1%
≈ 0.05%
但真实业务可能是:几乎所有 chargeback 都来自少数头部租户。对 tenant 42,实际联合选择率可能是 1%,比估算高 20 倍。
这不是单列统计不新鲜,而是单列模型根本没有表达联合分布的能力。
5.1 AND 与 OR 的共同根因
无多列统计时:
s(A AND B) ≈ s(A) × s(B)
OR 条件通常遵循容斥直觉:
s(A OR B) ≈ s(A) + s(B) - s(A AND B)
所以只要交集估算错误,AND 和 OR 都会受到影响。互斥、蕴含、高度相关或“不可能组合”都会破坏独立性假设。
5.2 Extended Statistics 的三个工具不是并列名词,而是三个不同问题的答案
| 统计类型 | 它回答的问题 | 典型场景 |
|---|---|---|
dependencies | 一列是否在统计意义上决定另一列 | country='JP' AND currency='JPY' |
mcv | 哪些值组合异常高频、低频或几乎不存在 | tenant_id=42 AND status='chargeback' |
ndistinct | 多列组合到底有多少种 | GROUP BY tenant_id, status |
创建对象:
CREATE STATISTICS orders_tenant_status_stats
(dependencies, mcv, ndistinct)
ON tenant_id, status
FROM public.orders;
-- CREATE STATISTICS 只创建定义,必须 ANALYZE 才会填充。
ANALYZE public.orders;
查看:
SELECT
statistics_name,
attnames,
kinds,
n_distinct,
dependencies,
most_common_vals,
most_common_freqs,
most_common_base_freqs
FROM pg_stats_ext
WHERE schemaname = 'public'
AND tablename = 'orders';
5.3 dependencies:修复“条件重复计算”
若 currency 几乎由 country 决定:
WHERE country = 'JP'
AND currency = 'JPY'
单列独立假设会把两个条件重复折扣。dependencies 可以告诉优化器:第二个条件并没有提供同等程度的额外过滤能力。
它主要适用于列与常量之间的等值或 IN 条件,不是数据库约束,也不会阻止异常数据写入。
5.4 mcv:直接记录常见组合的联合频率
多列 MCV 不只是各列 MCV 的笛卡尔积。它保存实际高频组合及其联合频率,还可表达:
- 两个单列值都常见,但组合几乎不存在;
- 某个租户与某个状态组合异常高频;
- 某些 OR 条件的重叠与独立模型差异很大。
它表达力更强,但统计对象更大、ANALYZE 成本更高,而且仍只能覆盖有限数量的组合。
5.5 ndistinct:修复聚合和上层 Join 的组合数
假设系统有 10,000 个租户和 20 个状态。简单相乘会得到 200,000 个组合,但每个租户可能只出现 3 种状态,真实组合数远小于乘积。
错误组合数会影响:
- HashAggregate 的内存估算;
- 排序规模;
- 聚合结果继续 Join 时的上层基数;
- 并行与临时文件决策。
ndistinct 正是用于表达这类多列组合基数。
5.6 Extended Statistics 的明确边界
它不是万能修复:
- 只能描述同一张表内的列或表达式;
- 当前不能直接修复两张表之间的 Join 选择率;
- 仍基于抽样,极稀有组合可能漏样;
- 不能替代索引、约束、分区和正确 SQL;
- 统计对象过多会增加 ANALYZE 时间和运维复杂度。
因此,不要对所有列做组合爆炸。应从真实慢查询中提取经常共同出现的谓词,再用 EXPLAIN ANALYZE 验证 Q-error 是否改善。
5.7 Join 基数为什么仍可能出错
简单等值 Join 可以用下面的粗略直觉理解:
join rows
≈ left_rows × right_rows
/ max(ndistinct_left, ndistinct_right)
真实实现还会考虑 NULL、唯一性、MCV 和其他条件,但核心不变:
任一侧输入行数错
或
Join key 的不同值数错
↓
Join 输出继续错
Extended Statistics 可以修复 Join 前某一张表内部的过滤基数,从而间接改善 Join 输入;但它并没有获得跨表联合分布。
跨表问题的根本方案通常是:
- 修复两侧单表过滤;
- 建立主键、唯一约束和外键等可证明信息;
- 调整索引;
- 分区裁剪;
- 预聚合、物化或适度反规范化;
- 对极端参数拆分查询路径。
至此,我们已经解决了“统计模型本身”的问题。但即使统计完全正确,Prepared Statement 仍可能让优化器看不到本次参数。这就是下一环。
6. 第五环:参数可见性决定“正确统计能否被正确使用”
6.1 Literal、Custom Plan 与 Generic Plan
对 Literal:
WHERE status = 'chargeback'
优化器能直接查 MCV 中 chargeback 的频率。
对 Prepared Statement:
PREPARE orders_by_status(text) AS
SELECT id, tenant_id, status, created_at
FROM public.orders
WHERE status = $1;
PostgreSQL 可能使用两类计划:
| 计划 | 规划时能否看到参数 | 优势 | 风险 |
|---|---|---|---|
| Custom Plan | 能 | 可利用本次 MCV、直方图、分区和部分索引 | 每次执行都要规划 |
| Generic Plan | 不能 | 复用计划,减少规划 CPU | 参数倾斜时只能采用平均或保守估算 |
如果 paid 占 99%,chargeback 占 0.5%,两者的最优路径可能完全不同:
paid → 顺序扫描可能更便宜
chargeback → 索引或部分索引可能更便宜
Generic Plan 需要让同一个计划服务两个极端参数,因此“计划稳定”可能变成“所有参数共用一个不适合某些参数的折中计划”。
6.2 为什么常见现象是“前几次快,后来变慢”
plan_cache_mode=auto 下,PostgreSQL 通常先用若干次 Custom Plan,比较其平均估算成本与 Generic Plan,再决定是否复用 Generic Plan。
因此可能出现:
同一物理连接前几次执行
→ Custom Plan,能识别头部租户或稀有状态
后续执行
→ 切换到 Generic Plan
→ 参数被平均化
→ 某类参数突然变慢
这不是“第六次必然变慢”的规则,而是一个强烈诊断信号。连接池会让复现更困难,因为每条物理连接都有自己的 Prepared Statement 和计划计数。
6.3 部分索引会放大参数可见性的差异
假设存在:
CREATE INDEX orders_chargeback_idx
ON orders (created_at DESC)
WHERE status = 'chargeback';
Literal 或 Custom Plan 能看到:
status = 'chargeback'
因此可以证明部分索引谓词成立。
Generic Plan 只看到:
status = $1
规划时无法证明 $1 必然等于 chargeback,通常不能使用该部分索引。
这说明参数敏感问题不只是“频率平均化”,还可能直接改变可选路径集合。
6.4 用 plan_cache_mode 做诊断,不做全局信仰
PREPARE orders_by_status(text) AS
SELECT id, tenant_id, status, created_at
FROM public.orders
WHERE status = $1;
SET plan_cache_mode = force_custom_plan;
EXPLAIN (ANALYZE, BUFFERS, SETTINGS, VERBOSE, SUMMARY)
EXECUTE orders_by_status('chargeback');
SET plan_cache_mode = force_generic_plan;
EXPLAIN (ANALYZE, BUFFERS, SETTINGS, VERBOSE, SUMMARY)
EXECUTE orders_by_status('chargeback');
RESET plan_cache_mode;
同时查看当前会话:
SELECT
name,
parameter_types,
generic_plans,
custom_plans,
statement
FROM pg_prepared_statements;
诊断时比较的不只是执行时间,还包括:
- Generic 计划中是否仍显示
$1; - 估算行数与实际行数;
- 是否失去部分索引或分区裁剪;
- Shared Read/Hit Blocks;
- planning time 与 execution time;
- 不同参数类别的 P95/P99。
不要全局 force_custom_plan:高 QPS 查询会把 CPU 消耗到重复规划上。也不要全局 force_generic_plan:参数倾斜、部分索引和分区场景可能出现灾难性尾延迟。
更稳妥的局部方式是:
BEGIN;
SET LOCAL plan_cache_mode = force_custom_plan;
-- 只执行已证明确实参数敏感的语句
COMMIT;
6.5 pgx 的正确分层:保持参数化,只对少数查询改变执行模式
pgx 默认常用 QueryExecModeCacheStatement,在每条物理连接上缓存协议级 Prepared Statement。它适合高复用、参数分布相对稳定的 SQL。
对已证明参数敏感的少数查询,可以按查询选择:
rows, err := pool.Query(
ctx,
ordersByStatusSQL,
pgx.QueryExecModeExec,
status,
)
QueryExecModeExec 仍使用 $1 参数绑定,并不等于拼接 Literal。它避免该语句跨执行使用命名服务端 Prepared Statement,同时保留安全参数化。
一个合理的策略层次是:
默认查询
→ CacheStatement,提高复用效率
已证明参数敏感的查询
→ QueryExecModeExec
或事务内 SET LOCAL force_custom_plan
不同参数需要完全不同数据路径
→ 拆分业务端点、SQL 或索引策略
错误做法是拼接用户输入:
sql := "SELECT id FROM orders WHERE status = '" + userInput + "'"
它会带来 SQL 注入、转义错误、SQL 文本爆炸和可观测性碎片化,而且无法修复陈旧统计、多列相关、跨表 Join、锁等待或 I/O 饱和。
6.6 PgBouncer:先区分协议状态与会话状态
需要区分:
- SQL 文本级
PREPARE name AS ...; - PostgreSQL 协议级 Parse/Bind/Execute 的命名 Prepared Statement。
PgBouncer 对协议级 Prepared Statement 的支持取决于版本、池模式与 max_prepared_statements 配置。transaction pooling 下,SQL 文本级 PREPARE、临时表、会话 GUC 和 advisory lock 等会话状态仍必须单独审查。
生产决策不应只写“支持”或“不支持”,而应记录:
PgBouncer 实际版本
pool_mode
max_prepared_statements
pgx DefaultQueryExecMode
DDL 后如何回收缓存连接
Failover 后如何限速重连与重新准备
6.7 Schema 变化为什么会让“自动失效”仍然报错
PostgreSQL 会在依赖对象 DDL、统计更新或 search_path 变化后,使缓存计划在后续使用时重新分析或规划。但客户端缓存的旧结果描述仍可能与新结构不兼容。
尤其是:
SELECT * FROM orders;
新增列或改变返回类型后,旧 Prepared Statement 可能出现结果类型不兼容错误。生产迁移应采用:
- 显式列名;
- expand/contract;
- 先部署兼容应用,再变更结构;
- 按实例滚动回收 pgxpool 连接;
- 按 PgBouncer 官方方式分批回收后端;
- 加抖动和连接速率限制,避免重连与重规划风暴。
到这里,统计、基数和参数可见性已经连成一条完整链。接下来把它转化为一套统一排障方法。
7. 一套统一排障法:从业务尾延迟回溯到最早失真点
7.1 第 0 步:先分清慢在哪里
应用观测必须把一次请求拆成:
请求总耗时
= 连接池等待
+ 数据库规划/执行
+ 网络与结果处理
若请求耗时 5 秒,其中 4.8 秒在等待连接,数据库中的某条慢 SQL 可能是根因,但当前请求本身并没有执行 5 秒。此时盲目增加连接通常会把 CPU、I/O 和锁竞争推入更深的饱和区。
至少同时收集:
- 应用端总耗时和 pool acquire duration;
- query ID、参数类别、调用量、P50/P95/P99;
- 数据库 active queries、wait events、CPU、I/O;
- 批量导入、ANALYZE、DDL、发布、PgBouncer 变更和 Failover 时间线。
7.2 第 1 步:确认统计是否过期
SELECT
relid::regclass AS relation,
n_live_tup,
n_mod_since_analyze,
last_analyze,
last_autoanalyze
FROM pg_stat_user_tables
WHERE relid = 'public.orders'::regclass;
如果故障紧跟批量导入、回填或逻辑同步,优先检查旧 MCV 和直方图是否仍在描述旧数据。
7.3 第 2 步:拿到代表性计划,而不是只拿一条“平均计划”
至少覆盖:
高频值与稀有值
头部租户与长尾租户
Literal
force_custom_plan
force_generic_plan
建议在可控环境执行:
EXPLAIN (
ANALYZE,
BUFFERS,
SETTINGS,
VERBOSE,
SUMMARY
)
SELECT ...;
对 DML,EXPLAIN ANALYZE 会真正执行修改;即使回滚,Sequence、外部调用和某些触发器副作用也未必完全恢复。生产应优先使用只读副本、脱敏克隆、canary 或低风险 SELECT。
7.4 第 3 步:找到第一个严重 Q-error
逐节点记录:
Plan Rows
Actual Rows
Loops
Actual Rows × Loops
Rows Removed by Filter
Shared Hit / Read Blocks
Sort Method / Disk
Hash Batches
定位规则:
| 第一个失真点 | 更可能的根因 |
|---|---|
| 表扫描输入规模就错 | reltuples、分区入口或统计缺失 |
| 单列过滤错 | 统计过旧、MCV/直方图过粗、目标过低 |
| 多列过滤错 | 独立性假设,需要 dependencies/mcv |
| GROUP BY 组合数错 | 需要多列 ndistinct |
| Custom 正确、Generic 错 | 参数敏感或部分索引/分区谓词不可证明 |
| 扫描都准,Join 才错 | Join key、唯一性、跨表分布、模型与索引 |
| 估算准但仍慢 | I/O、锁、连接排队、真实数据量、成本参数或资源饱和 |
7.5 第 4 步:只修复产生误差的层
| 根因 | 首选修复 | 不应先做 |
|---|---|---|
| 统计过旧 | ANALYZE 目标表/关键列,调整表级 autoanalyze | 重启数据库 |
| 尖峰未进入 MCV | 提高关键列统计目标后重 ANALYZE | 全局把目标设到极高 |
| 同表列相关 | 建立 dependencies/mcv/ndistinct | 期待更多普通索引自动修复估算 |
| 参数敏感 | 局部 Custom、pgx Exec、拆分参数路径 | 全库 force_custom_plan 或拼 Literal |
| 跨表 Join | 修输入、约束、索引、分区、预聚合或模型 | 期待 Extended Statistics 跨表生效 |
| 资源或锁问题 | 背压、限流、解除 blocker、容量治理 | 把所有慢都归因于统计 |
7.6 第 5 步:验证必须跨越三个维度
修复不能只以“节点名称变了”为完成标准。至少验证:
估算:estimated vs actual、Q-error
性能:planning time、execution time、Buffers、temp、CPU、I/O
并发:pool acquire、active queries、queue depth、锁与重试
高可用:副本延迟、切换后重连、统计是否就绪、RTO
业务:头部/长尾参数的 P50/P95/P99
并持续覆盖一个完整峰值周期。统计修复可能降低执行 CPU,但提高 ANALYZE 成本;Custom Plan 可能修复头部租户,却增加规划 CPU。只有全链路指标都可接受,才算根本修复。
8. 将同一条因果链放到高性能、高并发和高可用中
8.1 高性能:错误估算如何映射到资源
| 资源 | 典型因果链 |
|---|---|
| CPU | Generic/Custom 选择不当导致重复规划,或坏计划进行大量无效过滤和 Nested Loop |
| 内存 | Hash Join/HashAggregate 输入低估,实际分批或多个并发节点累计内存 |
| 随机 I/O | 高频值被当作稀有值,Index Scan 执行大量随机 Heap Fetch |
| 顺序 I/O | 稀有值被当作高频值,错误选择全表扫描 |
| 临时文件 | Sort、Hash 或 Aggregate 的真实输入远超估算 |
| 网络 | 查询返回过多数据,或不合适执行模式增加协议往返 |
| WAL | ANALYZE 本身主要读;但坏 DML 计划触碰更多行会放大 WAL 和同步复制等待 |
这里有两个重要反直觉:
- 正确计划不一定使用索引;返回大比例数据时 Seq Scan 可能更便宜。
- 提高
work_mem不能替代修复基数。work_mem按节点、worker 和并发查询累积,可能把落盘问题变成内存风险。
8.2 高并发:慢查询首先消耗的是“连接时间”
必须区分:
应用 goroutine 数 ≠ 数据库连接数
数据库连接数 ≠ 活跃查询数
活跃查询数 ≠ TPS
排队请求数 ≠ 锁等待数
假设查询原本占用连接 10 ms,错误计划后占用 5 s。单连接吞吐下降约 500 倍。即使数据库连接数不变,应用侧也会迅速积累等待。
因此高并发治理的顺序通常是:
- 有界
MaxConns; - HTTP/gRPC deadline;
- 在线请求与批任务隔离连接池或角色;
- 按租户、报表或任务限流;
- 只对明确 SQLSTATE 重试完整事务;
- 指数退避、抖动、最大次数和幂等键;
- 修复最早基数误差。
盲目增加连接会让每个坏计划同时消耗更多 CPU 和 I/O,形成连接放大。
8.3 高并发中的重规划风暴
统计更新、DDL、连接重建或 Failover 会使大量连接在后续请求中重新规划或重新准备语句。如果所有应用实例同时重连,可能出现:
连接建立峰值
+ Prepared Statement 重建
+ 计划重新生成
+ 缓存冷启动
= 短时 CPU 与延迟尖峰
应采用滚动回收、随机抖动、连接建立限速和 canary,而不是一次性重启全部连接池。
8.4 高可用:数据正确不等于性能就绪
统计信息通常不改变事务复制的 RPO 语义,但会直接影响性能 RTO。
物理复制
物理复制会复制系统目录变化,因此 Standby 通常拥有 Primary 的优化器统计。但:
- 相同统计不代表相同缓存冷热和设备延迟;
- Prepared Statement 是后端内存状态,不会复制;
- Promotion 后应用必须重连并重新准备;
- 新主库的写入分布变化后,仍要继续维护统计。
逻辑复制
逻辑复制不复制优化器系统目录统计。Subscriber 在初始同步或大批 Apply 后必须本地 ANALYZE,才能安全承担读流量。
升级和恢复
PostgreSQL 18 的 pg_upgrade 可保留大部分优化器统计,降低升级后的性能悬崖,但显式 Extended Statistics 等仍需核对和重建。新版本优化器、硬件环境和缓存状态也可能让计划变化。
因此 HA 就绪检查不能只问:
数据是否追平?
还要问:
关键表统计是否存在且足够新?
扩展统计是否已填充?
头部与长尾参数计划是否回归?
连接和 Prepared Statement 重建是否限速?
切换后 P99、I/O 和 pool acquire 是否在门限内?
8.5 三维决策矩阵
| 场景 | 高性能策略 | 高并发策略 | 高可用策略 |
|---|---|---|---|
| 批量导入改变分布 | ANALYZE 变化列,验证 MCV/计划 | 导入、ANALYZE 与在线流量做 admission control | ANALYZE 与 canary 通过后再切流 |
| 同表相关列误估 | Extended Statistics | 减少错误计划导致的长连接占用 | 在新主库/恢复库验证统计对象与数据 |
| 参数敏感查询 | 局部 Custom 或 pgx Exec | 限制头部参数并发,监控规划 CPU | Failover 后重新准备并覆盖冷热参数 |
| 在线 DDL | expand/contract、显式列名 | 分批回收连接,避免重规划峰值 | 保留兼容窗口和回滚路径 |
| 逻辑副本承担读流量 | 本地 ANALYZE | 限速同步后 canary | 把统计就绪纳入切换门禁 |
| 大版本升级 | 统计迁移后仍做计划回归 | 分阶段 ANALYZE,避免 I/O 峰值 | 统计缺失检查、回滚与性能 RTO 验证 |
9. 三个递进实验:沿着同一条链验证三个失真点
只在可丢弃环境执行。节点类型和耗时受硬件、缓存、成本参数和 PostgreSQL 小版本影响。实验成功标准是估算与资源行为的变化,不是强求某个固定节点名称或固定毫秒数。
9.1 实验一:旧统计如何把高频值当成稀有值
目标
验证:文件增长可以修正总行数,却不能更新列分布;批量导入后若不 ANALYZE,查询可能选择高读放大的索引路径。
准备旧分布
DROP SCHEMA IF EXISTS ch07 CASCADE;
CREATE SCHEMA ch07;
CREATE TABLE ch07.bulk_items (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
category text NOT NULL,
payload text NOT NULL
) WITH (
-- 只用于实验,故意推迟 autoanalyze。
autovacuum_analyze_threshold = 100000000,
autovacuum_analyze_scale_factor = 1.0
);
CREATE INDEX bulk_items_category_idx
ON ch07.bulk_items (category);
INSERT INTO ch07.bulk_items (category, payload)
SELECT 'legacy', repeat(md5(g::text), 3)
FROM generate_series(1, 20000) AS g;
ANALYZE ch07.bulk_items;
批量导入新分布
INSERT INTO ch07.bulk_items (category, payload)
SELECT 'new', repeat(md5(g::text), 3)
FROM generate_series(1, 480000) AS g;
此时 new 占约 96%,但统计仍主要认识 legacy。
ANALYZE 前检查
SELECT
relid::regclass,
n_live_tup,
n_mod_since_analyze,
last_analyze,
last_autoanalyze
FROM pg_stat_user_tables
WHERE relid = 'ch07.bulk_items'::regclass;
SELECT
c.reltuples,
c.relpages,
pg_relation_size(c.oid) AS heap_bytes
FROM pg_class AS c
WHERE c.oid = 'ch07.bulk_items'::regclass;
SELECT
attname,
n_distinct,
most_common_vals,
most_common_freqs
FROM pg_stats
WHERE schemaname = 'ch07'
AND tablename = 'bulk_items';
EXPLAIN (ANALYZE, BUFFERS, SETTINGS, VERBOSE, SUMMARY)
SELECT id, category, payload
FROM ch07.bulk_items
WHERE category = 'new';
重点记录:
estimated rows vs actual rows
Index/Bitmap/Seq Scan
Rows Removed by Filter
Shared Hit / Read Blocks
ANALYZE 后重测
-- PostgreSQL 16+
ANALYZE (
VERBOSE,
BUFFER_USAGE_LIMIT '64MB'
) ch07.bulk_items;
-- PostgreSQL 14/15 使用普通 ANALYZE。
EXPLAIN (ANALYZE, BUFFERS, SETTINGS, VERBOSE, SUMMARY)
SELECT id, category, payload
FROM ch07.bulk_items
WHERE category = 'new';
结论
旧统计
→ new 被当成未知尾部值
→ 高频值被低估
→ 索引随机访问可能被误判为便宜
新统计
→ new 进入 MCV,频率接近真实分布
→ 估算改善
→ 优化器重新比较顺序与索引路径
这一步验证的是“统计新鲜度”。如果 ANALYZE 后两个单列都准确,但组合条件仍错,就进入实验二。
9.2 实验二:单列都准确,联合条件仍被低估
目标
验证独立性假设如何低估相关列,并用 dependencies、mcv 和 ndistinct 修复过滤与聚合基数。
准备数据
CREATE TABLE ch07.correlated_events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
region_id integer NOT NULL,
shard_id integer NOT NULL,
payload text NOT NULL
);
INSERT INTO ch07.correlated_events (region_id, shard_id, payload)
SELECT
g % 100,
g % 100,
repeat(md5(g::text), 2)
FROM generate_series(1, 500000) AS g;
CREATE INDEX correlated_events_region_shard_idx
ON ch07.correlated_events (region_id, shard_id);
ANALYZE ch07.correlated_events;
每个单列值约占 1%,但 region_id 与 shard_id 完全相等。
扩展统计前
EXPLAIN (ANALYZE, BUFFERS, SETTINGS, VERBOSE, SUMMARY)
SELECT *
FROM ch07.correlated_events
WHERE region_id = 42
AND shard_id = 42;
EXPLAIN (ANALYZE, BUFFERS, SETTINGS, VERBOSE, SUMMARY)
SELECT region_id, shard_id, count(*)
FROM ch07.correlated_events
GROUP BY region_id, shard_id;
理论上:
独立估算:1% × 1% = 0.01%
实际联合:约 1%
过滤 Q-error:约 100
创建扩展统计并重测
CREATE STATISTICS correlated_events_rs_stats
(dependencies, mcv, ndistinct)
ON region_id, shard_id
FROM ch07.correlated_events;
ANALYZE ch07.correlated_events;
EXPLAIN (ANALYZE, BUFFERS, SETTINGS, VERBOSE, SUMMARY)
SELECT *
FROM ch07.correlated_events
WHERE region_id = 42
AND shard_id = 42;
EXPLAIN (ANALYZE, BUFFERS, SETTINGS, VERBOSE, SUMMARY)
SELECT region_id, shard_id, count(*)
FROM ch07.correlated_events
GROUP BY region_id, shard_id;
结论
- 等值过滤的估算应显著接近实际;
- GROUP BY 组合数应从接近
100 × 100改善到接近 100; - 计划节点可能不变,但 Q-error 改善本身就是成功;
- 若跨表 Join 仍估错,不能继续堆 Extended Statistics,而应检查 Join 边界。
这一步验证的是“统计表达能力”。如果统计和联合分布都准确,但某些参数仍慢,就进入实验三。
9.3 实验三:正确统计在 Generic Plan 中为什么仍然失效
目标
验证严重倾斜值在 Literal、Custom Plan 和 Generic Plan 下的差异,并观察部分索引对参数可见性的依赖。
准备数据
CREATE TABLE ch07.skew_orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
status text NOT NULL,
created_at timestamptz NOT NULL,
filler text NOT NULL
);
INSERT INTO ch07.skew_orders (status, created_at, filler)
SELECT
CASE WHEN g <= 497500 THEN 'paid' ELSE 'chargeback' END,
clock_timestamp() - (g % 86400) * interval '1 second',
repeat(md5(g::text), 4)
FROM generate_series(1, 500000) AS g;
CREATE INDEX skew_orders_chargeback_idx
ON ch07.skew_orders (id)
WHERE status = 'chargeback';
ANALYZE ch07.skew_orders;
Literal 对照
EXPLAIN (ANALYZE, BUFFERS, SETTINGS, VERBOSE, SUMMARY)
SELECT *
FROM ch07.skew_orders
WHERE status = 'chargeback';
EXPLAIN (ANALYZE, BUFFERS, SETTINGS, VERBOSE, SUMMARY)
SELECT *
FROM ch07.skew_orders
WHERE status = 'paid';
Custom 与 Generic 对照
PREPARE skew_q(text) AS
SELECT *
FROM ch07.skew_orders
WHERE status = $1;
SET plan_cache_mode = force_custom_plan;
EXPLAIN (ANALYZE, BUFFERS, SETTINGS, VERBOSE, SUMMARY)
EXECUTE skew_q('chargeback');
EXPLAIN (ANALYZE, BUFFERS, SETTINGS, VERBOSE, SUMMARY)
EXECUTE skew_q('paid');
SET plan_cache_mode = force_generic_plan;
EXPLAIN (ANALYZE, BUFFERS, SETTINGS, VERBOSE, SUMMARY)
EXECUTE skew_q('chargeback');
EXPLAIN (ANALYZE, BUFFERS, SETTINGS, VERBOSE, SUMMARY)
EXECUTE skew_q('paid');
SELECT name, generic_plans, custom_plans, statement
FROM pg_prepared_statements
WHERE name = 'skew_q';
RESET plan_cache_mode;
DEALLOCATE skew_q;
结论
- Literal 与 Custom 能识别参数频率;
- Generic 计划中通常仍显示
$1; - Generic 无法证明
$1='chargeback',通常不能使用对应部分索引; - 稀有值与高频值共享计划时,至少一类参数可能承担明显的资源放大;
- 修复应局部作用于该语句,而不是全局改变整个实例的计划策略。
清理
DROP SCHEMA ch07 CASCADE;
三个实验依次证明了三类不同根因:
实验一:模型过期
实验二:模型表达能力不足
实验三:模型正确,但规划时看不到参数
这三者不能用同一个“多跑 ANALYZE”或“强制走索引”解决。
10. Go 与 pgx:把计划策略落实到连接池和请求边界
正文只保留决策骨架,完整可运行示例见配套文件 ch07_pgx_example.go。
10.1 默认路径:有界连接池 + 参数化 + Statement Cache
cfg, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, fmt.Errorf("parse DATABASE_URL: %w", err)
}
cfg.MaxConns = 16
cfg.MaxConnLifetime = 30 * time.Minute
cfg.MaxConnIdleTime = 5 * time.Minute
cfg.ConnConfig.DefaultQueryExecMode = pgx.QueryExecModeCacheStatement
pool, err := pgxpool.NewWithConfig(ctx, cfg)
这里的重点不是 16 这个数字,而是:
- 连接数必须有界;
- 查询必须受
contextdeadline 控制; - 默认保留参数化与缓存复用;
- 只有经过证据确认的查询才覆盖默认模式。
10.2 参数敏感查询:按查询使用 QueryExecModeExec
queryCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
rows, err := pool.Query(
queryCtx,
ordersByStatusSQL,
pgx.QueryExecModeExec,
status,
)
适合:
- 高频值与稀有值最优计划差异很大;
- Generic Plan 已被实验确认是尾延迟根因;
- 不希望该语句进入命名 Prepared Statement 缓存;
- 仍要保留安全参数绑定。
10.3 事务内局部强制 Custom Plan
tx, err := pool.BeginTx(ctx, pgx.TxOptions{AccessMode: pgx.ReadOnly})
if err != nil {
return err
}
defer tx.Rollback(context.Background())
if _, err := tx.Exec(ctx,
"SET LOCAL plan_cache_mode = force_custom_plan",
); err != nil {
return err
}
rows, err := tx.Query(ctx, ordersByStatusSQL, status)
它适合少数已证明参数敏感、且希望明确控制服务端计划行为的事务。需要同时监控 planning CPU,避免把执行瓶颈转移成规划瓶颈。
10.4 错误分类与重试边界
只有明确可重试的事务错误,例如 40001 或 40P01,才应在完整事务边界重试,并配合:
- 指数退避;
- 随机抖动;
- 最大次数;
context取消;- 业务幂等键。
慢计划、statement timeout、连接重置和提交结果不确定不能无条件重放。错误计划让事务持续更久,会增加死锁和序列化冲突概率,因此重试策略必须与基数治理一起设计。
10.5 DDL 与 Failover 后的连接处理
DDL / Failover
↓
旧物理连接失效或缓存描述过期
↓
滚动回收连接
↓
按实例加抖动重新建立
↓
重新准备高频语句
↓
canary 验证头部/长尾参数
不要一次性重启全部应用池。否则连接建立、TLS、认证、Prepared Statement 和重新规划会在同一时间形成峰值。
11. 生产生命周期中的三个门禁
11.1 批量导入门禁
批量写入提交
→ 校验行数与业务不变量
→ ANALYZE 新分区或变化列
→ 查看 MCV、直方图与 n_mod_since_analyze
→ 对头部/长尾/高频/稀有参数做 EXPLAIN
→ 小流量验证 P95/P99、Buffers、CPU、I/O
→ 正式放量
这比“等 autoanalyze 自己完成”更可控。大表的导入和 ANALYZE 还必须纳入 I/O admission control,避免维护与在线查询同时压满存储。
11.2 在线 DDL 门禁
先扩展兼容结构
→ 部署兼容新旧结构的应用
→ 回填并校验
→ 切换读写
→ 滚动回收连接与语句缓存
→ 删除旧结构
核心要求:显式列名、expand/contract、分批连接回收、可回滚。
11.3 HA 切换门禁
复制追平
→ 统计对象与数据核对
→ 代表查询计划回归
→ 连接重建限速
→ canary
→ 观察 P99、pool acquire、I/O、复制延迟
→ 扩大流量
逻辑副本必须增加“本地 ANALYZE”步骤;物理副本虽有目录统计,也不能跳过缓存冷热与 Prepared Statement 重建验证。
12. 三个事故案例:用同一套因果链复盘
12.1 案例一:夜间导入后,索引扫描拖垮早高峰
现象
risk_level='high' 原本占 0.2%,新批次变成 65%。查询仍走索引,随机 I/O、连接占用和 Replica lag 同时上升。
最早失真点
扫描节点把 high 估算为稀有值。reltuples 已接近新规模,但 MCV 仍是旧分布。
放大链
旧 MCV
→ 高频值低估
→ 大范围 Index Scan
→ 随机 Heap Fetch
→ 查询占用连接变长
→ pool acquire 与队列上升
→ I/O 竞争使副本重放变慢
止损
限流报表和批任务,手工 ANALYZE 关键列,小流量验证后放量。
根治
调整表级 autoanalyze 阈值,把 ANALYZE 与代表参数计划检查加入导入流水线。
12.2 案例二:同一 SQL 前几次正常,头部租户随后变慢
现象
小租户一直正常,头部租户 P99 从几十毫秒升到十几秒;连接存活一段时间后更容易复现。
最早失真点
Custom Plan 能看到头部租户参数,Generic Plan 只按平均租户估算;同时 tenant_id,status 高度相关。
放大链
参数平均化 + 缺多列统计
→ 头部租户严重低估
→ 不合适索引/Nested Loop
→ CPU、随机读、连接占用上升
→ 连接池排队
止损
对该 query ID 使用 pgx QueryExecModeExec 或局部 Custom,并限制头部租户并发。
根治
补充多列 MCV/dependencies,重新设计索引或拆分头部与长尾路径,在同一物理连接覆盖连续多次执行测试。
12.3 案例三:逻辑副本数据追平,却无法承担读流量
现象
分析集群的逻辑订阅已追平,切流后大范围查询和聚合突然落盘,P99 超出目标。
最早失真点
Subscriber 没有本地优化器统计,或新分区和 Extended Statistics 尚未 ANALYZE。
放大链
数据已到位但统计缺失
→ 过滤与 GROUP BY 基数错误
→ 扫描/哈希/排序策略不当
→ 临时文件和 I/O 饱和
→ 切流失败,性能 RTO 延长
止损
停止扩大流量,本地 ANALYZE,完成代表查询回归。
根治
把统计就绪、计划 canary 和连接预热加入逻辑副本切流门禁。
13. 常见反模式:它们为什么看似有效,却切断了因果链
13.1 统计层反模式
- 把 ANALYZE 当精确全表统计。 它通常抽样,连续结果可以波动。
- 全局把
default_statistics_target设得极高。 所有表维护变贵,关键查询未必受益。 CREATE STATISTICS后忘记 ANALYZE。 对象只有定义,没有统计数据。- 期待 Extended Statistics 修复跨表 Join。 它只能描述同表联合分布。
- 批量导入后只核对
reltuples。 总量接近不代表列分布正确。
13.2 计划层反模式
- 有索引就必须走索引。 高频值返回大比例表时 Seq Scan 可能更便宜。
- 用
enable_seqscan=off作为永久修复。 它隐藏了统计、成本或索引设计问题。 - 全局强制 Custom Plan。 高 QPS 下重复规划可能成为新瓶颈。
- 全局强制 Generic Plan。 倾斜参数、部分索引和分区查询可能出现灾难性 P99。
- 拼接 Literal 绕过计划缓存。 引入注入风险和观测碎片,且不能修复统计或 Join 问题。
13.3 并发与高可用反模式
- 慢查询后第一动作是增加连接。 错误计划只会被更高并发放大。
- 对所有数据库错误无界重试。 会把慢计划放大为重试风暴。
- DDL 后同时重启全部应用池。 形成连接与重规划尖峰。
- 物理或逻辑复制追平就立即全量切流。 数据一致不等于计划与缓存已经就绪。
- 升级后因统计已迁移而跳过计划回归。 扩展统计、优化器版本和硬件状态仍可能导致计划变化。
14. 面试题:沿着“统计 → 基数 → 计划 → 并发 → HA”作答
14.1 统计与基数(1—5)
题 1:ANALYZE 与 VACUUM 有什么区别?
30 秒回答: ANALYZE 抽样生成优化器统计,影响计划;VACUUM 处理死元组、可见性和冻结,维护 MVCC 与空间复用。VACUUM (ANALYZE) 可以一起执行,但目标不同。
深入回答: 统计过旧通常造成性能故障;VACUUM 失控还可能带来 XID wraparound 风险。批量 COPY 后只做 VACUUM 不足以更新分布,仍需 ANALYZE 或确认 autoanalyze 完成。
常见误区: “ANALYZE 是只读版 VACUUM”或“VACUUM 必然更新统计”。
题 2:如何用 pg_stats 解释 col='x' 的选择率?
30 秒回答: 先扣除 null_frac;若 x 在 MCV 中,使用对应频率;否则把剩余非空、非 MCV 的概率质量平均分给剩余不同值。
深入回答: n_distinct 可能为负,需要乘当前行数。提高统计目标的主要价值,是让更多尖峰值进入 MCV、让直方图更细,而不是让统计变成精确全表计数。
常见误区: 所有等值查询都按 1/n_distinct。
题 3:n_distinct=-0.5 与 correlation=0.95 各表示什么?
30 秒回答: 前者表示不同值数约为表行数的 50%;后者表示该列顺序与堆物理顺序高度正相关,可能降低大范围 Index Scan 的随机 I/O 成本。
深入回答: correlation 是单列与物理位置的关系,不是两列业务相关性;两列关系需要 Extended Statistics。
常见误区: 把 correlation 解释成两列之间相关 95%。
题 4:为什么 AND 条件容易严重低估?
30 秒回答: 缺少多列统计时,优化器常把不同列近似看作独立,使用 s(A)×s(B);相关、互斥或蕴含关系会破坏这一假设。
深入回答: country='JP' 与 currency='JPY' 可能各占 5%,独立估算得到 0.25%,真实联合可能接近 5%。可用 dependencies 或多列 MCV 修复。
常见误区: 认为 SQL 中条件书写顺序决定从左到右执行。
题 5:dependencies、mcv、ndistinct 怎么选?
30 秒回答: 等值或 IN 的功能依赖用 dependencies;异常高频或不可能组合用 mcv;多列 GROUP BY 组合数用 ndistinct。
深入回答: 三者可组合,但都基于样本,只适用于同表,不能替代约束和索引,也不能直接修复跨表 Join 选择率。
常见误区: 建一个包含所有列的 mcv 就能解决全部计划问题。
14.2 计划与排障(6—10)
题 6:有索引却走 Seq Scan,首先查什么?
30 秒回答: 先看实际结果比例、行宽和 Buffers,再查统计新鲜度、MCV、直方图、correlation、类型转换和部分索引谓词。Seq Scan 可能本来就是正确计划。
深入回答: 高频值读取大比例表时,索引页加随机 Heap Fetch 可能比顺序扫描更贵。不能用 enable_seqscan=off 代替根因分析。
常见误区: “有索引就必须使用索引”。
题 7:怎样定位 Join Cardinality 错误传播?
30 秒回答: 从计划树底部比较每个节点的 Plan Rows、Actual Rows 和 loops,找到第一个数量级偏差;不要只看顶层最慢节点。
深入回答: 扫描低估会改变 Join 顺序和算法;Nested Loop 外层低估会放大内层循环;Hash 低估会分批和落盘。Extended Statistics 只能先修复单表输入,不能直接表达跨表联合分布。
常见误区: 顶层耗时最大,所以顶层节点就是根因。
题 8:什么时候提高 statistics target?
30 秒回答: 关键列分布不规则、MCV 漏掉尖峰、直方图过粗或 n_distinct 波动时,按列提高并验证,不应全局盲升。
深入回答: 提高后要同时验证 Q-error、计划、P99 和 ANALYZE 成本。如果问题是多列相关、跨表 Join 或缺索引,提高单列目标无效。
常见误区: 统一设置成极大值就一定最好。
题 9:Custom Plan 与 Generic Plan 的权衡是什么?
30 秒回答: Custom 能看本次参数,适合参数敏感查询但有规划 CPU;Generic 可复用计划,适合分布均匀、高频查询,但可能把倾斜参数平均化。
深入回答: Generic 还可能无法证明部分索引谓词或某些分区条件。应通过 force_custom_plan 与 force_generic_plan 对照,再决定局部 pgx Exec、Custom 或拆分路径。
常见误区: Prepared Statement 永远更快,或 Generic 永远更稳定。
题 10:为什么一条查询可能前几次快,后来变慢?
30 秒回答: 同一物理连接在 plan_cache_mode=auto 下可能先使用 Custom,再评估 Generic;切换后倾斜参数失去定制计划,因此变慢。
深入回答: 连接池会让每条物理连接各自经历这一过程。应在同一连接执行连续多次,查看 pg_prepared_statements 计数,并比较 Custom/Generic 的估算与 Buffers。
常见误区: PostgreSQL 每五次固定清理一次计划缓存。
14.3 高性能、高并发与高可用(11—15)
题 11:统计错误如何拖垮连接池?
30 秒回答: 错误计划让单查询占用连接更久,连接吞吐下降,应用请求在池外排队;增加连接会把坏计划同时放大到更多 CPU 和 I/O。
深入回答: 要区分请求总耗时、pool acquire 和数据库执行时间。治理顺序是限流和背压、修复最早 Q-error、再评估连接容量。
常见误区: pool acquire 高就直接扩大 MaxConns。
题 12:批量导入后计划异常的 Runbook 是什么?
30 秒回答: 限制流量,核对 n_mod_since_analyze 和旧 MCV,手工 ANALYZE 变化表/列,重跑代表参数 EXPLAIN,再 canary 放量。
深入回答: 文件增长只能缩放总行数,不能更新列分布。大表应把 ANALYZE 和计划验证做成导入门禁,并控制导入、维护和在线查询的 I/O 并发。
常见误区: 等 autovacuum 自己处理,或重启数据库刷新统计。
题 13:pgx + PgBouncer transaction pooling 如何选执行模式?
30 秒回答: 核对 PgBouncer 版本、pool mode 和 max_prepared_statements。兼容环境可保留 CacheStatement;不兼容或参数敏感查询可使用 QueryExecModeExec。
深入回答: 协议级 Prepared Statement 与 SQL 文本级 PREPARE 不是一回事。还要测试 DDL、连接回收和 Failover 后的重新准备行为。
常见误区: “PgBouncer 永远不支持 Prepared Statement”或“新版配置无需测试”。
题 14:如何做不破坏缓存计划的在线 Schema 迁移?
30 秒回答: 使用 expand/contract、显式列名和兼容读写顺序;DDL 后分批回收应用与 PgBouncer 连接,不同时重启全部实例。
深入回答: PostgreSQL 会失效依赖计划,但客户端可能保留旧结果描述。SELECT * 在新增列或类型变化时风险更高。完整方案还要有 canary、抖动和回滚。
常见误区: 数据库会自动重规划,所以客户端缓存无需处理。
题 15:为什么统计信息属于 HA 的性能 RTO,而不是事务 RPO?
30 秒回答: 统计通常不改变事务是否复制成功,但会决定新主库、恢复库或逻辑副本能否在切换后稳定执行查询,因此直接影响性能恢复时间。
深入回答: 物理副本通常有目录统计,但没有连接内存中的 Prepared Statement;逻辑副本要本地 ANALYZE;升级后仍要核对 Extended Statistics 和计划变化。
常见误区: 数据追平就等于业务已经可全量切流。
15. 练习与参考答案
15.1 理论练习
练习 1
某表 1,000,000 行:
null_frac = 0.10
n_distinct = 1000
MCV 条目数 = 20
MCV 频率之和 = 0.40
常量 x 不在 MCV 中。估算 col=x 的选择率和行数。
答案:
剩余概率 = 1 - 0.10 - 0.40 = 0.50
剩余不同值 = 1000 - 20 = 980
选择率 ≈ 0.50 / 980 ≈ 0.0005102
估算行数 ≈ 510
练习 2
A、B 各有 100 个均匀值,但总满足 B=A。表有 10,000,000 行。无 Extended Statistics 时,A=7 AND B=7 估算和实际各是多少?
答案:
独立估算:10,000,000 × 1/100 × 1/100 = 1,000
真实结果:10,000,000 × 1/100 = 100,000
Q-error ≈ 100
练习 3
一条查询规划 0.4 ms;长尾参数执行 1 ms,头部参数 Custom 执行 20 ms、Generic 执行 5 s。是否应全局强制 Custom?
答案: 不应。先修统计和索引,再只对该查询或头部参数路径使用 Custom/pgx Exec,并评估调用频率和规划 CPU。全局强制会让所有语句支付重复规划成本。
15.2 实验练习
- 将实验三的稀有值比例改为 0.05%、2%、10%,记录 Literal、Custom、Generic 的 Q-error、Buffers 和 P99。
- 构造三列强依赖但含 2% 异常组合的数据,分别只建 dependencies、只建 mcv、两者同时建立,比较 AND 与 OR。
- 对最近一小时形成尖峰的时间列,比较默认统计目标与目标 1000 的直方图、范围估算和 ANALYZE 成本。
15.3 系统设计练习
设计一个每天导入 20 亿事件的平台,查询包含 tenant_id、event_type、created_at,要求 15 分钟内可查询、P99 小于 500 ms、头部与长尾租户相差 10,000 倍,并使用 pgxpool、PgBouncer、物理 HA 与逻辑分析副本。
答案至少应包含:
- 按时间分区,必要时按租户 hash 或头部租户隔离;
- 关键访问路径索引和受控部分索引;
- 新分区加载后 ANALYZE;
(tenant_id,event_type)的 mcv/dependencies/ndistinct 验证;- 头部与长尾参数分开压测和计划策略;
- 在线、导入、ANALYZE 使用独立配额;
- 逻辑副本本地 ANALYZE;
- 切换时限速重连、canary 和性能 RTO 监控。
16. 生产速查表
16.1 估算直觉
命中 MCV:
s ≈ 对应频率
未命中 MCV:
s ≈ (1 - null_frac - Σmcv_freq)
/ (ndistinct - mcv_count)
AND,无多列统计:
s(A AND B) ≈ s(A) × s(B)
OR:
s(A OR B) ≈ s(A) + s(B) - s(A AND B)
简单等值 Join:
rows ≈ left_rows × right_rows
/ max(nd_left, nd_right)
Q-error:
max(actual/estimate, estimate/actual)
16.2 现象到第一动作
| 现象 | 第一动作 |
|---|---|
| 批量导入后计划异常 | 查 n_mod_since_analyze、MCV,ANALYZE 变化列 |
| 单列尖峰值低估 | 提高该列统计目标并重 ANALYZE |
| 同表相关列 AND 低估 | CREATE STATISTICS + ANALYZE |
| GROUP BY 组合数错误 | 多列 ndistinct |
| 稀有与高频参数计划相反 | 比较 Custom/Generic,不拼 Literal |
| 同一连接运行一段时间后变慢 | 查计划缓存启发式与 prepared counters |
| PgBouncer 下 prepare 异常 | 核对版本、pool mode、配置与 pgx mode |
| DDL 后 cached plan 错误 | 显式列名,滚动回收连接,expand/contract |
| 扫描准确但 Join 先失真 | 查唯一性、Join key、跨表分布和模型 |
| 估算准确但仍慢 | 查锁、I/O、连接排队、真实数据量和成本参数 |
| 逻辑副本切流后慢 | 本地 ANALYZE、计划回归、canary |
16.3 本章闭环
业务尾延迟
→ 区分排队与数据库执行
→ 对代表参数获取 Literal/Custom/Generic 计划
→ 找到最早 Q-error
→ 判断过期、单列精度、多列相关、参数可见性或 Join 边界
→ 选择最小修复
→ 在性能、并发、HA 三个维度验证
→ 将 ANALYZE、计划回归和连接重建写入生命周期门禁
17. 版本说明与官方资料
17.1 PostgreSQL 14—18 与本章相关的版本提示
| 版本 | 相关变化 |
|---|---|
| PG14 | Extended Statistics 对表达式和 OR 条件的利用增强 |
| PG15 | 分区/继承父表统计能力增强 |
| PG16 | ANALYZE ... BUFFER_USAGE_LIMIT 可控制维护缓冲环 |
| PG17 | 引入 MAINTAIN 权限,维护命令安全性与目录表现有所调整 |
| PG18 | pg_upgrade 可迁移大部分优化器统计,但仍需核对扩展统计和计划回归 |
具体行为应以部署版本和小版本官方文档为准。
17.2 官方资料
- PostgreSQL 18:Planner Statistics
- PostgreSQL 18:pg_stats
- PostgreSQL 18:ANALYZE
- PostgreSQL 18:CREATE STATISTICS
- PostgreSQL 18:Multivariate Statistics Examples
- PostgreSQL 18:pg_stats_ext
- PostgreSQL 18:PREPARE
- PostgreSQL 18:Query Planning / plan_cache_mode
- PostgreSQL 18:Auto Analyze 参数
- PostgreSQL 18:ANALYZE Progress Reporting
- PostgreSQL 18:pg_upgrade
- pgx/v5 官方 API
- pgxpool 官方 API
- PgBouncer:max_prepared_statements
18. 结语
统计信息不是孤立的维护知识,Prepared Statement 也不是孤立的客户端优化。它们共同决定优化器能否对一个具体参数回答:
这一步究竟会有多少行?
一旦这个答案错误,后果会从扫描和 Join 扩散到 CPU、I/O、连接池、锁、复制延迟和故障切换后的性能恢复。
因此,本章最终要建立的不是“遇到慢查询就 ANALYZE”的习惯,而是一套可重复的工程方法:
从业务尾延迟出发,找到计划树中最早的基数失真;判断它来自统计过期、单列精度、多列相关、参数不可见还是跨表边界;只修复对应层,并在高性能、高并发和高可用三个维度完成验证。