上一篇文章把 PostgreSQL 从接收 SQL 到完成语法分析、语义分析的过程梳理了一遍。这次继续往下走,重点讲从 Query 树到执行计划、再从执行计划到真正把数据行返回给客户端这一段。简单说,上一篇讲的是“读懂你在说什么”,这一篇讲的是“决定怎么做”和“真的做出来”。
如果你写过几年 SQL,却不清楚为什么有时候加了索引反而慢、为什么 EXPLAIN 里的 cost 数字那么大、为什么一条小查询会触发并行执行,那这篇文章正好能补上这些盲区。对 DBA、后端开发、以及想搞懂数据库内部机制的初学者都很合适。内容不会太偏向内核源码,我会尽量用实际操作中的经验来讲清楚 PostgreSQL 的 SQL 执行过程,顺便把 EXPLAIN 怎么看、慢 SQL 怎么下手这类问题一并聊透。
1. SQL 声明之后:优化器为何是执行过程的分水岭
1.1 从语法树到 Query 树,上一站在哪里
先说清楚一个容易混淆的点:语法分析结束后,PostgreSQL 得到的并不是一张可以直接执行的“流程清单”,而是一棵分析树。这棵树里记录了表名、列名、WHERE 条件、JOIN 关系等,但它仍然是完全面向“你写了什么”的,和“计算机应该怎么找数据”没有任何关系。
接下来进入语义分析阶段,PostgreSQL 会把分析树转换成一棵 Query 树。这一步会做不少正经事:检查表是否存在、列是否存在、根据当前搜索路径解析未加 schema 限定的表名、确定函数和操作符的实际类型,还会把 SELECT * 展开成具体的列清单。
等 Query 树出来后,后面的大头才登场:重写器、优化器和执行器。第一篇如果只讲到了语法树,那从这里开始就是我们这次要展开的全部内容。你在 pg_stat_statements 里看到一条 SQL 的调用次数和平均耗时,背后也是这条整链路消耗的总时间。
1.2 规则重写:把真实意图先还原出来
很多初学者不知道 PostgreSQL 在执行前还有一个重写阶段。规则系统平时不太容易被感知,但它一直在背后干活。最常见的一个场景就是视图。
你写 SELECT * FROM v_user_order,v_user_order 在数据库里其实是一条已保存的 SELECT 定义。重写器拿到你的查询后,会把视图引用展开成视图对应的子查询,再和你的条件组合。看起来你是查一张“表”,实际执行时可能已经变成两张表甚至更多张表的 JOIN 了。这也是为什么有时候视图查起来很慢,因为真实查询可能比你想的要复杂得多。
除了视图展开,重写器还负责规则触发,比如你可以用 CREATE RULE 对某个表定义插入、更新或删除的替代行为。虽然规则系统的使用场景不算多,但理解它的存在有助于排查一些“我明明改了表数据,为什么没有生效”的诡异问题。更重要的结论是:执行过程并不是从你的原始 SQL 直接开始的,而是从一个经过改写和展开之后的 Query 树开始的。
1.3 优化器不像执行器那样“听话”
如果数据库直接按照 SQL 文本字面意思去执行,那结果很多时候会是一场灾难。比如你写:
sql复制SELECT *
FROM orders
WHERE customer_id = 1024;
理论上先全表扫描 orders,再逐行判断 customer_id 是否等于 1024,也能得到正确结果。但如果 orders 表已经有两千万行,每次查询都全表扫一遍,报表业务根本没法用。
SQL 是声明式语言,你要的是结果集合,而不是执行步骤。执行细节怎么安排,完全是优化器和执行器的事。PostgreSQL 的优化器会考虑许多可能的访问路径,比如按主键索引取行、按普通索引回表、用位图扫描、或者干脆顺序扫描整张表,然后挑一个它认为“成本最低”的计划交给执行器。这个决策的过程,就是一条 SQL 能不能跑得快的关键分水岭。
很多人在应用层骂“数据库怎么这么傻,有索引不用”,其实很可能问题就出在统计信息不准确、或者成本估算与实际执行相差太大,而不是数据库本身逻辑简单。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 路径与代价:优化器如何做出执行决策
2.1 统计信息是执行决策的底牌
优化器做决策不能靠猜,它得有依据。PostgreSQL 在每个表上维护的统计信息,就是它做所有估算的重要底牌。
这些统计信息主要存储在 pg_class 和 pg_statistic 等系统目录中。pg_class.relpages 记录表大约占多少数据页,reltuples 记录大约有多少行。pg_statistic 则保存每个字段的详细分布情况,包括最常见的值(MCV)、直方图边界、空值比例、平均字段宽度等等。但 pg_statistic 默认不允许普通用户直接读取,日常你通过 pg_stats 视图就能看到比较完整的统计。
统计信息准不准,直接决定执行计划靠不靠谱。我经历过一次印象很深的故障:一张流水表每天半夜会批量插入几百万行,但统计信息没有及时更新。第二天白天某个报表查询上来时,优化器还傻乎乎地以为这张表只需要扫描 1000 个数据页,结果选了一个在小数据量下很合理的嵌套循环,实际却需要扫描上万页,查询跑了 10 多分钟。
解决办法也不复杂:在批量导入后执行 ANALYZE,或者开 autovacuum 让它自动维护。很多人以为 autovacuum 只负责清理死行,其实它还承担着更新统计信息的职责。把它禁用掉,早晚会遇到计划不稳定问题。
2.2 成本常量与访问路径计算示例
PostgreSQL 的成本计算并不是直接用毫秒,而是用一套抽象的成本单位。默认的 seq_page_cost 是 1.0,表示顺序读一个数据页的成本;random_page_cost 默认是 4.0,表示随机读一个页的成本约是顺序读的 4 倍。CPU 处理相关成本由 cpu_tuple_cost、cpu_index_tuple_cost、cpu_operator_cost 控制,默认值分别是 0.01、0.005、0.0025。
举个例子,一张 10000 行的表,如果占 500 个数据页。全表扫描成本大概是 500 个页的 seq_page_cost 加上 10000 行的 cpu_tuple_cost,也就是 500 * 1.0 + 10000 * 0.01 = 600。注意扫描算子输出的 cost=0.00..600.00 里,前一个数字是启动成本,后一个数字是总成本。
如果用索引扫描返回其中 20 行,优化器会同时计算读取索引页的成本、匹配索引元组的 CPU 成本,以及回表读数据页的成本。回表如果是大量随机 IO,每页成本按 4.0 算,一旦预估返回的行数过多,总成本可能迅速超过全表扫描。于是你会看到查询明明有索引可用,计划器却选择了 Seq Scan。这在 PostgreSQL 里不是 bug,而是成本模型在起作用。
2.3 连接顺序:优化器最耗费心思的地方
多表 JOIN 的执行计划生成比单表查询复杂得多,因为表之间的连接顺序会影响中间结果大小和最终成本。优化器会尝试把 5 张表按不同顺序连接、并对每种连接方法(嵌套循环、哈希连接、归并连接)分别估算成本,最终选一个总体成本最小的方案。
表少的时候可以靠穷举,表一旦变多,路径数量会爆炸。PostgreSQL 的做法是如果参与连接的表的数量超过 geqo_threshold(默认 12),会切换到遗传算法来搜索近似最优解。所以在极端复杂的 JOIN 查询里,你不能指望执行计划一定全局最优,只能说在有限资源下找到一个足够好的方案。
这里给业务开发一个非常实际的建议:JOIN 的表不要一下子堆太多,经验上超过 8 张表以后,不仅优化器计算计划的时间变长,而且统计误差被逐级放大的风险也高很多。如果业务确实需要很多表关联,考虑拆成多步查询,或者用物化视图提前加工中间结果。至少从执行过程的角度看,这比把全部压力交给优化器要稳。
3. 执行器怎么把计划变成结果集
3.1 Portal、事务与执行上下文
优化器产出执行计划之后,这条 SQL 并不是立刻就直接甩给执行器。中间还有一个承担“包装”作用的组件,叫作 Portal。可以理解为一个与客户端会话绑定的执行上下文容器。
PostgreSQL 的命令执行层先根据语句类型构建 Portal,把计划、参数值、游标状态、事务上下文等挂到 Portal 上。之后通过 PortalStart、PortalRun、PortalDrop 这些流程来真正驱动执行。如果一条语句使用了绑定参数,参数值也会在执行前填充到计划中的参数槽位里。这也是为什么带参数的 SQL 在服务端能被多次复用执行计划,不需要每次重新优化一遍。
在这个阶段,PostgreSQL 还会判断语句是否需要开启新事务块。如果当前不在显式事务里,它会自动为单条语句包一个隐式事务,保证所有操作要么全部成功要么全部回滚。聊到 SQL 执行过程时,很多人会忽略“事务和语句是两码事”这个点,但 PostgreSQL 实际上是把每条 SQL 都放在一个事务快照里执行的。
3.2 一次取一行的火山模型
PostgreSQL 执行器采用的是经典的火山模型,也叫迭代模型。执行计划是一棵节点树,顶层节点负责把结果返回给客户端,下面每个节点只需要从自己的子节点一行一行地取数据、做处理、再往父节点吐数据。整个流程是典型的“边算边取”,不需要等所有数据全部准备完成再输出。
拿一条最普通的查询举例:
text复制Seq Scan on orders
执行器对 orders 表顺序扫描,拿到一行,做可见性判断和条件过滤,然后立刻把这一行往上返回。如果需要排序,排序节点就得先把子节点传来的所有行收集齐,完成排序后再逐步输出,所以 Sort 节点天生具有阻塞性。这也是为什么 EXPLAIN ANALYZE 里 Sort 节点的 actual time 通常会有一个明显偏大的启动时间。
从工程实现角度看,每次只取一行效率不一定高,因为函数调用和上下文切换的成本会被放大。PostgreSQL 在较新版本里对投影路径做了一些批处理优化,但架构上整体仍然是迭代式拉取模型。理解这个模型对调优很有帮助:执行计划哪个节点“卡住”了,往往就是数据在那个节点上被积压或者被反复排序。
3.3 几个常用算子的运行特征
看 EXPLAIN 的时候,执行计划里那一串节点名不是摆设,每一个都对应不同的执行行为。
Seq Scan 是顺序扫描,对表的每个数据页从头到尾过一遍,适合返回大比例行数的场景。它的执行速度和表的物理大小强相关,所以一张大表的全表扫描即使只是测试也会消耗不少 IO。
Index Scan 先走索引定位到目标元组的位置(TID),再回表读取实际数据行。适合返回行数占比很小的查询,但要注意它天然带随机 IO,如果匹配行数很多,成本会很高。
Bitmap Index Scan 是为了改善上面这个问题而出现的。它会先把所有匹配行的页面位置收集成一个位图,然后再按页面顺序去读表,减少无谓的反复随机读。很多业务同学问,都是走索引,Index Scan 和 Bitmap Index Scan 哪个好?答案不是绝对,行数多的条件下 Bitmap 往往更友好,因为它的读盘顺序更规律。
Hash Join 会先把内表数据读入内存构建哈希表,然后外表逐行到哈希表里探测匹配。构建哈希表过程对 work_mem 很敏感,内存不够时会把哈希表分批写入临时文件,性能下降非常明显。Nested Loop 则适合外表小、内表有高效索引的场景,每从外表拿一行,就去内表做一次索引查找。
3.4 并行执行:多个 Worker 怎么分担工作
PostgreSQL 的并行执行也不是所有语句都能用。优化器把单个查询拆成多个部分,交给一组 Worker 进程并行处理,最后由一个 Gather 节点把所有 Worker 的中间结果汇总后返回给客户端。
在 EXPLAIN 输出里,你会看到类似 Gather、Worker 0、Worker 1 的节点标记。能不能启用并行,涉及好几个参数:max_parallel_workers_per_gather 控制单个 Gather 节点最多能使用多少 Worker;min_parallel_table_scan_size 则决定表要多大才值得为顺序扫描启动并行;parallel_setup_cost 和 parallel_tuple_cost 是“工人开销”的估算值。
我经常看到有人把 max_parallel_workers_per_gather 调得很大,但查询并没有变快。原因很多:有些节点无法并行,比如带 ORDER BY 并且排序本身无法利用并行的场景;又或者查询非常轻量,启动额外进程的开销反而更大。并行不是银弹,它适合那种大表扫描、大量数据聚合、或者需要读取巨大中间结果的查询。
4. 执行过程隐藏的内存、锁与快照因素
4.1 shared_buffers 与执行速度的关系
执行器读表时并不是每次都直接从磁盘读,而是先经过共享缓冲区 shared_buffers。PostgreSQL 把所有表数据和索引的数据页都缓存在这块共享内存里,同一个数据页在多个会话之间共享。
一次查询如果所有需要的页面都命中了 shared_buffers,那执行过程基本不涉及磁盘 IO,速度会非常快;反之,发生缓存未命中就得去操作系统或磁盘里取页面,执行时间会明显拉长。这里有一个非常常见的调优误区:只调 shared_buffers,但不关心真实命中率。你可以通过 pg_stat_database 查看每个数据库的 blks_hit 和 blks_read,这两个字段能算出来缓存命中率。
shared_buffers 并不是越大越好,因为 PostgreSQL 还要通过内核页缓存再做一层 IO。很多生产环境里设置在物理内存的 15% 到 25% 之间比较合理。如果已经设置了 32GB 内存却只分配 128MB 给 shared_buffers,业务上又频繁查询大表,执行器就会频繁“缺页”,大量的时间会耗在存储层,而不是 SQL 本身。
4.2 快照和可见性判断:每一行都要做的事
SELECT 查询执行时,并不是把表里每一行都原样返回。在 MVCC 机制下,PostgreSQL 需要通过判断数据行的事务状态和当前事务快照来决定这行到底能不能被看见。
这个可见性判断发生在扫描算子的内部流程里。每个数据行头里保存了插入事务 ID 和删除事务 ID,执行器会对照当前事务快照来判断该行是否已经提交、是否被你自己的事务修改过、是否已经删除。如果行已经更新,可能还需要沿着旧版本链找到对当前事务可见的版本。
这也是为什么不要频繁执行大范围 UPDATE 或 DELETE,因为修改会产生大量旧版本行,查询时需要跳过这些不可见版本,执行时间自然上涨。曾经有个运维同事问我,明明一张表 delete 完之后 SELECT COUNT(*) 还是很慢,原因就在于表中堆积了大量需要被事务机制跳过的旧版本数据。只有 VACUUM 才能真正清理占用的空间,否则执行过程依然要不断做可见性判断。
4.3 多次执行相同 SQL 时,背后有不少变量
同一个 SQL 语句在执行器里每次运行结果可能都很不一样,因为执行计划只是一个模型,实际发挥还依赖当时的系统状态。
第一类是内存因素。同样的 ORDER BY 语句,如果限定的 work_mem 足够大,排序完全在内存中完成,执行时间可能是几十毫秒;如果数据量超出内存,临时文件会溢出到磁盘,执行时间可能直接翻几十倍。这个“溢出边界”在 EXPLAIN ANALYZE 里能看到类似 Sort Method: external merge Disk 的提示,一旦出现,说明该考虑加大 work_mem 或改写 SQL 了。
第二类是锁等待。如果表正在被 ALTER TABLE 等 DDL 操作持锁,你的普通查询虽然不会被阻塞太多,但某些级别的锁等待会导致语句处在 wait_event 状态。从 PostgreSQL 的视角看,语句还在执行期间,但实际上在做的是等待,不是数据计算。
第三类是系统自身的资源竞争。磁盘繁忙、CPU 争抢、网络抖动都会影响单次语句执行时间。所以看一条 SQL 的性能不能只看某一次执行,要多采样几次,结合 pg_stat_statements 里的平均耗时来看。
5. EXPLAIN 输出的秘密与慢 SQL 排查
5.1 读 EXPLAIN:不要只看 total cost
新手看 EXPLAIN 容易只盯住末尾的 cost 数字,甚至以为这个值越小越好。但实际上 cost 是优化器内部的用于比较计划优劣的相对值,不是执行耗时,也不是毫秒。真正能代表实际执行情况的是 EXPLAIN ANALYZE 输出的 actual time 和 rows。
比如如下这段计划:
text复制Hash Join (cost=10.00..30.00 rows=100 width=24)
Hash Cond: (a.id = b.id)
-> Seq Scan on a (cost=0.00..10.00 rows=100 width=12)
-> Hash (cost=5.00..5.00 rows=100 width=12)
加上 ANALYZE 后,你会看到每个节点用逗号分隔的两组耗时。前一个数字表示启动耗时,即读取第一行前花的时间;后一个数字表示这个节点处理完全部数据的总耗时。如果一个节点输出行数与优化器估算的 rows 相差太大,通常说明统计信息不准,比如更新统计信息后执行计划可能就恢复正常。
养成这样看 EXPLAIN 的习惯:先看整体树的形状,然后对比估算行数与实际行数,最后定位最耗费时间、输出行数异常或出现长启动时间的节点。这样排查慢 SQL 会高效很多。
5.2 统计信息不准、work_mem 太小等真实案例
一次生产系统的大表关联查询,业务反馈平时 40 毫秒返回,某天突然变成 5 秒。EXPLAIN 后执行计划里出现了 Nested Loop,并且估算表行数只有几百行,而实际已经增长到上百万行。
原因是长期没有对大表做 ANALYZE,autovacuum 又被某种原因卡住,导致 reltuples 还停留在很久以前的值。优化器以为内表很小,选择嵌套循环没问题,但实际每扫描一次外表的一个匹配行,就要在上百万行里做索引查找,效果自然差了。我执行 ANALYZE 后重新查询,计划变成了 Hash Join,耗时恢复到几十毫秒。
还有一个高频场景是 sort 或 hash 节点触发了磁盘。某条报表查询需要把所有订单按用户分组,work_mem 默认只有 4MB,执行器很快就写了大量临时文件。通过 EXPLAIN ANALYZE 看到 external merge Disk 后,把该会话 work_mem 临时加大到 128MB,效果立竿见影。必须提醒的是,不推荐把全局 work_mem 一下调太大,因为它是按操作分配的,并发下可能把内存耗尽。更稳妥的做法是:先在单个会话里 SET work_mem TO '128MB' 做测试,确认效果后再决定要不要提升全局默认值。
5.3 锁、权限与进程初始化问题,别甩锅给 SQL
有些“执行异常”并不是 SQL 执行器本身的问题,而是语句在进入执行阶段前就失败了。很多刚上手 PostgreSQL 的朋友照着教程初始化数据库,启动时遇到类似这样的报错:
text复制无法创建锁文件 "/var/run/postgresql/.s.PGSQL.5432.lock": 权限不够
这个错误经常发生在以普通用户启动 PostgreSQL 的时候。服务器进程要在 unix_socket_directories 指定的目录里创建 UNIX socket 文件,默认目录是 /var/run/postgresql,普通用户没有写权限就会失败。严格说,这发生在数据库启动阶段,而不是某一条 SQL 的执行过程中。但如果理解整个流程,你会发现连接都建立不起来,后面的 SQL 执行过程根本无从谈起。
解决办法有几种:把 socket 目录改成用户可写的路径,比如 /tmp;或者调整目录权限;也可以用 pg_ctlcluster 这类工具让服务以正确身份启动。遇到类似启动报错,先别怀疑 SQL 写法,回头检查数据目录权限、socket 目录归属和端口占用。
还有一个连接相关的常见误解:同一个数据库连接上,后端只能按照请求顺序处理 SQL。Java 多线程程序想在同一个 Connection 上并行执行多条 SQL,本质上是不可靠的,因为 PostgreSQL 的简单查询协议必须等一条语句返回后,才会处理下一条。要并发处理就得使用连接池或建立多个连接,而不是依赖单连接内的多线程。这也解释了为什么“程序等一条 SQL 执行完毕后才能执行下一条”是常见现象,而不是执行引擎的缺陷。
6. 常见问题速查与实践建议
6.1 高频问题的排查速查表
这里整理了一些执行过程中容易踩坑的场景,很值得当作参考。
| 症状 | 可能原因 | 处理思路 |
|---|---|---|
| 有索引但执行计划走全表扫描 | 返回行数占比过高,索引回表成本更大 | 检查 WHERE 条件的选择性,统计数据是否更新,必要时用 SET enable_seqscan=off 辅助测试不走全表扫描的效果 |
| EXPLAIN 估算 rows 与实际差距很大 | 表统计信息长期未更新 | 对相关表执行 ANALYZE,确认 autovacuum 正常工作 |
Sort 节点出现 external merge Disk |
work_mem 不足导致排序落盘 | 单独会话调大 work_mem 测试,再决定是否全局调整 |
| 查询等待时间很长但 CPU/IO 都不高 | 可能锁等待或快照问题 | 通过 pg_stat_activity 查询 wait_event_type 和 state 进一步定位 |
启动 PostgreSQL 出现 lock 文件权限不够 |
socket 目录权限不足 | 调整 unix_socket_directories 或目录权限 |
| 主从环境中备库查询经常报错或延迟高 | 备库回放与查询冲突 | 调大 max_standby_streaming_delay,或把长查询改成不冲突的时间窗 |
| 单连接并发执行多线程 SQL 没效果 | 简单查询协议按请求顺序处理 | 使用连接池或增加数据库连接 |
这张表并不能覆盖所有情况,但常见的执行过程和连接初始化问题基本都能从这几类入手。
6.2 日常使用中值得养成的几个习惯
如果让我给一条最朴素的建议,就是让统计信息尽量保持准确。大表数据量发生明显变化后,手动 ANALYZE 不丢人,这比在业务代码里反复改 SQL 要省事得多。
第二个习惯是遇到慢查询先 EXPLAIN ANALYZE,不要凭直觉加索引。某些场景下加索引不会减少执行时间,反而增加写放大。但也要注意,加了 ANALYZE 之后,如果原 SQL 包含 INSERT/UPDATE/DELETE,它会真的修改数据。如果想观察这类写语句的执行计划,可以在事务里执行再回滚,或者使用只读事务。
第三个习惯是理解 work_mem 的影响要分成两层看。它对排序和哈希操作影响极大,但调大后并发一高,内存压力也会上来。实际项目里我会用连接池给不同的业务账号分配不同的 work_mem 默认值,把重查询和轻查询隔离开,避免一条复杂报表把实例内存吃满。
第四个习惯是关注 pg_stat_statements。这个扩展会记录每条 SQL 的调用次数、总耗时、平均耗时、缓冲区命中等信息。配合它来定位高频慢 SQL,能少走很多弯路。它对理解 SQL 执行过程也是很好的辅助,因为系统把这些统计数值暴露出来,能让你看到同一类 SQL 在不同负载下的真实表现。
6.3 从执行过程角度理解数据库同步类问题
我在生产环境里还碰到过涉及数据库同步的场景。很多同步工具把源库的变更解析出来之后,在目标端会拼成 INSERT、UPDATE、DELETE 语句再执行。这时如果目标端 SQL 执行过程很慢,数据同步就会延迟。
从执行过程的角度看,目标端每一条写入语句同样要经过解析、优化、执行、WAL 写入这一整条链路。如果目标端的表缺少合适索引,或者统计信息不准确,同步工具的“单条执行”模式会放大问题。反过来,如果不了解这个执行过程,看到同步延迟第一反应往往是网络问题,最后查了半天才发现目标端某张表的唯一约束和源库有差异,导致更新时需要频繁扫描大表。理解 SQL 语句在 PostgreSQL 里到底经历了什么,在很多“看似不是数据库问题”的场景里反而能帮你快速定位根因。
我自己这几年使用 PostgreSQL 最深的体会是:执行过程不是一个黑盒,理解了从 Query 树到执行计划再到执行器里的节点流转,再遇到慢 SQL 和诡异问题时,你会在心里先建一张地图,知道该往哪个方向排查,而不是东试试西试试。希望这篇文章能帮你把这条链路的关键环节串起来。
