☰
深入理解PostgreSQL执行优化器:从慢SQL到执行计划调优
2026/10/11 2:55:01 网站建设 项目流程

1. 从一条慢SQL说起:为什么我们要搞懂执行优化器

先讲个我记忆特别深的场景。某天下午,业务方扔过来一条SQL,说页面打开要十几秒,用户已经骂了好几次了。我拿过来一看,单表只有几十万行,索引也不缺,WHERE条件也不复杂。当时我的第一反应是“这不合理”,然后习惯性地敲了EXPLAIN ANALYZE,结果出来一个让我有点意外的执行计划——明明有索引,优化器却选择了全表扫描,还做了两层嵌套循环。

那一刻我意识到,调SQL如果只看语法、只看索引,不看执行优化器是怎么想的,很多时候就是在碰运气。搞懂PG数据库的优化器,本质上就是搞懂数据库是怎么做“决策”的。它为什么要全表扫?为什么不用我建的索引?为什么两个表JOIN的顺序跟我想的不一样?为什么预估行数和实际行数差了十万八千里?这些问题,全部指向同一个底层机制——执行优化器。

这篇文章不打算写成一册官方文档的摘抄,而是想以一个实际踩坑、实际排查过的视角,把PG的执行优化器讲清楚。会先聊它到底是干什么的、内部有哪些核心环节,再实际拆解一条SQL的执行计划,最后把常见的坑和排查思路整理出来,包括一些我自己的实测经验和判断方法。适合三类人看:刚接触执行计划、面对EXPLAIN输出一脸懵的新手;写过不少SQL但对“为什么走这个计划”没有底的同学;以及被慢SQL折磨过、想系统补一补优化器知识的人。

PG的优化器是典型的基于代价的优化器(Cost-Based Optimizer,CBO),它不做“经验主义”的判断,而是给每一条可能的执行路径算一笔账,然后挑最便宜的那条路。但这个“账本”有时候算得准,有时候算得离谱。离谱的时候,就是我们需要介入的时候。

理解优化器的价值,不在于把每条SQL都调成最优,而在于你能看懂数据库为什么这么做,以及什么情况下它的“判断”会失效。

2. 执行优化器内部在做什么:一个决策流水线

2.1 从SQL文本到执行计划:三个核心阶段

一条SQL从客户端发到PG,到真正执行,中间要经过一连串的加工。优化器并不是一上来就在“想怎么执行”,它得先把SQL变成自己能理解的东西。

整个过程大致分三段:解析、重写、规划。

解析阶段(Parser)做的活儿比较简单粗暴,就是把SQL字符串拆成一个个语法单元,然后根据PG的语法规则,生成一棵解析树(Parse Tree)。这个阶段基本不对SQL做任何“思想性”的判断,纯粹是在检查语法对不对。比如你写了SELEC * FROM t,在这里就会被判定为语法错误。解析树只是把SQL的骨架还原出来,它跟你脑子里想的那条SQL是一一对应的,还没有任何执行层面的信息。

重写阶段(Rewrite)会基于规则(Rule)对解析树做变换。PG这里有一套自带的规则系统,最典型的是视图展开。假如你查询一个视图,优化器不会真的以“视图”为单位去执行,而是会把视图的定义SQL替换进来,变成对底层表的查询。这个阶段也是PG比较有特色的地方——它的规则系统非常强大,甚至可以让你对一张表的INSERT做改写。但在日常优化中,重写阶段我们关注得不多,因为大部分问题发生在更后面的规划阶段。

规划阶段(Planner)才是真正意义上的“优化器”。它拿到重写后的查询树,开始生成多个候选的执行计划,给每个计划估算代价,最后选一个代价最小的作为最终执行计划。这一阶段有两个独立的子问题:连接顺序怎么排、每个表怎么访问。PG会把这些组合起来,生成一棵完整的计划树。

理解这个流水线,你就知道了一个关键点:你写的SQL,只是优化器的输入之一,不是执行方式的最终决定者。优化器有可能把你的子查询改成JOIN,有可能把你的JOIN顺序调换,有可能把你的IN改成EXISTS,这些改写发生在规划过程中,内部机制远比我们想的复杂。

2.2 代价模型:数据库是怎么“算账”的

PG优化器的核心思想是“算账”。每条执行路径,它都会给出一个总代价(Total Cost),然后选最低的。代价的单位不是毫秒,也不是CPU周期,而是PG自定义的一个抽象单位。我们可以简单把它理解为“数据库执行这条路径需要消耗多少标准资源”。

PG的代价模型主要包含几个部分:启动代价(Startup Cost),表示返回第一行之前需要付出的代价;总代价(Total Cost),表示整个计划执行完需要付出的代价;还有就是行数预估(Rows)和行宽度(Width)。其中行数和宽度会通过EXPLAIN显示出来,这两个值直接影响后续操作的代价估算。

在计算代价时,PG会用到几个重要的权重参数:顺序扫描页代价seq_page_cost、随机扫描页代价random_page_cost、CPU处理一条元组的代价cpu_tuple_cost、CPU处理一个索引条件的代价cpu_index_tuple_cost、CPU处理一个操作符的代价cpu_operator_cost。这些参数有默认值,也可以调。我在实际项目中就遇到过因为random_page_cost设置过大,导致优化器宁可全表扫也不用索引的情况。这个问题后面细说。

代价估算的大致逻辑是:扫描路径的代价由“读页的代价”加上“处理元组的代价”构成;JOIN路径的代价则要额外加上连接操作的CPU代价;排序操作还会有专门的排序代价。这套模型看起来很科学,但它的准确性严重依赖于统计信息。如果统计信息过期、失真,那算出来的账就是一本烂账。

2.3 统计信息:优化器的“视力表”

如果说代价模型是优化器的大脑,那统计信息就是它的眼睛。优化器看不到表的真实数据,它只能根据pg_statistic里存的数据分布信息来做判断。

PG通过ANALYZE命令来收集统计信息,包括每个列的空值比例、平均宽度、最常见值(MCV)、直方图边界等。这些信息会被规划器用来估算选择率(Selectivity)——也就是满足WHERE条件的行占比。选择率乘以表行数,就得出预估行数。

统计信息有个非常经典的问题:它是采样估计的,不是精确的。默认情况下,PG采集的样本量有限,对于数据分布不均匀的表,预估结果可能偏离真实值。比如一个列上90%的值都是同一个值,那在等值查询时,即使你建了索引,优化器也会算出“返回大量行”的结果,从而选择全表扫描。

所以,我把统计信息比作优化器的视力表。你长期不跑ANALYZE,表的统计信息停留在半年以前,数据却已经翻了几十倍,那优化器就是在“近视”状态下做决策,做出的计划自然不可靠。

3. 核心机制拆解:访问路径与连接策略

3.1 单表访问:为什么有索引也不走

优化器在决定怎么访问一张表时,主要候选路径就几种:顺序扫描(Seq Scan)、索引扫描(Index Scan)、位图扫描(Bitmap Scan)、仅索引扫描(Index Only Scan)。

这里最容易被误解的是顺序扫描。很多新手一看到Seq Scan就头大,觉得数据库肯定“偷懒”了。但顺序扫描在两种情况下是完全合理的:一是表很小,读整张表比走索引还要快;二是查询要返回大部分行,走索引反而要来回随机读堆表,代价更高。

那优化器怎么判断“大部分”是多少呢?这里有个关键概念叫选择率。比如一个表有100万行,你查某列等于某个值,数据分布是均匀的,假设有100个不同值,那选择率大约是1%,预估返回1万行。1万行对于顺序扫描和索引扫描的决策会产生不同影响。

位图扫描经常被忽略,但它在处理“单索引选择率不够低、多条件组合”时特别有用。它的思路是:先用索引定位到所有满足条件的堆页,再按物理顺序批量读取这些页。这种方式能把随机I/O转换成相对有序的大块读取,在机械硬盘时代效果显著。现在虽然很多环境已经是SSD,但位图扫描依然有价值,尤其是多索引合并的场景。

而仅索引扫描是性能最好的一种——如果查询的列全部包含在索引中,PG就可以只读索引不读堆表,省掉回表环节。但这要求足够多的可见元组映射(VM)信息,否则还要检查可见性。

单表访问路径的选择,本质上是代价的比较,而这个比较结果我们完全可以通过EXPLAIN看到预估代价的数值。

3.2 多表连接:优化器最烧脑的决策

多表连接比单表访问复杂一个量级。PG需要决定:表之间的连接顺序是什么?每个连接用什么算法?是否需要对输入先做排序或物化?

连接算法主要有三种:嵌套循环连接(Nested Loop Join)、哈希连接(Hash Join)、归并连接(Merge Join)。

嵌套循环是最基础的算法——对于外层表的每一行,去内层表找匹配行。如果内层表有索引,就是“索引嵌套循环”,在没有索引或内层表特别小的情况下,代价可能很大。它适合外层表小、内层表连接列有索引的场景。

哈希连接的做法是:对外层表建一个哈希表,然后遍历内层表去哈希表里探测。它适合两表都比较大的等值连接场景。PG默认对等值连接一般会优先考虑哈希连接,因为它不需要内层有索引。

归并连接要求两个输入都已经按连接列排好序,然后像合并两个有序数组一样做匹配。它适合连接列上有索引或已有排序结果的场景,也适合非等值连接。

这里有个很重要的认知:优化器并不一定选择执行最快的那种算法,它选的是估算代价最低的那种。如果统计信息不准,估算出来的代价就很离谱。比如内表实际只有100行,统计信息却显示有100万行,优化器算出的嵌套循环代价会高得吓人,于是选了哈希连接。结果哈希连接建表开销不小,本来嵌套循环几十毫秒就能跑完,最后跑了1秒。

连接顺序的问题也同样关键。N个表连接,可能的连接顺序数量是阶乘级的,PG不会穷举所有可能,而是通过动态规划和遗传算法来搜索。当表数量较少时(一般少于12个),使用动态规划可以找到最优顺序;表特别多时,PG会切到遗传算法来降低搜索空间,但这样找到的可能不是全局最优解。这也是为什么我常在多表JOIN场景下推荐手工用JOIN_ORDER提示干预的原因。

3.3 排序与去重:被低估的代价来源

排序操作在SQL里太常见了:ORDER BY、DISTINCT、GROUP BY、Merge Join的前置操作都可能需要排序。PG提供专门的排序节点(Sort),当排序数据量超过work_mem时,会临时落到磁盘文件里,性能暴跌。

很多人调SQL只看扫描类型和JOIN类型,不关注计划里隐藏的Sort节点。其实排序的代价往往被低估。比如一个百万行的结果集做排序,内存排序也许只要几百毫秒,一旦落到磁盘就是几秒钟的差距。

优化器在这里还有一个优化手段:如果发现排序键刚好是索引列,并且索引扫描本身就是按这个顺序返回的,那么就可以省掉排序。这在计划里会显示为Index Scan ... Using ...,且上层没有Sort节点。这也是为什么复合索引设计要考虑查询的排序需求——不是为了匹配WHERE条件,而是为了“顺便消掉排序”。

4. 实操:手把手解析一条SQL的执行计划

4.1 准备环境与示例数据

理论讲完了,总得实际操作一遍。我用一个模拟业务场景来演示。假设我们有两张表:orders(订单表)和users(用户表),数据量分别约200万和50万。我们需要查询“最近30天内下单超过3次的活跃用户”。

SELECT u.user_id, u.nickname, COUNT(*) AS order_cnt FROM users u JOIN orders o ON u.user_id = o.user_id WHERE o.created_at >= NOW() - INTERVAL '30 days' GROUP BY u.user_id, u.nickname HAVING COUNT(*) > 3;

先不建任何新索引,直接跑EXPLAIN ANALYZE,看优化器给的默认计划。

EXPLAIN (ANALYZE, BUFFERS) SELECT u.user_id, u.nickname, COUNT(*) AS order_cnt FROM users u JOIN orders o ON u.user_id = o.user_id WHERE o.created_at >= NOW() - INTERVAL '30 days' GROUP BY u.user_id, u.nickname HAVING COUNT(*) > 3;

4.2 逐步拆解:每个节点到底在干什么

假设我得到如下简化版的执行计划:

HashAggregate (cost=45234.11..45256.21 rows=2210 width=48) Filter: (count(*) > 3) -> Hash Join (cost=43821.10..45230.11 rows=10234 width=48) Hash Cond: (o.user_id = u.user_id) -> Seq Scan on orders o (cost=0.00..42123.45 rows=10234 width=8) Filter: (created_at >= now() - '30 days'::interval) -> Hash (cost=1725.12..1725.12 rows=50112 width=44) -> Seq Scan on users u (cost=0.00..1725.12 rows=50112 width=44)

在没有索引的情况下,这个计划其实非常典型。优化器选择了对orders做顺序扫描,过滤条件之后预估剩下1万行;users直接全表扫,建了一个哈希表;然后用Hash Join把两个集合连接起来;最后通过HashAggregate做分组聚合,再过滤掉计数小于等于3的组。

从执行时间上看,这个计划在本地可能跑1.5秒左右。对200万行的订单表来说不算离谱,但我们有更好的选择——对orders(user_id, created_at)建一个联合索引。

CREATE INDEX idx_orders_user_created ON orders(user_id, created_at);

再跑一次,计划会变成这样:

HashAggregate (cost=12033.22..12055.32 rows=2210 width=48) Filter: (count(*) > 3) -> Nested Loop (cost=0.56..11941.10 rows=10234 width=48) -> Seq Scan on users u (cost=0.00..1725.12 rows=50112 width=44) -> Index Scan using idx_orders_user_created on orders o (cost=0.56..8.11 rows=1 width=8) Index Cond: (user_id = u.user_id) Filter: (created_at >= now() - '30 days'::interval)

执行时间可能降到400毫秒左右。

4.3 结合EXPLAIN输出逐项解读

很多人在看执行计划时只瞟一眼最上面那个节点,其实应该从最底层往上看。每个缩进层级代表一个执行节点,缩进越深的先执行。

cost那一列有两个数字:第一个是启动代价,第二个是总代价。注意,父节点的启动代价是从所有子节点启动那一刻算起的,不是单看它自己。

rows是预估行数,actual rows是实际行数。如果你看到两者差距巨大,比如预估1万行实际10万行,这就说明统计信息或者选择率估算出了问题。BUFFERS选项会给出每个节点实际读了多少共享缓冲页,这个信息对判断I/O开销极其有用。

Execution Time是整个语句的总执行时间。但要注意,这不包括网络传输时间和客户端接收结果的时间。

4.4 验证统计信息的作用

上面的例子中,如果我在插入大量数据后没有跑ANALYZE,优化器可能仍然认为orders只有20万行,那么它算出来的Seq Scan代价就会偏低,从而坚持走全表扫描。这个现象非常常见。

解决方式很简单:

ANALYZE orders; ANALYZE users;

分析完后再跑EXPLAIN,预估行数会明显贴近真实数据,执行计划可能自动切换为索引扫描。这也是排查执行计划异常时第一步要做的事——别急着改SQL,先更新统计信息看看。

5. 与代价相关的关键参数:让优化器更懂你的硬件

5.1 数据页与CPU代价参数

PG的代价模型是参数驱动的。几个最核心的参数决定了优化器如何计算访问路径的代价:

  • seq_page_cost:默认1.0。顺序读一页的代价。
  • random_page_cost:默认4.0。随机读一页的代价。
  • cpu_tuple_cost:默认0.01。处理一行元组的CPU代价。
  • cpu_index_tuple_cost:默认0.005。处理一条索引元组的CPU代价。
  • cpu_operator_cost:默认0.0025。执行一个操作符(比如比较运算)的CPU代价。
  • effective_cache_size:默认4GB。用于评估索引扫描“缓存命中”的概率。

random_page_cost是我踩过最多坑的参数之一。默认4.0来源于机械硬盘时代——随机I/O比顺序I/O慢4倍。但在如今普遍使用SSD甚至NVMe的环境里,随机读与顺序读的差距远小于4倍。如果保持默认4.0,优化器会高估索引扫描的代价,导致它倾向于全表扫描。

我的经验是:纯SSD环境可以设到1.5左右,NVMe的话设到1.1都没问题。但这只是经验参考值,具体还要看实际压测结果。有的项目我有意调成1.0,让优化器把随机读和顺序读看作几乎等价,效果很好,但要注意监控有没有“计划抖动”——也就是同一条SQL的执计划频繁换。

5.2 work_mem与排序、哈希的内存边界

work_mem控制每个排序或哈希操作最多能使用多少内存。默认值是4MB。这个数值看起来不起眼,但对大量排序和哈希JOIN的性能影响极大。

当一个Hash Join想建的哈希表超过work_mem限制时,PG会分批处理,把溢出的部分写到磁盘临时文件。这个“溢出”操作非常昂贵。优化器在评估Hash Join时,也会把“可能溢出”的代价算进去,因此work_mem太小时,优化器甚至可能避开哈希连接而选择更慢的嵌套循环。

调work_mem不能贪大,因为它不是全局共享一个4MB,而是每次排序或哈希操作都可能独占这么多内存。如果一条SQL有多个Hash Join和Sort节点,连接池并发一高,内存可能直接被吃满。我的建议是:先处理慢SQL,观察单条语句的峰值内存,在安全范围内从64MB开始逐步调。

5.3 planner是否有全局的“偏好设置”

还有一个容易被忽略的参数叫enable_*系列,比如enable_seqscan、enable_hashjoin、enable_nestloop、enable_mergejoin。这些参数不是强制指定执行方式,而是把对应路径的代价乘上一个很大的惩罚系数(不是直接禁用)。早期排查慢SQL时,我经常临时用它们来确认问题出在哪种执行方式上。

比如怀疑全表扫描不合理,就执行:

SET enable_seqscan = off;

再重新看执行计划。如果用了索引还是慢,说明问题不在选路。这个技巧只能用于排查,不能作为上线配置——长期关闭全表扫描会导致优化器做出各种奇怪的选择。

6. 实战排查:优化器选错计划的两个真实场景

6.1 统计信息滞后导致的全表扫描

我先说的这个场景,是我实际遇到过的。某张业务流水表,平时一天新增几十万行,但因为数据保留策略要定期删旧数据。某一天我收到慢SQL告警,查询是查最近7天的数据,结果走了全表扫描。问了一圈,运维说数据更新脚本里没有执行ANALYZE的习惯。

查看执行计划时,优化器以为表只有80万行,其实当前已经460万行了。它还认为“最近7天”的数据会占很大比例,算了算觉得全表扫更划算。直到我手动执行了ANALYZE,重新查看计划,优化器才意识到最近7天只占4%的数据,切换到索引扫描后,查询从3秒降到30毫秒。

这个场景能复现得非常稳定。所以我给大家一条非常实用的建议:上线任何批量数据变更脚本(大量INSERT、DELETE、UPDATE)之后,都主动跑一次ANALYZE。尤其是那种做DELETE后回收空间的,统计信息滞后必然发生。

6.2 参数设置与硬件不匹配导致的索引拒绝

还有一次是在某个高并发读多写少的系统上,明明查询条件能用到索引,但优化器每次都是全表扫描。我当时判断大概率是random_page_cost设高了,于是问了下运维磁盘类型。确认是SSD后,我把random_page_cost从4.0改成1.5,然后再看执行计划,索引路径的代价就直接低于全表扫描了。

这个案例的启发是:优化器的“价值观”是参数喂出来的,硬件变了,参数也要变。数据库迁移到云端、或者从机械盘换成SSD之后,除了关注性能基准测试,也要重新审视random_page_cost和effective_cache_size这些参数。

6.3 JOIN顺序问题与手工干预

还有一类问题是,明明可以用小表驱动大表,优化器偏偏选择把大表作为外表。这种现象多发生在统计信息不准之后。即使统计信息准,很多开发者也喜欢一口气JOIN七八张表,表的连接顺序一旦复杂,优化器的遗传算法结果并不是全局最优。

遇到这类情况,我的常规做法是使用pg_hint_plan扩展来手工指定连接顺序和连接方法。PG本身不像某些商业数据库那样原生支持HINT,需要安装扩展。

SET search_path TO public; LOAD 'pg_hint_plan';

然后在SQL前面加提示注释:

/*+ Leading((u (o p))) HashJoin(o p) IndexScan(o) SeqScan(u) */ SELECT ...

这种方法适合作为“最后手段”。原因在于:一旦手工指定,SQL就脱离了自动优化能力,以后表数据分布变化、统计信息更新,这个固定计划可能反而变差。所以我的建议是:手工提HINT之前,先确保统计信息是最新的,参数设置是合理的,然后再考虑“人工接管”。

7. 常见问题速查:执行的迷雾与出口

表现最可能原因排查方向建议处理
有索引但走全表扫描random_page_cost过高、统计信息过期、选择率高查看EXPLAIN中预估行数与实际行数更新统计信息、调整random_page_cost
预估值与实际值差距极大统计信息陈旧、数据倾斜严重对比rows和actual rows执行ANALYZE、考虑扩展统计信息
多个表JOIN后计划奇怪连接顺序搜索不充分、统计信息不准用EXPLAIN逐层查看起估行数适时手工指定JOIN顺序
排序操作变成性能瓶颈work_mem过小导致磁盘排序观察计划中的Sort节点是否带external sort适当调大work_mem
Hash Join频繁落盘work_mem过小观察计划中Hash节点的内存使用调大work_mem、优化连接顺序
同一条SQL时快时慢计划抖动对比多时段执行计划固定参数、考虑HINT
某个WHERE条件列有索引但始终不用数据分布倾斜、MCV统计缺失或错误查看pg_stats中该列的直方图重新ANALYZE、必要时分桶清洗

这个表格是我长期排查问题的结果汇总。可以说90%的优化器选择异常,都能归到这七个方向里。排查的顺序很重要:先看统计信息新不新,再看参数是否匹配硬件,最后才是手工干预。

8. 延伸:从执行优化器到SQL优化思维

学习执行优化器的最终目的,不是背下来代价公式,而是建立起一种“站在数据库角度想问题”的思维模式。

很多时候,开发者习惯于从SQL文本去推测执行方式:“我写了JOIN,那应该就是嵌套循环吧”“我建了索引,那必须走索引”。但优化器的真实逻辑完全不是这样。它只认三样东西:统计信息、代价参数、路径枚举结果。

所以我的SQL优化流程已经固化成一套动作:

第一步,看执行计划前,先确认统计信息是否新鲜。数据量波动大的表,优先跑ANALYZE。

第二步,用EXPLAIN (ANALYZE, BUFFERS)看实际执行。比较预估行数和实际行数,如果差异超过10倍,那说明选择率估算出了问题。

第三步,检查参数。重点是random_page_cost和work_mem。这两个参数几乎是最常被忽视的。

第四步,试着简化SQL。把复杂的JOIN拆开,逐步加上去,观察执行计划的变化,找到导致代价爆炸的“元凶”。

第五步,抗不住了再上HINT。手工固定计划可以,但一定要注释清楚“为什么固定”,避免后人接手时一头雾水。

这套流程用完,绝大多数慢SQL都能找到明确的优化方向。剩下的少数顽固分子,往往需要从业务逻辑入手调整SQL写法,那已经超出优化器的范畴了。

9. 写在最后的一些个人体会

PG的执行优化器是一个设计精巧又相当复杂的组件。刚接触时,会觉得它就是个黑盒子——输入一条SQL,输出一个执行计划,好坏全靠运气。但实际用下来会发现,它其实有非常清晰的决策逻辑:统计信息是它看世界的眼睛,代价模型是它的价值观,路径枚举是它做选择的工具箱。

我踩过不少坑,其中印象最深的教训是:千万不要用自己的直觉去替代优化器的判断。比如你觉得“这个表必须走索引”,于是拼命加索引,实际执行计划却完全不看。这时候应该停下来想想,为什么优化器认为全表扫描更便宜?是因为统计信息不准,还是因为代价参数没有适配硬件?想清楚这两个问题,比盲目加索引要有效一百倍。

另外,学习优化器不能只停留在“看懂EXPLAIN输出”这个层面。强烈建议大家自己建两张测试表,插入几万到几百万行数据,多试试不同的WHERE条件、JOIN顺序、索引组合,对比执行计划的差异。只有亲手造过几个极端数据分布的场景,才能真正理解优化器在面临这些情况时的取舍逻辑。

PG的优化器不是万能的,但它非常讲道理。你给它准确的统计信息,配好合理的代价参数,它一般能给你不错的计划。它出错的时候,通常是因为外部条件变了,而我们还拿着旧地图在找新大陆。

最后分享一个小技巧:每次优化完一条慢SQL,我都会把优化前后的执行计划、参数调整过程、最终结果记录成一篇短文档。时间久了,这比任何理论书都有价值——因为它是真实数据、真实场景下的决策记录,而那些“为什么这样选”的思考过程,恰恰是最难从文档里学到的部分。

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

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

立即咨询