1. 一条慢查询,从看懂执行计划开始
1.1 慢SQL排查第一步:让数据库告诉你它是怎么跑的
做PostgreSQL的人,迟早会遇到这么一天:某个平时毫秒级返回的查询,突然变成了秒级,甚至把生产库的CPU打满。这时候大部分人的第一反应是"加索引",但很多时候索引加了一堆,问题依旧。原因很简单——你连数据库是怎么执行这条SQL的都不清楚,凭什么判断瓶颈在哪?
EXPLAIN就是PostgreSQL给你的"作战地图"。它告诉我的是:优化器打算用哪种方式读取数据、先读哪张表、怎么连接、走没走索引、每一步估算处理多少行。拿到这张图,你才能从"瞎猜"变成"对症下药"。
我见过不少开发者,SQL写得很溜,但一看到EXPLAIN的输出就头大,满屏的"Seq Scan""Hash Join""cost=0.00..35.50"不知道是什么意思。这篇文章我从头到尾拆一遍,结合几个真实场景,带你从零开始学会读执行计划,并且能实际用来优化慢查询。
1.2 EXPLAIN 与 EXPLAIN ANALYZE:规划和实测的差别
先纠正一个几乎所有新手都会犯的错误:直接执行EXPLAIN得到的执行计划,只是优化器的"计划草案",它并没有真正跑SQL。后面那串cost、rows,全是基于统计信息算出来的估算值。
EXPLAIN SELECT * FROM orders WHERE user_id = 42;输出长这样:
Index Scan using idx_orders_user_id on orders (cost=0.28..8.30 rows=1 width=35) Index Cond: (user_id = 42)注意看,这里没有任何"实际耗时",因为这条SQL根本没执行。如果你想知道真实情况,必须用EXPLAIN ANALYZE:
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 42;这个命令会真实执行SQL,然后输出实际执行时间、实际处理行数、实际循环次数等信息。最重要的对比来了:
Index Scan using idx_orders_user_id on orders (cost=0.28..8.30 rows=1 width=35) (actual time=0.012..0.014 rows=1 loops=1)actual time那一列就是真相。优化器的估算和实际差多少,一眼就能看出来。
提示:
EXPLAIN ANALYZE会真实执行SQL,如果是UPDATE、DELETE或者耗时的查询,在测试环境执行没问题,生产环境要谨慎。我习惯用BEGIN; EXPLAIN ANALYZE ...; ROLLBACK;包一层,避免误操作。
2. 执行计划的原子单位:算子逐个拆解
2.1 扫描算子:Seq Scan、Index Scan 与 Index Only Scan
执行计划中最底层的操作就是"读数据",PostgreSQL里有几种主要的扫描方式。
Seq Scan(顺序扫描),简单说就是从头到尾把整张表翻一遍。表只有3000行的时候,全表扫也就几毫秒,完全够用。但如果表有3000万行,全表扫描就是灾难。很多新手一看到Seq Scan就慌,其实不用——优化器不是傻子,如果全表扫描估算成本更低,它就会选全表扫描。比如一张表总共才几百行,走索引反而更慢。
Index Scan(索引扫描),通过索引定位到匹配的行,再回表读取其他列数据。这里有个重要的概念叫"回表"。索引里通常只存了索引列的值和行指针,如果查询的列没有全部包含在索引中,就需要拿着行指针回表取完整行。每回一次表,就是一次随机IO。
Index Only Scan(仅索引扫描),是索引扫描的"最强形态"——查询需要的所有列都包含在索引里,不需要回表。比如你有idx_orders_created_at_status这个复合索引,索引里有created_at和status这两个列,而查询只要这两个字段,PostgreSQL就直接从索引返回结果,省掉了回表开销。我优化查询时,会刻意把常用查询的列全部塞进复合索引,目的就是让Index Only Scan生效。
还有一个容易被忽略的扫描方式是Bitmap Index Scan。当条件能命中的行数比较多(比如走了索引但匹配100万行),PostgreSQL会先生成一个"位图"标记哪些数据页有匹配行,再一次性批量读取这些页。这种方式的随机IO比单纯Index Scan逐行回表要少。执行计划里经常看到Bitmap Index Scan后面跟着Bitmap Heap Scan,这种搭配在中等选择性查询里很常见。
2.2 连接算子:Nested Loop、Hash Join、Merge Join 的取舍
多表JOIN是慢查询重灾区。读懂三种连接方式,基本就能看明白80%的复杂执行计划。
Nested Loop(嵌套循环连接),相当于两层for循环。外层表每取一行,内层表就完整扫一遍(或者走索引定位一把)。这种连接方式适合"小表驱动大表"的场景,内层表如果走了索引,性能会非常好。注意执行计划里会显示loops,如果loops显示很大,说明外层表数据量不小,内层每次都重新查,成本会成倍放大。
Hash Join(哈希连接),把一张表的连接字段放进内存哈希表,然后另一张表逐行过来匹配。这种方式适合两个表数据量都比较大、而且是等值连接的情况。哈希表是内存操作,速度很快。执行计划里会看到Hash算子和Hash Cond,那个就是哈希连接的条件。
Merge Join(归并连接),要求两张表都按连接字段排好序,然后像拉链一样顺序匹配。如果数据已经天然有序(比如走了索引),或者排序成本不高,Merge Join表现也不错。但注意,如果两边都有排序操作,排序本身也要算进总成本。
这里有个实战经验:优化器选哪种连接,不是你想控制就能控制的。它主要看估算行数、有没有索引、内存参数。与其纠结"怎么强制走Hash Join",不如思考怎么让优化器认为某种方式更便宜。后面我会讲参数调整。
2.3 排序与聚合:Sort、HashAggregate 与 GroupAggregate
执行计划里的Sort算子,大家都见过。ORDER BY、DISTINCT、GROUP BY、MERGE JOIN都可能触发排序操作。
PostgreSQL的排序有两种方式,内存排序和磁盘外部排序。执行计划里如果显示Sort Method: quicksort Memory: 25kB,说明在内存里搞定了,很快。但如果显示Sort Method: external merge Disk: 3192kB,那就是内存不够用,排到磁盘上去了,性能断崖式下跌。这里跟work_mem参数直接相关,后面我会详细讲。
聚合也有两种常见算子。HashAggregate,把分组键做哈希,内存里分组聚合,速度快。GroupAggregate,要求输入数据已经排好序,然后顺序扫描合并分组。执行计划里如果看到GroupAggregate前面带着Sort,那成本通常比HashAggregate高。
我遇到过一类的慢查询:GROUP BY一个没有被索引的字段,优化器先全表扫描再排序再聚合,那酸爽。这种场景一般就两个方向:一是建覆盖索引让数据有序,二是调整查询逻辑减少要聚合的数据量,比如加个时间范围限制。
3. 读得懂数字,才算真看懂执行计划
3.1 cost 是多少钱?rows 和 width 又代表什么
执行计划里每个节点旁边都有cost=0.28..8.30这种数字。很多人问这个cost的单位是什么,答案是:不是秒,不是毫秒,是PostgreSQL自定义的"成本单位",基于磁盘页读取次数和CPU处理代价折算出来的一个相对值。
- 第一个数字(0.28)是启动成本,就是读取第一行之前的花费,比如索引定位的开销。
- 第二个数字(8.30)是总成本,就是读取所有返回行的总花费。
怎么理解成本单位?PostgreSQL默认认为顺序读一个磁盘页的成本是1.0,随机读一个页的成本是4.0(random_page_cost参数,默认是4)。所以如果你看到cost=8.30,大概估算一下,约等于读取了若干个数据页的开销。
再来看rows和width。rows是优化器估算的这个节点会返回多少行,width是估算的每行平均字节数。width乘以rows再乘以加工难度,基本就是总成本的组成部分。
关键来了:rows是估算值,不一定准。它依赖统计信息,统计信息过期或者不完整,rows就会严重跑偏。比如你明明知道某个状态码只有0.1%的数据,但优化器估算的是50%的数据,那它自然觉得全表扫描划算,结果索引也不走。这种坑后面专门讲。
3.2 估算和实际的偏差,是性能问题的第一信号
用EXPLAIN ANALYZE时,一定要对比估算rows和实际rows:
Seq Scan on orders (cost=0.00..35.50 rows=2550 width=35) (actual time=0.010..0.012 rows=500 loops=1)这里估算2550行,实际只有500行。偏差超过5倍,就已经说明统计信息靠不住了。偏差几十倍、上百倍的情况我都见过,那个执行计划基本就是优化器在"盲人摸象",选错方案一点都不奇怪。
有个比较典型的场景:字段上建了索引,状态值分布严重不均,比如订单状态,90%是"已完成",5%"待支付",5%"已取消"。如果你查询条件是查那5%的量,索引本该很好用,但优化器拿到的统计信息可能以为状态值均匀分布,认为查出来50%的量,于是选择了全表扫描。
看到估算和实际偏差太大,第一件事不是加索引,而是更新统计信息:
ANALYZE orders;如果数据经常大幅变动,可以更激进一点,用VACUUM ANALYZE把旧版本清理掉再更新统计。我经常在数据同步、批量导入之后安排一次ANALYZE,代价很低,收益却很大。
经验:我在优化慢查询时,第一步永远是先跑一遍
EXPLAIN ANALYZE,然后直接看actual rows和估算rows的比值。这个偏差检查,比盯着cost省时间得多。偏差不大的话,问题基本出在索引和SQL写法上;偏差大的话,先治统计信息。
4. 实战复盘:一个订单查询从 8 秒到 80 毫秒
4.1 拿到慢查询,先复现再解释
下面这个案例是我之前优化过的一个订单查询,场景类似电商系统的订单列表。表结构简化之后是这样:
CREATE TABLE orders ( id bigserial PRIMARY KEY, user_id integer NOT NULL, product_id integer NOT NULL, total_amount numeric(10,2) NOT NULL, status smallint NOT NULL DEFAULT 0, created_at timestamptz NOT NULL DEFAULT now() );表里有大约500万行数据。业务方反馈说,查某个用户近三个月的已完成订单很慢,SQL长这样:
SELECT * FROM orders WHERE user_id = 88 AND created_at >= '2024-01-01' AND created_at < '2024-04-01' AND status = 1 ORDER BY created_at DESC;先不要急着一顿操作,我习惯先跑一遍EXPLAIN ANALYZE把现状拍下来:
Gather Merge (cost=21271.35..23151.35 rows=428 width=35) Workers Planned: 2 -> Sort (cost=20271.32..20272.39 rows=428 width=35) Sort Key: created_at DESC Sort Method: external merge Disk: 1888kB -> Parallel Seq Scan on orders (cost=0.00..20249.97 rows=428 width=35) Filter: ((created_at >= '2024-01-01'::timestamp with time zone) AND (created_at < '2024-04-01'::timestamp with time zone) AND (user_id = 88) AND (status = 1))问题很明显:并行顺序扫描,全表过滤,然后在外部排序。8秒的耗时就烧在这里——500万行全扫一遍,还要排到磁盘上。
这里要插一句:遇到了慢查询先别动手,先记录原始执行计划。优化之后可以对比前后差异,也方便复盘到底哪个改动起了作用。
4.2 为什么加了索引还是不走
第一次尝试,我建了一个单列索引:
CREATE INDEX idx_orders_user_id ON orders (user_id);结果执行计划变化不大,依然走了Seq Scan。原因在于:查询里user_id = 88这个条件过滤后,可能仍然有几十万行数据(这个用户是活跃用户),对于一个单列索引来说,选择性不够好。优化器一算,回表成本太高,还不如全表扫。所以记住一个核心概念:索引的选择性决定了优化器是否愿意用它。选择性太差,索引帮倒忙。
这时候正确的方向不是删索引,而是建立符合查询模式的复合索引。这条SQL的过滤条件有三个字段:user_id、created_at、status,排序是created_at DESC。
按照"等值列放前面,范围列放后面"的建索引原则,我建了复合索引:
CREATE INDEX idx_orders_user_created_status ON orders (user_id, created_at DESC, status);等值条件的user_id放最前面,范围条件created_at放中间,status放最后,并且created_at指定DESC是为了匹配ORDER BY created_at DESC,让排序直接走索引的顺序。
再来看看执行计划:
Index Scan Backward using idx_orders_user_created_status on orders (cost=0.42..8.44 rows=4 width=35) Index Cond: ((user_id = 88) AND (created_at >= '2024-01-01'::timestamp with time zone) AND (created_at < '2024-04-01'::timestamp with time zone)) Filter: (status = 1)快了非常多,查询时间从8秒降到50毫秒左右。这里有个细节值得注意:执行计划里status = 1变成了Filter而不是Index Cond。原因是索引里有status列,但PostgreSQL在索引扫描时选择先用等值+范围条件定位,status字段作为过滤条件其实也利用了索引,只是执行计划展示方式上被标成了Filter。这个细节不同版本表现有差异,核心是性能已经达标。
4.3 复合索引与部分索引的取舍
上面的优化里,其实还有一个更激进的选择:部分索引(Partial Index)。因为status = 1是有业务含义的固定状态,我只想索引"已完成订单":
CREATE INDEX idx_orders_completed_user_created ON orders (user_id, created_at DESC) WHERE status = 1;这个索引的体量更小,只包含已完成状态的订单,扫描效率更高。但注意,部分索引有个代价:查询条件必须完全匹配索引的WHERE条件,优化器才会使用它。如果你的查询还会查"待支付"状态,那部分索引就不适用,得再建一个。
我给的建议很实际:先建复合索引解决问题,等确认业务查询模式非常固定之后,再考虑部分索引做极致优化。部分索引在数据量大、状态分布极不均匀的场景下收益非常明显,前提是别过度设计,索引太多反而拖累写入性能。
5. 常见问题与排查技巧实录
5.1 统计信息过期:优化器瞎猜的时候怎么办
下面整理一张速查表,都是实际工作中最常见的情况:
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 估算rows和实际rows差距巨大 | 统计信息过期 | SELECT last_analyze FROM pg_stat_user_tables WHERE relname='orders'; | ANALYZE orders;或VACUUM ANALYZE orders; |
| 明明有索引却走Seq Scan | 选择性差 / 统计信息错误 / 参数问题 | 检查rows估算、查看random_page_cost设置 | 更新统计信息、调整参数、或建复合索引提高选择性 |
| Sort Method显示external merge Disk | work_mem过小 | 查看执行计划中的Sort算子 | 适当调大work_mem,注意是局部调还是全局调 |
| Nested Loop内层循环次数巨大 | 外层表太大,内层无索引 | 看loops和Index Scan | 给内层表连接字段加索引 |
| 查询计划频繁变化 | 统计信息不稳定 / 参数波动 | 对比多次执行计划 | 用绑定参数使计划稳定 |
统计信息那块务必重视。PostgreSQL默认的autovacuum会自动更新统计信息,但大数据量高频更新下,统计信息可能还是滞后。我有个习惯:大表在批量导入数据后,马上手动执行ANALYZE。
5.2 调节优化器参数,别一上来就调 work_mem
很多人一遇到慢查询就想着调大work_mem。这个参数确实影响排序和哈希操作,但需要拿捏好分寸。
我的建议是分步骤来:
第一步,先确认执行计划里是不是出现了external merge Disk或者HashAggregate明显超大。如果是,再看这个操作实际用到的内存量(EXPLAIN ANALYZE里Sort Method后面的Memory: xxx kB会显示)。
第二步,评估调参范围。work_mem是每个会话、每次排序或哈希操作单独计算的,不是全局共享的。比如设置work_mem = 64MB,有20个并发会话同时排序,理论上内存峰值就可能到1280MB以上。线上数据库要谨慎,我一般为了安全会限制单次会话的设置:
SET work_mem = '128MB';只在当前会话生效,不影响其他连接。如果确实要全局调,建议小步调整,观察内存压力。
另一个参数是random_page_cost。默认值是4,是HDD时代的参数,针对机械硬盘随机读很贵的设定。如果你们数据库跑在SSD甚至NVMe上,随机读成本远低于4,把它调到1.1左右更合理。这个参数调低之后,优化器会更愿意选择Index Scan,因为随机读的成本不再那么吓人。改完记得RELOAD,一般不需要重启:
ALTER SYSTEM SET random_page_cost = 1.1; SELECT pg_reload_conf();5.3 三分钟速判执行计划健康度
最后给大家一个我自己的速判套路,拿到任何一条执行计划,按顺序看四个地方:
第一,看有没有Seq Scan扫大表。如果See Scan出现在数据量超过百万级的表上,而且Filter条件多,先打问号。
第二,看估算rows和实际rows的偏差。偏差大的先更新统计信息再判断。
第三,看有没有external merge Disk。有就说明内存紧张,要么调整work_mem,要么优化SQL减少排序数据量。
第四,看有没有Bitmap Heap Scan。这个算子本身不是坏事,但如果出现在高频查询里,说明查询条件的选择性一般,可以考虑重写SQL或者加针对性的复合索引。
按照这个顺序,一条慢查询基本能快速定位到问题区域。执行计划这块东西,看得多了就有感觉了,不需要每行每个数字都纠结,关键是把"异常点"捞出来。
我个人在实际操作中的体会是:执行计划不是用来背的,是用来对比的。同一个SQL,加索引前后各看一次,调整参数前后各看一次,差距一目了然。很多你觉得玄乎的数据库优化,本质上就是让执行计划朝你预期的方向走一点点。多跑几次EXPLAIN ANALYZE,多记录几组before/after数据,比看十篇文章都有用。另外一个小技巧分享给大家:把经常用的EXPLAIN命令存成模板,加上(ANALYZE, BUFFERS, SETTINGS)选项,一次把执行时间、缓冲命中、优化器开关全打出来,排错效率翻倍。