读懂 PostgreSQL 执行计划:从 EXPLAIN 输出定位 Spring Boot 慢 SQL 的代价估算偏差

读懂 PostgreSQL 执行计划:从 EXPLAIN 输出定位 Spring Boot 慢 SQL 的代价估算偏差

1. 先看一个真实场景:接口为什么突然慢了 20 倍

假设你维护一个电商后台的订单查询接口,技术栈是 Spring Boot + MyBatis + PostgreSQL。上线半年一直稳定,某个周一早上,运营反馈"按用户查订单"的页面从 300 毫秒变成了 6 秒。你没有改过这段 SQL,也没有发布新版本,数据库 CPU 却从 15% 涨到了 80%。

你打开日志,看到这条语句只是很普通的一个关联查询:按用户 ID 查该用户最近的订单,再带上订单明细的商品名称。看起来条件很明确、也建了索引,为什么会慢?更让人困惑的是,把同样的 SQL 拿到测试库执行,几十毫秒就返回了。

这时候大多数人会做两件事之一:加索引,或者加缓存。但这两步都可能治错病。真正需要先回答的问题是:PostgreSQL 到底打算怎么执行这条 SQL?它预估要处理多少行?预估和实际差了多少?

数据库不会"随机"变慢。绝大多数突发的计划劣化,都源自同一个根因:它基于统计信息做出的行数估算错了,于是选了一个在真实数据量下极差的执行方式。本文就沿着这个根因,把 EXPLAIN 输出读懂、用透。

2. 一句话模型与全局框架

先记住一个最小模型:PostgreSQL 的查询优化器是一个"按估算成本挑选方案"的决策器。它先读统计信息估算每一步要处理多少行,再按行数乘以单位代价算出总成本,最后从若干候选计划里挑成本最低的那个去执行。

也就是说,优化器并不真的执行 SQL 来试哪个快,它是在"纸上算账"。账算错了,选择就错了。

把整体拆成四部分,它们按下面的顺序串起来:

text 复制代码
SQL 文本
  |
  v
[1] 解析与重写  -->  语法树 / 查询树
  |
  v
[2] 规划器 Planner
      读取 pg_statistic 统计信息
      估算每个节点的 rows
      用 cost 模型累加代价
      生成若干候选路径(顺序扫描 / 索引扫描 / 各类连接)
  |
  v
[3] 选出成本最低的计划
  |
  v
[4] 执行器 Executor 按计划树逐节点拉取数据
  |
  v
结果返回 Spring Boot / JDBC

这四部分里,第 2 步是本文的主战场。因为 EXPLAIN 展示的就是第 2 步的产物,而 EXPLAIN ANALYZE 展示的是第 2 步的估算与第 4 步的实际同时对照。

一次请求完整走一遍是这样:JDBC 把 SQL 发给 PostgreSQL,后端进程解析、重写,规划器产出计划树,执行器从计划树的根节点开始,自上而下"要数据",最底层节点从堆表或索引里取行,逐层向上传递、连接、过滤、排序,最终把结果集流式返回给 JDBC。Spring Boot 侧看到的耗时,就是这条链路的总和。

3. EXPLAIN 与 EXPLAIN ANALYZE:一个看想法,一个看事实

很多人第一次用 EXPLAIN,只会看到一堆缩进文本,不知道从哪读起。先区分两个命令:

  • EXPLAIN:只展示规划器"打算怎么做",不执行 SQL。它给出的是估算值。
  • EXPLAIN ANALYZE:真正执行 SQL,并给出每一步的实际耗时、实际行数、实际循环次数,同时保留估算值。

定位慢 SQL 时,优先使用 EXPLAIN ANALYZE,因为只有它能把"估算"和"实际"并排摆出来。差异最大的地方,往往就是问题所在。

sql 复制代码
-- 输入:一个按用户查订单的关联查询
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT o.id, o.created_at, oi.product_name
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
WHERE o.user_id = 10086
ORDER BY o.created_at DESC
LIMIT 20;

输出大致长这样(数值为示例):

text 复制代码
Limit  (cost=1.12..45.30 rows=20 width=52) (actual time=0.05..0.09 rows=20 loops=1)
  ->  Nested Loop  (cost=1.12..22130.44 rows=20 width=52) (actual time=0.04..0.09 rows=20 loops=1)
        ->  Index Scan using idx_orders_user on orders o
              (cost=0.43..1200.00 rows=1 width=24) (actual time=0.02..0.03 rows=20 loops=1)
              Index Cond: (user_id = 10086)
        ->  Index Scan using idx_items_order on order_items oi
              (cost=0.43..1040.00 rows=1 width=36) (actual time=0.01..0.01 rows=1 loops=20)
              Index Cond: (order_id = o.id)

关键读法:每个节点括号里前一组是估算(cost、rows、width),后一组是实际(actual time、rows、loops)。发现"估算 rows"和"实际 rows"差距很大时,就该怀疑统计信息或代价模型。

4. 把 EXPLAIN 输出拆成几个可操作的数字

理解 EXPLAIN,本质上就是理解几个数字的来源:

字段 含义 由谁决定 出错时会怎样
cost 估算的启动代价...总代价,单位是抽象"页读取"代价 规划器 cost 模型 只影响选图,不影响真实耗时
rows 该节点估算输出的行数 统计信息 + 选择率公式 偏差会放大到上层节点
width 估算每行平均字节数 列类型与统计信息 影响排序/哈希的内存估算
actual time 该节点真实耗时(含子节点) 执行器实测 用来判断真实瓶颈
actual rows 该节点真实输出行数 执行器实测 与估算对比找偏差
loops 该节点被执行的次数 执行器实测 上层嵌套循环会放大它

这里最容易误解的是 cost。它不是毫秒,而是规划器自定义的相对单位。一次顺序页读取的成本由 seq_page_cost 控制(默认 1.0),一次随机页读取由 random_page_cost 控制(默认 4.0),一次 CPU 处理有 cpu_tuple_cost 等参数。这些参数是可调的,也是"估算偏差"的一大来源,尤其在 SSD 上默认的 random_page_cost 往往偏高。

另一个常见误解是:Limit 节点的 cost 很小,不代表整条语句就快。因为 Limit 只负责截断,它下面的节点如果代价巨大,真实耗时依然会爆。

5. 行数估算错误:慢 SQL 最常见的根因

现在回到开头那个订单查询。为什么估算会错?

PostgreSQL 不是逐行扫描才知道有多少行,它维护的是统计信息 :每个表有多少行、每列有多少不同值、最常见的值各占多少比例、以及直方图分布。这些信息由 autovacuum 触发或手动 ANALYZE 更新,存在系统表里。规划器用它们估算"这个条件会命中多少行"。

问题在于估算是有前提的。三类典型偏差来源:

第一是数据倾斜。比如订单表里 90% 的订单都属于少数几个大客户。统计信息里的"最常见值"能覆盖一部分,但如果某用户的订单量远超平均数,而它没被采样进 MCV 列表,估算就会严重偏低。

第二是多列相关性 。当 SQL 同时过滤 city = '上海' 和 district = '浦东' 时,规划器默认两个条件独立,把选择率相乘。但现实中这两个值高度相关,真实命中行数远小于相乘结果。

第三是统计信息过期。批量导入十万行数据后,如果没有及时 ANALYZE,规划器还按老数据估算,选择计划自然错。

一个直观的数字例子:真实命中 50000 行,估算只有 20 行。优化器以为只要在循环里跑 20 次索引查找,于是选了嵌套循环;执行时却跑了 50000 次,每次都是一次随机 I/O,耗时从毫秒级变成秒级。估算误差不是小问题,它会被嵌套循环按倍数放大。

6. 嵌套循环:估算偏差的放大器

要理解为什么偏差如此致命,必须先理解三种连接的适用条件。

嵌套循环(Nested Loop)的逻辑是:对外表的每一行,去内表查一次匹配。它适合"外表很小、内表有高效索引"的场景。只要外表行数估算成 20,规划器就会觉得"循环 20 次很便宜"。

如果行数估算错了,外表实际有 50000 行,循环就变成 50000 次。即使每次索引查找只要 0.1 毫秒,也要 5 秒。这就是执行计划劣化的典型病理。

对比一下三种连接方式:

连接方式 工作机制 适合场景 估算偏差下的表现
嵌套循环 Nested Loop 外表每行去内表查一次 外表小、内表有索引 外表被低估时急剧劣化
哈希连接 Hash Join 对小表建哈希表,扫大表探测 两表都较大、无可用索引 相对稳定,内存不足时落盘
归并连接 Merge Join 两表按连接键排序后归并 连接键已有序或可排序 依赖排序代价

用流程表示一次嵌套循环的执行顺序:

text 复制代码
计划树(简化)
  Nested Loop
     |-- 外表 Index Scan (orders, user_id=10086)  rows=20 实际=20
     |-- 内表 Index Scan (order_items, order_id=...) loops=20

执行顺序:
  取外表第 1 行 -> 用 order_id 查内表 -> 得到匹配行
  取外表第 2 行 -> 用 order_id 查内表 -> 得到匹配行
  ...
  取外表第 N 行 -> 用 order_id 查内表 -> 得到匹配行
  外层行数 N 直接决定内层被执行多少次

结论:嵌套循环的成本几乎与外层估算行数成正比。估算翻倍,代价翻倍;估算偏小 100 倍,实际就可能慢 100 倍。 这也是为什么"行数估算错误"排在慢 SQL 根因的第一位。

7. 完整示例一:复现一次估算偏差

目标:在一个干净的表上,亲眼看到估算与实际行数的差距,以及它如何改变执行计划选择。

前置环境:PostgreSQL 14+,本地可执行 psql。

sql 复制代码
-- 简化代码:建表并构造倾斜数据
DROP TABLE IF EXISTS orders_demo;
CREATE TABLE orders_demo (
    id          bigserial PRIMARY KEY,
    user_id     bigint NOT NULL,
    amount      numeric(10,2) NOT NULL,
    created_at  timestamptz NOT NULL DEFAULT now()
);

-- 插入 20 万行:99% 归属少数大客户,制造数据倾斜
INSERT INTO orders_demo (user_id, amount)
SELECT
    CASE WHEN g % 100 = 0 THEN 10086 ELSE (g % 5000) + 1 END,
    (random() * 1000)::numeric(10,2)
FROM generate_series(1, 200000) AS g;

CREATE INDEX idx_orders_demo_user ON orders_demo (user_id);

-- 关键:先不 ANALYZE,让统计信息为空
EXPLAIN ANALYZE
SELECT * FROM orders_demo WHERE user_id = 10086;

关键步骤与预期结果:未 ANALYZE 时,规划器对表几乎一无所知,rows 估算可能是个很小的默认值,而实际返回约 2000 行。执行计划也许仍选索引扫描,但如果这个查询改成关联上层,偏差就会被放大。

接着执行 ANALYZE,再对比:

sql 复制代码
ANALYZE orders_demo;
EXPLAIN ANALYZE
SELECT * FROM orders_demo WHERE user_id = 10086;

此时估算 rows 会接近真实值。观察 actual rows 与 rows 是否收敛。

适用场景:任何怀疑统计信息过期的表。容易改错的地方:忘了 ANALYZE 是采样而非全量扫描,极端倾斜下仍可能估歪;也不要指望 ANALYZE 能自动发现多列相关性。

8. 统计信息目标:给关键列"加采样"

如果 ANALYZE 之后估算依然不准,下一步就是调统计信息目标。它控制的是"每列收集多少份样本、直方图分几个桶",默认值是 100。对普通列够用,但对付高度倾斜或取值极多的列,往往不够。

可以按列单独设置:

sql 复制代码
-- 提升关键列的采样精度
ALTER TABLE orders_demo ALTER COLUMN user_id SET STATISTICS 1000;

-- 针对多列相关性,建立扩展统计信息
CREATE STATISTICS stat_orders_user_amount (dependencies)
    ON user_id, amount FROM orders_demo;

-- 重新收集
ANALYZE orders_demo;
调整手段 作用 代价 何时使用
SET STATISTICS 提高单列样本量,桶更细 ANALYZE 更慢、存储略增 单列严重倾斜
CREATE STATISTICS (dependencies) 记录列间函数依赖 额外统计开销 多列高度相关
CREATE STATISTICS (ndistinct) 修正多列去重估算 额外统计开销 分组估算偏
手动 ANALYZE 立即刷新统计 瞬时 I/O 大批量写入后

注意边界:统计信息目标不是越大越好。设成 10000 会让 ANALYZE 变慢、pg_statistic 膨胀,收益递减。通常 500 到 1000 已经能覆盖大多数倾斜场景。

9. 完整示例二:Spring Boot 侧的慢 SQL 观测

目标:在 Spring Boot 应用里打开执行计划观测,把数据库的估算偏差和应用日志关联起来。

前置环境:Spring Boot 3.x + PostgreSQL 驱动 + MyBatis,本地数据库可达。

第一步,确认数据源配置。PostgreSQL JDBC 支持通过选项把执行计划日志带到服务端:

yaml 复制代码
# application.yml
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/shop?options=-c%20log_min_duration_statement%3D200
    username: app
    password: secret
    hikari:
      maximum-pool-size: 10
      connection-timeout: 3000

这行配置的含义是:让执行超过 200 毫秒的语句写入 PostgreSQL 日志。它不是打印计划,而是先帮你"抓住慢语句",随后用 EXPLAIN ANALYZE 逐条分析。

第二步,写一个可运行的最小验证类,直接用 JDBC 执行 EXPLAIN ANALYZE:

java 复制代码
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.Statement;

public class ExplainDemo {

    public static void main(String[] args) throws Exception {
        String url = "jdbc:postgresql://localhost:5432/shop";
        String sql = "EXPLAIN (ANALYZE, BUFFERS) "
                   + "SELECT * FROM orders_demo WHERE user_id = 10086";

        try (Connection conn = DriverManager.getConnection(url, "app", "secret");
             Statement stmt = conn.createStatement();
             ResultSet rs = stmt.executeQuery(sql)) {
            while (rs.next()) {
                System.out.println(rs.getString(1));
            }
        }
    }
}

关键步骤:运行后控制台会逐行输出计划树,包括实际的 rows 和 loops。预期结果是你能在应用进程里直接拿到计划,不必登数据库。

适用场景:想快速验证某条 MyBatis 语句的真实计划。容易改错的地方:EXPLAIN ANALYZE 会真正执行语句,对写操作要在事务里回滚,否则会产生副作用;生产环境避免对写语句使用它。

10. 完整示例三:从偏差到修复的完整闭环

目标:模拟一个"估算偏小导致嵌套循环灾难"的场景,并通过调整统计信息和 SQL 修复它。

前置环境:沿用示例一的 orders_demo,再建一张明细表。

sql 复制代码
DROP TABLE IF EXISTS items_demo;
CREATE TABLE items_demo (
    id         bigserial PRIMARY KEY,
    order_id   bigint NOT NULL,
    price      numeric(10,2) NOT NULL
);

-- 每个订单约 3 条明细
INSERT INTO items_demo (order_id, price)
SELECT o.id, (random() * 100)::numeric(10,2)
FROM orders_demo o
CROSS JOIN generate_series(1, 3);

CREATE INDEX idx_items_demo_order ON items_demo (order_id);

-- 故意制造偏差:先让 orders_demo 的统计信息停留在旧状态
ANALYZE orders_demo;
INSERT INTO orders_demo (user_id, amount)
SELECT 10086, 1.00 FROM generate_series(1, 80000);
-- 此时不 ANALYZE,制造估算滞后

EXPLAIN (ANALYZE, BUFFERS)
SELECT o.id, i.price
FROM orders_demo o
JOIN items_demo i ON i.order_id = o.id
WHERE o.user_id = 10086;

预期结果:orders_demo 的估算 rows 明显小于实际,规划器可能选 Nested Loop,loops 值很大,实际耗时远高于估算。

修复动作:

sql 复制代码
ANALYZE orders_demo;

EXPLAIN (ANALYZE, BUFFERS)
SELECT o.id, i.price
FROM orders_demo o
JOIN items_demo i ON i.order_id = o.id
WHERE o.user_id = 10086;

关键步骤与观察点:ANALYZE 后估算 rows 上升,规划器可能从 Nested Loop 切换为 Hash Join 或调整访问路径。对比两次输出的 actual time 顶部节点,通常能看到显著差异。

适用场景:批量写入、数据迁移、大促前预热后必须验证计划。容易改错的地方:不要只盯着单项指标,必须同时对比顶部节点的执行时间;有时计划切换了但总耗时没变,说明瓶颈在别处,例如缺索引或返回列过多。

11. 代价模型里被忽视的参数

除了统计信息,代价模型的参数本身就是偏差来源。尤其是存储介质从机械盘换成 SSD 之后,默认参数往往不再匹配现实。

参数 默认值 现实含义 SSD 下的建议
seq_page_cost 1.0 顺序读一页的代价 保持 1.0 作为基准
random_page_cost 4.0 随机读一页的代价 常见调整到 1.1~1.5
effective_cache_size 4GB 规划器认为可用于缓存的内存 设为内存的一半到四分之三
work_mem 4MB 每个排序/哈希节点可用内存 过高会放大并发内存

这里最容易误解的是 effective_cache_size。它不分配内存,只是告诉规划器"有多少数据可能命中 OS 缓存",从而更愿意选索引扫描。把它调太小,规划器会误以为索引扫描要频繁读盘,反而选择顺序扫描。

调整方式可以在会话级,也可以按库:

sql 复制代码
-- 会话级,用于验证假设
SET random_page_cost = 1.1;
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders_demo WHERE user_id = 10086;

边界:全局修改前必须在业务低峰验证。代价参数影响所有查询,改错方向可能让一批原本正常的语句变慢。

12. 常见误区

下面这些误解,几乎每个排查慢 SQL 的人都踩过。

误区一:EXPLAIN 里的 cost 就是毫秒。它不是,只是相对单位。要看真实耗时,必须用 EXPLAIN ANALYZE 的 actual time。

误区二:估算 rows 差一点没关系。恰恰相反,嵌套循环下误差会被外层行数放大,差 100 倍就可能慢 100 倍。

误区三:加了索引就一定快。索引扫描是否被选,取决于规划器对行数和代价的估算。小表全表扫描可能比走索引更便宜,这是合理选择,不是 bug。

误区四:ANALYZE 一次就永远准。数据持续变化,统计信息会过期。自动清理依赖 autovacuum 的触发阈值,大表往往需要显式调度。

误区五:所有问题都能靠调参数解决。如果 SQL 写法本身导致了无法走索引(例如在索引列上做函数运算),再调参数也没用。

13. 生产实践建议

把上面的机制落成可执行的习惯:

第一,把 EXPLAIN ANALYZE 纳入上线前的 SQL 评审。对涉及大表关联的语句,要求提交计划对比截图。

第二,为关键表配置统计信息目标和扩展统计,尤其是多列过滤和高倾斜列。不要等出了问题才处理。

第三,批量写入、数据迁移、大促预热之后,主动 ANALYZE 关键表,避免规划器在统计滞后的窗口里选错计划。

第四,在 PostgreSQL 侧开启 auto_explain,把慢语句的自动执行计划记录进日志,结合 Spring Boot 侧的链路追踪定位接口。

第五,SSD 环境评估 random_page_cost 与 effective_cache_size,但只在数据支撑下调整,不凭感觉。

14. 排障清单

遇到慢 SQL 时,按这个顺序走,避免乱试。

text 复制代码
1. 拿到语句与参数
     |
2. EXPLAIN (ANALYZE, BUFFERS) 执行
     |
3. 对比每个节点 估算 rows vs 实际 rows
     |--- 差距小 --> 看 actual time 找真实热点(排序/落盘/返回列多)
     |--- 差距大 --> 进入统计信息排查
     |
4. 检查统计信息是否过期:pg_stat_user_tables.last_analyze
     |
5. ANALYZE 关键表后重跑 EXPLAIN
     |--- 仍未收敛 --> 调 SET STATISTICS / 建扩展统计
     |
6. 观察是否仍选嵌套循环且 loops 巨大
     |--- 是 --> 检查 cost 参数是否匹配硬件
     |
7. 验证修复:对比顶部节点 actual time

每一步都要记录"改前/改后"的对比,否则无法判断是否真的有效。

15. 面试与复盘问题

这些问题适合自测是否真正理解本文内容:

  1. EXPLAIN 和 EXPLAIN ANALYZE 的核心区别是什么?各自适合什么场景?
  2. 为什么嵌套循环对行数估算偏差特别敏感?换成哈希连接会怎样?
  3. 统计信息目标默认 100,什么情况下需要调大?调大的代价是什么?
  4. cost 为什么不是时间?哪些参数会影响它?
  5. 多列相关性为什么会让估算偏低?扩展统计是如何修正的?
  6. 在 Spring Boot 项目里,如何把接口慢和数据库执行计划关联起来?

16. 一页纸决策框架

最后把分散的知识收进一张判断表。

现象 最可能原因 优先动作
估算 rows 远小于实际 统计过期或倾斜 ANALYZE,必要时调 STATISTICS
选了嵌套循环且 loops 极大 外层行数被低估 修正估算,或改写 SQL 限制外层
cost 很低但 actual time 很高 代价模型与硬件不符 评估 random_page_cost
同 SQL 测试库快、生产慢 数据分布或统计不同 对比两边统计与执行计划
多列过滤估算持续偏低 列间相关性 建扩展统计

一句话总结决策逻辑:先确认估算是否准确,再确认代价模型是否匹配硬件,最后才考虑改 SQL 或加索引。 顺序颠倒,就会一直在治标。

17. 总结

PostgreSQL 的优化器本质上是在用统计信息"算账",估算行数是所有决策的起点。EXPLAIN 让你看到它的想法,EXPLAIN ANALYZE 让你看到它估算与事实的差距。行数估算一旦偏离,嵌套循环会把这个误差成倍放大,最终表现为接口从毫秒变成秒级。

排查的正确路径是:先拿到真实计划,对比估算与实际行数,找到偏差最大的节点,再回头检查统计信息是否过期、是否需要调高统计信息目标或建立扩展统计,最后评估代价参数是否匹配当前硬件。把这条链路走顺,慢 SQL 就从"玄学"变成了可复现、可验证的工程问题。

18. 参考资料

相关推荐
云和数据.ChenGuang1 小时前
Spring AI 对接 LangGraph:构建企业级智能体工作流实战
java·人工智能·spring boot·后端·spring
花月C1 小时前
Agent可观测性篇:Grafana
postgresql·grafana
RuoyiOffice3 小时前
Spring AI+Vue3 企业知识库设计:让每个答案有依据,让每份资料守住权限
java·spring boot·vue3·rag·企业知识库·spring ai·ruoyi office
子沫20204 小时前
mysql慢sql
sql·mysql
余槐i13 小时前
PostgreSQL 函数索引报错 must be immutable:3种绕法与代价
数据库·后端·sql·postgresql·性能优化
数据库小学妹13 小时前
PostgreSQL表膨胀怎么办?autovacuum清不动的根因与排查命令
数据库·postgresql·表膨胀·mvcc·数据库运维
凤山老林15 小时前
Spring Boot 接入国密 SM2/SM3/SM4 的一些实践记录
spring boot·后端·python
伯恩bourne16 小时前
Spring Boot Bean 注册的两种方式:@Component 家族 vs @Configuration + @Bean
java·spring boot·后端
省钱兄--zs16 小时前
深圳24小时自助健身房软硬件解决方案实战指南与开发流程详解
java·数据仓库·spring boot·小程序·架构