阅读Postgres中的EXPLAIN ANALYZE而不迷失方向

读懂PostgreSQL中的EXPLAIN ANALYZE输出,不再迷失方向。学习解读查询计划、定位性能瓶颈并优化数据库性能。

  • MIT
  • 更新于 2026-05-15

第一次看到Postgres的EXPLAIN ANALYZE输出时,它看起来就像一棵挂满数字的圣诞树。对于你实际关心的问题——为什么这个查询这么慢?——其中大部分数字都只是噪音。

看过几百份这样的输出之后,下面是我阅读它们的顺序。

用一个查询来锚定思路 #

EXPLAIN (ANALYZE, BUFFERS)
SELECT u.id, u.email, count(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.signup_at > now() - interval '30 days'
GROUP BY u.id;

一段典型的输出片段:

HashAggregate  (cost=12345.67..23456.78 rows=10000 width=48)
               (actual time=412.331..480.219 rows=8742 loops=1)
  ->  Hash Right Join  (cost=2345.67..11234.56 rows=120000 width=44)
                       (actual time=18.221..380.115 rows=98213 loops=1)
        ...
        Buffers: shared hit=18234 read=4521

这份输出已经足够用来演示这套方法了。

第一步:看顶层行的actual time #

最外层节点的第二个actual time值,就是整个查询的实际墙钟耗时(以毫秒为单位,指该节点单次执行的耗时)。在上面的例子中:480毫秒。查询计划里的其他所有内容,都是在拆解这480毫秒具体花在了哪里。

如果顶层数字看起来没问题,但你正在追查一份"慢查询"报告,请再三确认你EXPLAIN的查询,和应用程序实际运行的查询是同一个。不同的参数值会产生不同的查询计划。

第二步:对比rows=的估计值与实际值 #

每一行都有两个行数:

  • cost=…部分中的rows=N——规划器的估计值
  • actual time=…部分中的rows=N——真实发生的情况

当这两者相差10倍或更多时,说明规划器使用的统计信息有问题,它上层的每一个节点都是基于一个错误假设做出的选择。这几乎总是你要找的那个问题所在。

在上面的例子中,规划器预期这次连接会产生120,000行,实际产生了98,213行。这没什么大不了的,误差约20%。但如果我看到类似"估计100,实际1,000,000"这样的情况——立刻打住,这就是问题所在。常见原因包括:

  • 统计信息过时 → 运行ANALYZE the_table,然后重新EXPLAIN。
  • 列之间存在相关性 → 对这组列执行CREATE STATISTICS,或者重写谓词条件。
  • 规划器无法建模的数据倾斜 → 有时你需要借助pg_hint_plan给出提示,或者重写查询。

第三步:找出时间到底花在了哪里 #

每个节点的actual time=A..B loops=L读作:“开始后A毫秒产生第一行,B毫秒产生最后一行;该节点共运行了L次。”

要得到仅该节点本身(不含子节点)花费的时间,你需要用它的actual time范围减去所有子节点的actual time范围。对于最常见的loops=1情形,简化算法是:

自身耗时 ≈ 该节点的B − 所有子节点B值之和

我会从上到下扫描整个计划,寻找自身耗时最大的那个节点。那里才是应该投入优化预算的地方。

对于loops=N且N较大的节点(比如Nested Loop的内侧),报告的是每次循环的耗时。需要乘以loops才能得到总耗时。

第四步:用BUFFERS区分I/O开销和CPU开销 #

EXPLAIN (ANALYZE, BUFFERS)会额外添加如下几行:

Buffers: shared hit=18234 read=4521
  • shared hit——已经存在于Postgres缓冲区缓存中的页面数。开销低。
  • shared read——从操作系统/磁盘读取的页面数。开销高。
  • temp written / read——排序或哈希操作因为超出work_mem而溢出到磁盘的数据量。同样开销很高。

如果read占主导,说明查询计划本身没问题,只是数据没有被缓存。可以把查询运行两次——第二次运行更能代表稳定状态下的真实表现。如果两次运行都因为read过高而很慢,说明工作集装不下,你需要更多内存、一个能覆盖更少页面的索引,或者一个更小范围的查询。

如果你在SortHash节点上看到temp written,请为该会话调高work_mem,然后重新EXPLAIN。溢出到磁盘很容易让一个节点的耗时增加10倍。

我最常见到的三种模式 #

排查过所有这些之后,实际的问题往往可以归为几种类型:

1. 对"本应建索引"的列做了顺序扫描。 计划节点显示Seq Scan on big_table Filter: (...),并且被过滤器剔除的行数(rows-removed-by-filter)非常大。给过滤列加个索引。但如果这张表本身很小,或者过滤条件的选择性不高,就不要加索引——规划器的选择是对的。

2. 本该是Hash Join,却出现了Nested Loop。 内层运行了成千上万次。这几乎总是由上游行数估计过低导致的(也就是第二步的那个问题)。修复统计信息或重写谓词条件,规划器就会选择正确的连接方式。

3. 本该先过滤再连接,结果先连接后过滤。 查询计划先把所有数据连接起来,再进行过滤。应该把过滤条件推入子查询或CTE中,让它在连接之前就生效,从而减少连接需要处理的行数。

一份快速阅读清单 #

当有人把一份EXPLAIN ANALYZE递给我时,我会按以下顺序处理:

  1. 顶层的总耗时——它真的慢吗?
  2. 有没有哪个节点的rows估计值与实际值相差≥10倍?——那就是问题所在。
  3. 哪个节点的自身耗时最大?——那就是优化预算该投向的地方。
  4. 有没有temp written,或异常高的shared read?——这是I/O问题。
  5. 把这个慢节点对应到上面三种模式中的一种。

就是这样。这并不是什么魔法,但顺序很重要——如果在检查行数估计之前,就先去追查自身耗时最大的节点,你很可能只是在优化一个糟糕计划所表现出来的症状,而没有修复计划本身。

相关文章 #


推荐工具 #

对于正在构建或部署开源AI工具的开发者,我们推荐:

  • DigitalOcean — 新用户可获得200美元免费额度,覆盖14个以上的全球节点,一键部署的GPU/CPU Droplet非常适合AI工作负载。
  • Shiyunapi Claude API — Anthropic Claude / OpenAI / DeepSeek API代理。上面提到的大多数AI工具(聊天机器人、代码生成、翻译、搜索等)都需要一个LLM API密钥——这个代理服务能以官方定价约30%的价格,提供对顶级模型的稳定访问。

联盟链接——在不产生任何额外费用的情况下支持dibi8.com。

💬 留言讨论