PostgreSQL 执行计划实战(第 9 篇):SQL 和索引没变,计划为什么突然慢一百倍

同一条订单查询昨天只循环内表几百次,今天却循环几十万次。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 时间、统计存储和规划开销。它能提高单列分布精度,却仍不能自动表达 provincecity 的关系;相关性问题应使用扩展统计。两种手段面对不同信息缺口。

为什么关闭 Nested Loop 只能做对照

sql 复制代码
BEGIN;
SET LOCAL enable_nestloop = off;
EXPLAIN (ANALYZE, BUFFERS, SETTINGS) SELECT ...;
ROLLBACK;

若替代计划更快,只证明当前成本模型没有选中它,不能证明 Nested Loop 是根因。全局关闭会伤害真正的小外表、索引点查和低延迟 OLTP。

同理,把 random_page_cost 改到极端值可能改变计划,却是在重写整个实例的成本世界。除非有存储基准和完整工作负载验证,不应把它当单 SQL 修复。

生产修复顺序

  1. 保存慢时计划、参数、设置、统计更新时间与业务影响。
  2. 找第一处 rows 分叉,确认它发生在 base relation、表达式、聚合还是 join。
  3. 统计陈旧则先 ANALYZE;热点采样不足才调 target;多列相关才建扩展统计。
  4. 用同一数据快照比较修复前后估算、Buffers、时间和计划稳定性。
  5. 小范围部署统计或 SQL 变更,观察同 workload 的 p95/p99 与规划 CPU。
  6. 修复业务分布变化后的统计维护和告警,而不是只固定一个计划。

统计更新可能使其他 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;

官方资料

相关推荐
Gent_倪1 小时前
MySQL分库、分表、分区详解
数据库·mysql
额额额对了1 小时前
Linux 数据库开发实战:使用 C 语言与 SQLite3 构建轻量级终端词典
linux·数据库·oracle
qq_246839751 小时前
postgresql xxhash64 哈希函数实现
数据库·postgresql·哈希算法
希望奇迹很安静1 小时前
等级保护测评
网络·数据库
知产xiao_xin2 小时前
《伯尔尼公约》—— 版权保护期
经验分享·笔记·知识产权
会博通·代码搬运工2 小时前
疾控档案落地难点:异构业务系统并存,高价值纸质凭证如何回流业务闭环
数据库·档案管理·档案管理系统·智能扫描设备·会博通
数智启示录2 小时前
Apache Kafka Consumer 扩到 40 个仍不提速:Partition 上限锁死有效并行度 【Kafka合集】
数据库·经验分享·分布式·缓存·面试·kafka·apache
STQY燊桐启元(深圳)电子科技2 小时前
5G 射频光模块场景,ST‑XDP 微波吸波导热垫片应用优势,对比 DP 导热硅胶片
经验分享·笔记·5g
数智启示录2 小时前
Apache Kafka 幂等 Producer 的边界:Exactly-Once 到了 MySQL 为什么失效 【Kafka合集】
数据库·经验分享·分布式·mysql·面试·kafka·apache