阅读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过高而很慢,说明工作集装不下,你需要更多内存、一个能覆盖更少页面的索引,或者一个更小范围的查询。
如果你在Sort或Hash节点上看到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递给我时,我会按以下顺序处理:
- 顶层的总耗时——它真的慢吗?
- 有没有哪个节点的
rows估计值与实际值相差≥10倍?——那就是问题所在。 - 哪个节点的自身耗时最大?——那就是优化预算该投向的地方。
- 有没有
temp written,或异常高的shared read?——这是I/O问题。 - 把这个慢节点对应到上面三种模式中的一种。
就是这样。这并不是什么魔法,但顺序很重要——如果在检查行数估计之前,就先去追查自身耗时最大的节点,你很可能只是在优化一个糟糕计划所表现出来的症状,而没有修复计划本身。
相关文章 #
- Python上下文管理器:你真正需要的三种场景 — Python资源管理
- Scrapling评测:更快、更隐蔽的Python爬虫方案 — 数据提取技术
- Agent Reach:赋予你的AI代理互联网超能力 — AI驱动的开发工具
推荐工具 #
对于正在构建或部署开源AI工具的开发者,我们推荐:
- DigitalOcean — 新用户可获得200美元免费额度,覆盖14个以上的全球节点,一键部署的GPU/CPU Droplet非常适合AI工作负载。
- Shiyunapi Claude API — Anthropic Claude / OpenAI / DeepSeek API代理。上面提到的大多数AI工具(聊天机器人、代码生成、翻译、搜索等)都需要一个LLM API密钥——这个代理服务能以官方定价约30%的价格,提供对顶级模型的稳定访问。
联盟链接——在不产生任何额外费用的情况下支持dibi8.com。
💬 留言讨论