☰
PostgreSQL执行计划详解:从读懂EXPLAIN到SQL调优实战
2026/10/22 11:09:18 网站建设 项目流程

说个我经常遇到的情况:线上接口突然变慢,翻出慢查询日志一看,一条 SQL 要跑十几秒。很多人第一反应是“加索引”,但索引加完有时候还是慢,这时候数据库会告诉你真相——它到底是怎么执行这条 SQL 的。这就是 PostgreSQL 的执行计划。写这篇内容,是想把执行计划的读法和调优思路完整梳理一遍,后端开发、DBA、数据岗位的同学应该都用得上,新手可以从零开始看懂,老手也可以顺手对照一下自己的排障套路。

1. 执行计划是什么,为什么它是调优的地图

1.1 优化器是怎么“想”的

PostgreSQL 拿到一条 SQL 之后,并不是直接按写好的顺序去跑。它会把 SQL 解析成语法树,再根据表结构、索引、数据分布这些信息生成多个候选执行方案,最后用一套成本模型算出每个方案的大概开销,挑一个它认为最低的去执行。这个被挑中的方案,就是执行计划,本质上是一棵“操作树”。

你可以把优化器当成一个导航软件,SQL 只是目的地,导航会规划出好几条路线:走高速、走国道、绕小路,最终选一条它认为最快、最省油的。执行计划就是那条选中的路线,里面的每个节点就是一次转向或一个路段。比如 Seq Scan 是“全路段慢速通行”,Index Scan 是“走快速路”,Hash Join 是“先在路口建个临时立交桥再对接”。

理解这一点很重要,因为调优的本质不是“优化 SQL 的写法”,而是“让优化器选到更快的执行路线”。你写的 SQL 只是需求描述,真正决定性能的是优化器基于统计信息做出来的那个选择。

1.2 慢查询日志和监控只是先头部队

我见过不少同事,排查慢查询时只看慢日志里的耗时,然后凭感觉加索引,或者把 SQL 拆开多线程跑。慢日志确实能告诉你“哪条 SQL 慢”,但它不知道“为什么慢”,是扫描慢?连接慢?排序慢?还是中间结果太大?

执行计划回答的恰恰是这个问题。它把一条 SQL 的执行过程拆成了一个个节点,每个节点都有估算行数、实际耗时、扫描方式、连接方式,一眼就能看出瓶颈卡在哪个环节。没有执行计划做调优,就像修车只看仪表盘上的故障灯,不看发动机舱,永远只能猜。

所以这篇内容的定位很明确:教你用执行计划当排障工具,从看懂输出到定位瓶颈,再到给出一套可以直接抄的优化流程。

2. 先把执行计划读明白:EXPLAIN 的每一列都是线索

2.1 先分清 EXPLAIN 和 EXPLAIN ANALYZE

很多新手上来就跑EXPLAIN SELECT ...,看到输出里一堆 cost 和 rows,还是不知道慢在哪。这里有个关键区别:

  • EXPLAIN只生成执行计划,不真正执行 SQL,输出的是优化器根据统计信息估算出来的“预判”。
  • EXPLAIN ANALYZE会真正执行这条 SQL,并输出每个节点实际的耗时、实际行数、循环次数。

两者的关系就像是“天气预报”和“实测天气”。预判不一定准,尤其统计信息过期的时候,EXPLAIN看着很完美,一执行就翻车,所以排查真实性能问题时,一定要用EXPLAIN ANALYZE。

顺手推荐一个组合,输出信息更全,比默认格式好看很多:

EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT * FROM orders WHERE user_id = 123;

BUFFERS会告诉你每个节点读了多少个内存块和磁盘块,FORMAT JSON在计划复杂时更好定位层级关系。注意ANALYZE会真跑 SQL,如果是 UPDATE、DELETE、INSERT 这类写操作,最好包在一个事务里最后 ROLLBACK,避免线上数据被改动:

BEGIN; EXPLAIN ANALYZE DELETE FROM orders WHERE created_at < '2020-01-01'; ROLLBACK;

2.2 从里往外读:执行顺序和节点类型

执行计划的输出有一个固定的阅读规则:缩进最深的节点先执行,缩进相同的节点从上往下执行。它是一棵从下往上算的树,每个上层节点都在等待下层节点产出数据。

以这个计划为例:

Hash Join (cost=1.14..4195.07 rows=4518 width=16) Hash Cond: (o.user_id = u.id) -> Seq Scan on orders o (cost=0.00..3208.39 rows=118239 width=8) -> Hash (cost=1.10..1.10 rows=110 width=16) -> Index Scan using users_pkey on users u (cost=0.29..1.10 rows=110 width=16)

阅读顺序是先看最底层的Index Scan,再往上是Hash(把扫描结果做成哈希表),再看中间的Seq Scan,最后是Hash Join。凡是看到这种嵌套结构,都要先在脑海里把它还原成“先扫描 A -> 再扫描 B -> 然后连接 -> 最终返回结果”的过程。

常见的节点类型也不算多:

  • Seq Scan:全表扫描,顺序读整个表。
  • Index Scan:索引扫描,通过索引定位到表里的行。
  • Bitmap Index Scan+Bitmap Heap Scan:先用索引把匹配行的位置收集成位图,再回表读取,适合返回行数较多的情况。
  • Sort:排序,如果内存不够会溢写到磁盘,出现Sort Method: external merge就要警惕。
  • Hash/Hash Join:把一侧数据建成哈希表,然后另一侧逐行探测。
  • Nested Loop:嵌套循环,外表每行去内表找匹配行。
  • Aggregate/GroupAggregate/HashAggregate:聚合计算。

看计划不需要记住所有细节,但至少要能分辨:这个查询是扫描还是连接?用的是哪种扫描和连接方式?数据量估算和实际偏差大不大?这三点决定了 80% 的调优方向。

2.3 cost 里的数字到底怎么看

计划里每个节点都带cost=xx..yy,中间的rows是优化器估算的行数。很多人问:这个 cost 单位是毫秒吗?不是。PostgreSQL 的 cost 是一个“任意代价单位”,它综合了磁盘 IO、CPU 处理、内存操作等多项加权值,主要用途是让优化器在不同方案之间比较,而不是直接换算成真实时间。

所以在EXPLAIN里看到 cost 大小没有直接意义,重要的是用它和其他方案横向对比。真正反映性能的是EXPLAIN ANALYZE里每个节点的actual time和actual rows。

一个非常实用的检查动作:把估算行数 rows 和实际行数 actual rows 对比。如果两者差了几十倍甚至上千倍,基本可以确定统计信息有问题,优化器在拿一张“错误的地图”导航,后面选出来的计划自然不靠谱。这种偏差是很多“看起来很奇怪”的执行计划的根源。

3. 识别性能杀手:最常见的几种执行计划瓶颈

3.1 全表扫描不一定是坏事,索引扫描也不一定是好事

这是新手最容易踩的误区。看到 Seq Scan 就紧张,觉得一定要把它换成 Index Scan。但全表扫描在表很小,或者查询要返回表里大部分数据的时候,其实非常高效,因为它顺序读磁盘的性能比随机读要好得多。比如一张只有几百行的配置表,全表扫描几毫秒就完事,加了索引反而多余。

反过来,索引扫描也不是免费的。它需要先从索引里找到行指针,再回表随机读数据,如果满足条件的行数非常多,比如占了全表 30% 以上,优化器宁愿全表扫也不走索引。

但如果你在计划里看到一个明显异常的情况——大表、选择性很强的过滤条件,却依然是 Seq Scan,那大概率是下面几种原因:

  • 索引没建,或者建错了列。
  • 查询条件里对索引列做了函数处理,比如WHERE DATE(created_at) = '2024-01-01',导致索引失效。
  • 字段类型不匹配,比如 varchar 列和数字比较,PostgreSQL 可能走隐式转换,导致索引用不上。
  • 统计信息过期,优化器低估了返回行数,以为全表扫描更划算。

遇到这种情况,先查索引定义,再查统计信息新鲜度,最后看 SQL 写法,一步一步排查。

3.2 三种连接方式:Nested Loop、Hash Join、Merge Join

连接方式的选择是整个执行计划里最影响性能的部分。我接触过的慢查询,一多半问题出在这。

Nested Loop是最朴素的方式,两层循环逐行匹配。它的优势是内存占用极小,而且如果外表数据量小、内表连接列有索引,效率非常高。比如 10 行外表去索引查 100 万行的表,每次都走索引,总共也就几十次随机读,很快。但如果外表也很大,比如几十万行,每行都要去内表探测一次,几十万次随机 IO 就能让你怀疑人生。

Hash Join的思路是先把一侧表读出来,在内存里建一个哈希表,然后另一侧每行直接探测。它适合两个表都没有索引,或者数据量都很大的场景。PostgreSQL 的优化器通常会选较小的表作为哈希表的驱动侧,以节省内存。但要注意,如果驱动表比它估算的大得多,哈希表放不进work_mem,就会溢写到磁盘,性能断崖式下跌。

Merge Join要求两侧输入已经按连接键排好序,然后像拉链一样归并匹配。如果两侧数据都已经是有序的,比如来自索引扫描,或者查询本身需要排序,Merge Join 就很合适,它不支持索引探测,但两边各扫一遍就好。

三种方式的对比可以整理成一张表:

连接方式适用场景最大隐患
Nested Loop外表小、内表连接列有索引外表估算不准导致循环次数爆炸
Hash Join大表连接,无索引或数据量平均驱动表被低估导致哈希表溢出磁盘
Merge Join两侧输入有序,或需要有序输出额外排序带来的内存和 CPU 开销

优化器一般会根据统计信息选最优方式,但统计信息不准时经常选错。我在调优时有一个很实用的技巧:如果怀疑某种连接方式有问题,可以临时关掉它试试效果,比如SET enable_hashjoin = off;再跑一遍同样的 SQL 看计划。这是诊断手段,线上不要长期改这些参数,后面会展开说。

3.3 “rows=1” 的灾难:统计信息失准

执行计划读得多了,你会慢慢形成一种直觉:优化器的所有决策都是基于统计信息做的,统计信息一旦失真,再好的 SQL 也白搭。

我遇到过一次非常典型的案例。某个项目里有一个订单表和商品表,关联查询时优化器估算“扫描商品表返回 1 行”,但实际返回了 120 万行。因为估算错了,优化器选了 Nested Loop,用 120 万行的商品表去驱动查询,内层的订单表被反复探测了一百多万次,单个接口直接超时。

造成统计信息失准的原因通常有三个:一是表数据发生了大规模变化,但ANALYZE还没跑;二是列之间存在相关性,单列统计信息无法描述组合条件;三是表达式上的统计信息缺失,比如对函数表达式建了索引却没有对应的统计信息。

排查办法很简单,先手动执行ANALYZE 表名;让统计信息更新,再看估算行数是否恢复。如果还不行,可以用CREATE STATISTICS建扩展统计信息,专门解决多个列组合查询时估算不准的问题。这个功能很多人不知道,但在处理多列联合过滤时非常有用。

4. 把计划变成调优指令:一套可落地的优化流程

4.1 先拿基线:在复制环境或低峰期 EXPLAIN ANALYZE

调优的第一步永远是拿数据,不是拿直觉。我习惯在测试环境或者线上低峰期,先跑一遍EXPLAIN (ANALYZE, BUFFERS),把下面这些信息记录下来:

  • 总耗时和耗时最高的节点。
  • 每个节点的实际行数和估算行数偏差。
  • 有没有Sort Method: external merge、Buffers: shared hit/read数量巨大之类的危险信号。

为什么不直接在生产环境跑?因为ANALYZE会占用真实资源,高并发时段跑一条重型 SQL,本身就是对数据库的一次冲击。稳妥的做法是先在备库或低峰期验证,确认计划之后再去线上处理。

拿到基线之后要做的,是找出耗时占比最大的节点,然后回答一个问题:这个节点为什么会花这么多时间?是扫描的数据量大,还是连接次数多,还是排序溢出到磁盘了?确认了瓶颈所在,再往下走就有针对性了。

4.2 对症下药:从瓶颈节点反推优化动作

不同的瓶颈节点,对应的调优手段完全不同,千万别一套“加索引”走天下。

如果瓶颈是Seq Scan:检查过滤条件的列有没有索引,索引列有没有被函数包裹,统计信息是否需要更新。需要注意的是,有些场景下全表扫描反而是最优解,不要为了消除 Seq Scan 而去调优。

如果瓶颈是Nested Loop:看驱动的外表实际行数是不是被低估了。如果外表真实数据量很大,优化器应该选 Hash Join,这时基本是统计信息问题,先ANALYZE。如果统计信息正常但连接仍然很慢,检查内表连接列有没有索引,没有索引的 Nested Loop 会退化成双重全表扫描。

如果瓶颈是Sort:看是否真的需要全量排序。常见的优化方式有:利用索引天然有序来消除排序、ORDER BY和GROUP BY写同一列以复用排序结果、把排序推到子查询里减少参与排序的数据量。实在需要大排序时,可以适当调大work_mem,但要记住它每个连接都可能占用,不能无脑调太大。

如果瓶颈是Hash:检查驱动表是不是优化器意料之外的大表,哈希表溢出到磁盘会体现在Buffers: written和该节点耗时暴增上。这时候优化统计信息、改写 SQL 让更多过滤条件下推到连接之前,比单纯加内存更有效。

4.3 一个实战案例:把 10 秒查询压到 200 毫秒

光讲方法没有体感,我拿一个真实的调优过程出来说说。某项目里有个数据查询接口,需要从订单明细表和商品表关联后按天汇总金额,原 SQL 大概是这样的:

SELECT d.dt, count(*), sum(d.amount) FROM order_detail d JOIN product p ON d.product_id = p.id WHERE p.category_id = 10 GROUP BY d.dt ORDER BY d.dt;

线上跑了 10 秒出头,前端超时。用EXPLAIN (ANALYZE, BUFFERS)跑出来的计划里,最刺眼的是order_detail表走了Seq Scan,扫描了 1.2 亿行,实际命中却只有几万行。反过来product表由于category_id = 10过滤后只剩 2000 多行,但优化器没有先做这个过滤。

第一步,先更新统计信息,手动执行ANALYZE order_detail; ANALYZE product;。再跑计划,发现product表开始走Index Scan了,但order_detail还是全表扫。原因是order_detail和product的关联列product_id上没有索引,优化器觉得先扫全表再做 Hash Join 比随机扫描更划算。

第二步,在订单明细表上建索引:

CREATE INDEX idx_order_detail_product_id ON order_detail(product_id); CREATE INDEX idx_order_detail_product_dt ON order_detail(product_id, dt);

第二个是覆盖部分场景的复合索引,关联和分组都能用上。再跑计划,order_detail从 Seq Scan 变成了Bitmap Index Scan,经过过滤后的数据量只有几十万行,Hash Join 的耗时大幅下降。

第三步,发现仍然有将近 1 秒耗在Sort上,因为GROUP BY d.dt和ORDER BY d.dt都在做排序。这时候把order_detail的复合索引改成(product_id, dt)并不能覆盖ORDER BY d.dt的场景,因为排序列不是索引最前列。这里我选择的是在 SQL 写法上做一个小改动,先把过滤后的商品 ID 集合作成临时结果,再用JOIN LATERAL或直接IN子句缩小扫描范围,本质上是让优化器更清晰地知道数据量。

优化完成后,整个查询从 10 秒降到了 200 毫秒左右。最核心的转机不是某个单一操作,而是三件事的组合:更新统计信息让优化器看清数据分布、补上连接索引消除全表扫描、改写 SQL 让过滤操作提前。这也是我想强调的思路:调优不是单点操作,而是顺着执行计划一层一层把瓶颈剥掉。

5. 常见问题排查与高性价比技巧

5.1 建了索引为什么还是慢

这是社区里被问烂的问题。索引建了,EXPLAIN却显示没走,或者走了索引还是慢,排查方向建议按这个顺序来:

  • 先看索引列和查询条件列是否匹配,包括字段类型、长度、排序规则。
  • 看条件里有没有对列做函数或运算,WHERE product_id * 2 = 10这种写法基本无法用索引。
  • 看索引列的选择性,如果一列只有几个不同值,优化器会觉得走索引回表还不如全表扫。
  • 看统计信息新不新,表数据涨了之后多年不ANALYZE,优化器拿着旧地图找路。
  • 看是不是索引膨胀了,大量更新和删除之后死元组多,扫描大量死数据导致性能劣化,需要VACUUM或重建索引。

另外,多列索引不是建得越长越好。比如idx(a, b, c),它能帮助a等值 +b范围 +c排序的情况,但对单独的c条件无能为力。根据实际查询模式组合索引,比建一个巨大的索引更有效。

5.2 enable_xxx 开关:调试利器,不是长期方案

PostgreSQL 提供了一批enable_开头的开关,比如enable_seqscan、enable_hashjoin、enable_nestloop、enable_sort,可以用来临时禁用某种执行方式。我在调优时经常用它们做“假设验证”:关掉 Seq Scan 看看优化器会不会选索引,如果计划变好了,说明统计信息或成本参数有问题;关掉 Hash Join 看会不会选 Nested Loop,可以确认之前的估算偏差。

但这些东西不能当作长期调优手段。关闭某种执行方式等于把优化器的选项砍掉,数据分布一变,原本被禁用的方案可能才是最优的,结果会适得其反。我见过有人把enable_seqscan = off写进配置,结果数据量大了之后大量查询反而更慢。正确的姿势是用这些开关做诊断,改完之后恢复默认,把真正的问题通过索引、统计信息、SQL 改写解决掉。

5.3 几个低成本高收益的日常习惯

调优不一定每次都是大动干戈,日常维护里有几个习惯性价比极高:

  • 给大表设置合理的自动ANALYZE频率。数据变更超过 10% 左右就应该跑一次,确保统计信息跟得上事实。
  • 打开auto_explain插件,自动记录慢 SQL 的执行计划。这样下次调优不用等故障复现,直接看历史日志就行:
shared_preload_libraries = 'auto_explain' auto_explain.log_min_duration = '1000' auto_explain.log_analyze = on

设置后超过 1 秒的查询会自动带上执行计划落到日志里,相当于给慢查询装了行车记录仪。

  • 定期查看pg_stat_user_tables,关注哪些表长期没有 ANALYZE、死元组比例大、全表扫描次数多。这些指标能提前暴露调优机会,而不需要等用户来投诉。
  • 最后提醒一个关于work_mem的教训。我早年为了加速一个大排序,把work_mem调到了 1GB,单条查询确实快了,但线上几十个并发同时跑,内存直接爆了。work_mem是每个排序、哈希操作都可能分配的,不是“一次查询一个”。调它之前先数清楚并发数,再按可承受的内存上限反推。

我个人现在调优 SQL 时,已经形成了“先 EXPLAIN ANALYZE、再看统计信息、再补索引或改写法”的固定套路,整个过程很少超过半小时。执行计划其实不神秘,它只是把数据库优化器的思考过程摊开给你看,读得越多,越能理解数据库为什么这么选、你该怎么帮它做更好的选择。如果你刚接触这块,不用强记所有节点和参数,先从跑一次EXPLAIN ANALYZE开始,把每一行输出当成线索去追问,几次下来就会有自己的判断节奏。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询