上一篇我们聊了后台写进程如何为 checkpoint 减负。这一篇,我们把所有零部件串起来,回答一个最具体的问题:你敲下一行
UPDATE,按下回车,到数据真正落到磁盘上,中间到底发生了什么? 反过来,一次SELECT又是如何利用缓存,避免每次都去碰磁盘的? 理解这两条路径,前面所有关于进程、内存、WAL、数据页的知识,才会连成一张完整的图。

一、写入路径:从 SQL 到磁盘的七个阶段
一条 UPDATE 语句的执行,可以拆成七个阶段。
阶段一:解析(Parse)
客户端通过 TCP 连接把 SQL 文本发送给 backend 进程。backend 首先做词法分析和语法分析 ,把 SQL 文本转换成一棵解析树(parse tree)。这个阶段只检查语法是否正确,不关心表是否存在、列是否有效。
如果 SQL 是参数化的(如 PREPARE 或扩展协议的绑定参数),解析结果可以被缓存复用,避免重复解析。
阶段二:分析(Analyze)与重写(Rewrite)
解析树被交给查询分析器 ,它检查语义:表是否存在、列名是否正确、权限是否足够、类型是否匹配。分析后生成查询树(query tree)。
随后进入重写阶段 。PostgreSQL 的规则系统(Rule System)会在这里发挥作用:视图展开、行级安全策略(RLS)注入、RETURNING 处理等,都在这一步完成。重写后的查询树仍然是逻辑层面的描述,不涉及任何物理执行细节。
阶段三:计划(Plan)
查询树进入优化器(Planner) 。这是最复杂的一步。优化器会:
- 生成多种可能的执行路径(全表扫描、索引扫描、位图扫描、不同的连接顺序和连接算法);
- 基于统计信息(
pg_statistic)估算每种路径的成本; - 选择成本最低的执行计划。
对于 UPDATE,优化器还要决定如何定位要更新的行(Seq Scan 还是 Index Scan),以及是否使用并行执行。
阶段四:执行(Execute)
执行器按照计划树逐节点拉取元组 (Volcano 模型,也称为迭代器模型)。对于 UPDATE:
- 通过扫描节点定位目标行;
- 对每一行检查
WHERE条件; - 对满足条件的行,在共享缓冲区中分配一个新版本(MVCC 的更新不是原地修改,而是插入新版本、标记旧版本为过期);
- 更新索引项,指向新版本;
- 在 WAL 缓冲区中记录这次变更(包括必要的全页写)。
执行器操作的是 shared_buffers 中的页,而不是磁盘上的页。 如果目标页不在 shared_buffers 中,会先从磁盘读入,再进行修改。
阶段五:WAL 写入
在数据页被修改的同时,描述这次修改的 WAL 记录被追加到 wal_buffers。WAL 记录包含:事务 ID、被修改的页标识、变更的偏移和内容、以及(如果是检查点后第一次修改该页)整个页面的副本。
wal_buffers 由 WAL Writer 进程定期刷到 WAL 文件,但 COMMIT 时会强制触发一次刷盘(受 synchronous_commit 控制)。
阶段六:提交(Commit)
当客户端发出 COMMIT,backend 执行提交逻辑:
- 在 WAL 中写入提交记录(commit record) ;
- 确保该事务的所有 WAL 记录被刷到磁盘 (
fsync); - 在
pg_xact中将该事务标记为"已提交"; - 向客户端返回成功。
注意:此时数据页本身可能还没有被刷到磁盘。 脏页仍然留在 shared_buffers 中,等待 checkpointer 或 background writer 在未来的某个时刻刷出。持久性由 WAL 保证,而不是由数据页保证。
阶段七:脏页最终落盘
脏页由两个进程负责刷盘:
- Background Writer:渐进式地把脏页刷出,维持空闲 buffer 供给;
- Checkpointer :在检查点时把所有脏页刷到磁盘,并记录一个恢复起点。
一个数据页从被修改到最终落盘,可能经历多次 checkpoint 周期。但只要 WAL 还在,崩溃恢复就能把这个页"补"回来。
二、一次 UPDATE 的完整账本
把上面的阶段压缩成一句话:一次 UPDATE 实际上做了三件事------在 shared_buffers 中生成新版本行、在 WAL 中记录变更、在 pg_xact 中标记提交状态。数据文件本身的修改,被推迟到了未来的某个时刻。
这解释了为什么 UPDATE 的返回时间与数据文件的大小关系不大,而与WAL 刷盘速度 关系密切。也解释了为什么 UPDATE 会产生"死元组"------旧版本行仍然留在页上,只是对后续事务不可见,直到 VACUUM 回收。
三、读取路径:缓存如何改变一切
读取路径比写入路径短得多,但缓存的存在让它有了明显的"快慢之分"。
阶段一:解析、分析、重写、计划
与写入路径相同。对于 SELECT,优化器同样要决定扫描方式和连接算法。
阶段二:执行器请求数据页
执行器从计划树的根节点开始,向子节点请求元组。扫描节点需要访问表的数据页时,会调用 buffer manager 请求对应的页。
阶段三:Buffer Manager 的缓存查找
这是读取路径的核心。buffer manager 在 shared_buffers 的哈希表中查找目标页:
- 命中(cache hit) :页已经在 shared_buffers 中,直接返回,零磁盘 I/O;
- 未命中(cache miss) :需要从磁盘读入。
未命中时,buffer manager 会:
- 在 shared_buffers 中找一个空闲 buffer(必要时驱逐一个脏页或冷页);
- 向操作系统发起读取请求;
- 操作系统可能从 OS page cache 中直接返回(如果数据还在),也可能触发真正的磁盘 I/O;
- 读入的页被放入 shared_buffers,并标记为"最近使用"。
这就是 double buffering 的现实:一次 cache miss 不一定意味着磁盘读,因为 OS 可能已经缓存了那个页。 只有当 shared_buffers 和 OS page cache 都没有时,才会真正碰磁盘。
阶段四:可见性判断与返回
页被加载到 shared_buffers 后,执行器逐行检查 MVCC 可见性:这个行版本对当前事务的快照是否可见?可见则加入结果集,不可见则跳过。
阶段五:结果返回
结果集通过 backend 进程发送给客户端。如果查询涉及排序或哈希,work_mem 决定这些操作是在内存中完成,还是溢写到磁盘临时文件。
四、缓存命中率:读取性能的"体温计"
读取路径的性能,几乎完全取决于缓存命中率。PostgreSQL 提供了直接观测手段:
sql
SELECT
sum(blks_hit) AS cache_hits,
sum(blks_read) AS disk_reads,
round(sum(blks_hit) * 100.0 / nullif(sum(blks_hit) + sum(blks_read), 0), 2) AS hit_ratio
FROM pg_stat_database;
blks_hit 是 shared_buffers 命中次数,blks_read 是从磁盘(或 OS 缓存)读入的次数。生产环境健康的 OLTP 系统,命中率通常在 99% 以上。 如果命中率持续偏低,可能意味着 shared_buffers 不足、工作集过大,或者查询模式导致大量全表扫描。
但命中率不是唯一指标。一个查询即使命中率 100%,如果扫描了 100 万行却只返回 1 行,性能依然很差------问题在计划,不在缓存。读取路径的优化,要同时看"缓存效率"和"扫描效率"。
五、写入与读取的相互影响
两条路径不是孤立的。写入产生的脏页会占用 shared_buffers,挤压读取的缓存空间;checkpoint 的刷脏 I/O 会与读取的磁盘 I/O 竞争带宽;大量 UPDATE 产生的死元组会让表膨胀,增加读取时需要扫描的页数。
一个典型的恶性循环是:写入量大 → 脏页多 → buffer 被脏页占满 → 读取命中率下降 → 查询变慢 → 连接持有更久 → 更多并发写入。打破这个循环的手段,往往不是调大某个参数,而是从查询模式(减少全表扫描、控制事务大小)和写入模式(批量提交、避免长事务)入手。
六、总结
| 阶段 | 写入路径 | 读取路径 |
|---|---|---|
| 解析 | 语法分析,生成解析树 | 同左 |
| 分析/重写 | 语义检查、视图展开、RLS | 同左 |
| 计划 | 优化器选择扫描/连接方式 | 同左 |
| 执行 | 在 shared_buffers 中生成新版本,写 WAL 缓冲区 | 从 shared_buffers 或磁盘读页,做 MVCC 可见性判断 |
| 持久化 | WAL 先刷盘(COMMIT 时),数据页延迟刷 | 不涉及 |
| 落盘 | Checkpointer / Background Writer 异步完成 | OS page cache 可能兜底 |
两句话记住核心:
写入: 一次 UPDATE 修改的是内存中的页,持久化靠的是 WAL 的顺序写。数据页什么时候落盘,由 checkpointer 决定,而不是由 COMMIT 决定。
读取: 一次 SELECT 快不快,取决于目标页在不在 shared_buffers,以及 OS 是否已经缓存了它。缓存命中时,读取路径完全不碰磁盘。
一句话:PostgreSQL 的写入是"日志先行、数据页后置",读取是"缓存优先、磁盘兜底"。理解这两条路径的分工,你才知道该调 shared_buffers、还是该调 WAL、还是该改查询。
下一篇,我们进入 MVCC 机制:为什么 PostgreSQL 的读写互不阻塞?xmin 和 xmax 到底在判断什么?VACUUM 为什么不可省略?
思考题:
你查过自己数据库的缓存命中率吗?如果命中率低于 99%,你怀疑是 shared_buffers 不够,还是查询模式有问题?另外,你有没有遇到过"大量 UPDATE 之后,即使查询没变也变慢了"的情况------当时是怎么定位到死元组膨胀的?欢迎在评论区聊聊。