PostgreSQL MVCC、VACUUM 与 WAL 原理
这一页专门讲 PostgreSQL 最核心、也最容易学不懂的三个机制:MVCC、VACUUM、WAL。
如果只背“PostgreSQL 更新会产生新版本,VACUUM 清理旧版本,WAL 用于恢复”,面试和排查都不够。真正要理解的是:
- 一行数据更新时,旧版本和新版本分别在哪里。
- 查询为什么能看到“自己应该看到的版本”。
- 为什么普通查询不阻塞更新。
- 为什么旧版本不能马上删。
- 为什么 VACUUM 跟不上会让表越来越大。
- 为什么 WAL 可以保证提交后宕机不丢。
先建立整体图
flowchart TD
A["业务执行 update"] --> B["写入新 tuple 版本"]
B --> C["旧 tuple 标记为旧版本"]
B --> D["生成 WAL 记录"]
D --> E["事务提交"]
E --> F["WAL 按策略落盘"]
C --> G["查询根据快照判断可见性"]
C --> H["VACUUM 后续清理死 tuple"]一句话串起来:
PostgreSQL 用 MVCC 让读操作按快照选择可见版本,用 WAL 保证修改可恢复,用 VACUUM 清理已经没人需要的旧版本。
Tuple 是什么
在 PostgreSQL 中,表里保存的不是抽象的“一行”,而是一个个 tuple。更准确地说,同一条业务记录在不同时间可能对应多个 tuple 版本。
示例表:
create table account (
id bigint primary key,
balance numeric(12, 2) not null
);
insert into account(id, balance) values (1, 100.00);当执行:
update account
set balance = 80.00
where id = 1;PostgreSQL 通常不是直接在原位置把 100.00 覆盖成 80.00,而是写出一个新 tuple 版本。
flowchart TD
A["旧 tuple<br/>id=1 balance=100"] --> B["被标记为旧版本"]
C["新 tuple<br/>id=1 balance=80"] --> D["成为当前较新版本"]
A --> E["旧版本仍可能被老事务读取"]为什么不直接覆盖?
| 如果直接覆盖 | 会出现的问题 |
|---|---|
| 老事务正在读 | 它可能突然读到新值,破坏一致性读 |
| 回滚事务 | 没有旧值就难以恢复 |
| 并发读写 | 读写会更容易互相阻塞 |
PostgreSQL 选择保留多个版本,代价是旧版本需要后续清理。
xmin 和 xmax
每个 tuple 版本都有事务相关信息。初学阶段重点理解两个概念:
| 字段 | 简化理解 |
|---|---|
xmin | 创建这个 tuple 的事务 ID |
xmax | 删除或废弃这个 tuple 的事务 ID |
假设事务 10 插入一行:
tuple(id=1, balance=100, xmin=10, xmax=空)事务 20 更新这行:
旧 tuple: id=1, balance=100, xmin=10, xmax=20
新 tuple: id=1, balance=80, xmin=20, xmax=空读数据时,PostgreSQL 会结合当前事务快照判断:
- 创建它的事务是否已经提交。
- 删除或更新它的事务是否对当前查询可见。
- 如果当前版本不可见,就跳过它。
快照是什么
快照不是把整张表复制一份。快照更像一份事务可见性规则。
它大致告诉当前查询:
- 查询开始时哪些事务已经提交。
- 哪些事务还没提交。
- 当前查询应该看见哪些版本。
flowchart TD
A["select 开始"] --> B["生成事务快照"]
B --> C["扫描 tuple"]
C --> D["检查 xmin / xmax"]
D --> E{"按快照可见吗"}
E -- "可见" --> F["返回该版本"]
E -- "不可见" --> G["跳过该版本"]这就是为什么 PostgreSQL 普通 select 不会读到未提交数据。
Read Committed 和 Repeatable Read 的差异
Read Committed
Read Committed 下,每条 SQL 语句开始时生成自己的快照。
sequenceDiagram
participant A as 事务A
participant B as 事务B
A->>A: select,看到 balance=100
B->>B: update balance=80
B->>B: commit
A->>A: 再次 select,看到 balance=80同一个事务 A 里,两次查询可能看到不同结果,因为每条语句用的是新快照。
Repeatable Read
Repeatable Read 下,事务开始后第一次查询建立的快照会在事务内复用。
sequenceDiagram
participant A as 事务A
participant B as 事务B
A->>A: select,看到 balance=100
B->>B: update balance=80
B->>B: commit
A->>A: 再次 select,仍看到 balance=100这就是“可重复读”的直观含义。
普通 select 为什么不阻塞 update
普通 select 只需要找一个对自己可见的版本。它不需要阻止别人更新。
sequenceDiagram
participant R as 读事务
participant W as 写事务
R->>R: 生成快照
W->>W: update 生成新 tuple
R->>R: 继续读取旧 tuple
W->>W: commit读事务读旧版本,写事务写新版本,两者可以并发。
但这不代表所有操作都不阻塞。下面这个会加锁:
select *
from account
where id = 1
for update;for update 表示“我要读取并准备更新这行”,它必须防止别人同时改这行。
为什么 VACUUM 必须存在
更新、删除产生的旧 tuple 不会马上消失。原因是可能还有老事务需要看见它们。
flowchart TD
A["旧 tuple 产生"] --> B{"是否还有活跃事务可能看见它"}
B -- "有" --> C["不能清理"]
B -- "没有" --> D["VACUUM 标记空间可复用"]如果没有 VACUUM:
- 表会持续变大。
- 索引可能也膨胀。
- 查询扫描更多页。
- Buffer Cache 容纳有效数据变少。
- 性能逐渐下降。
VACUUM 不是简单删除文件
普通 VACUUM 通常做的是:
- 找到 dead tuple。
- 确认没有事务需要它。
- 标记这些空间可以被后续插入复用。
- 更新可见性相关信息。
- 帮助避免事务 ID 回卷问题。
它不一定马上把操作系统文件变小。很多时候表文件大小不变,但内部空间可复用。
如果要强制收缩物理文件,可能用 VACUUM FULL,但它会重写表,锁影响更大,生产要谨慎。
长事务为什么危险
长事务会让 PostgreSQL 认为很老的版本仍可能被需要。
sequenceDiagram
participant A as 长事务A
participant B as 业务事务B
participant V as VACUUM
A->>A: begin,并保持很久不提交
B->>B: 大量 update/delete
V->>V: 尝试清理 dead tuple
V-->>V: 发现 A 可能还需要旧版本,不能清理后果:
| 后果 | 原因 |
|---|---|
| 表膨胀 | dead tuple 不能清理 |
| 索引膨胀 | 索引项也可能持续积累 |
| 查询变慢 | 扫描更多无效版本 |
| autovacuum 压力大 | 清理追不上变更 |
排查长事务:
select pid,
usename,
state,
now() - xact_start as xact_age,
query
from pg_stat_activity
where xact_start is not null
order by xact_age desc;WAL 为什么能保证崩溃恢复
WAL 是 Write-Ahead Logging,意思是:数据页真正刷盘前,描述这次修改的日志要先可靠写入。
flowchart TD
A["修改 Buffer 中的数据页"] --> B["生成 WAL 记录"]
B --> C["提交时 WAL 落盘"]
C --> D["告诉客户端提交成功"]
D --> E["数据页稍后刷盘"]
E --> F["如果宕机,用 WAL 重放恢复"]为什么这样设计?
| 做法 | 问题 |
|---|---|
| 每次提交都刷完整数据页 | 随机 IO 多,性能差 |
| 只改内存不写日志 | 宕机后已提交事务会丢 |
| 先写 WAL 再慢慢刷数据页 | 顺序写日志性能好,还能恢复 |
WAL 把随机写数据页的压力,转化成顺序写日志,再由后台慢慢刷数据页。
一次 update 的完整过程
flowchart TD
A["客户端执行 update"] --> B["定位目标 tuple"]
B --> C["检查锁和可见性"]
C --> D["写新 tuple 版本"]
D --> E["旧 tuple 设置 xmax"]
E --> F["生成 WAL"]
F --> G["事务提交"]
G --> H["WAL 按策略落盘"]
H --> I["返回提交成功"]
I --> J["后续 VACUUM 清理旧版本"]每一步的意义:
| 步骤 | 为什么需要 |
|---|---|
| 定位 tuple | 找到要修改的数据版本 |
| 检查锁 | 防止写写冲突 |
| 写新 tuple | 保留 MVCC 多版本 |
| 标记旧 tuple | 让新查询知道旧版本已经被更新 |
| 生成 WAL | 保证宕机可恢复 |
| 提交 | 让新版本对后续事务可见 |
| VACUUM | 清理没人需要的旧版本 |
商业场景:订单状态更新
订单状态从待支付改成已支付:
update orders
set status = 'PAID',
paid_at = now()
where order_no = 'O202607050001'
and status = 'WAIT_PAY';这个 SQL 背后的过程:
- 通过订单号索引找到订单 tuple。
- 判断当前版本是否可见。
- 对目标行加写锁。
- 生成新 tuple,状态为
PAID。 - 旧 tuple 标记为被该事务更新。
- 写 WAL。
- 提交后新版本对其他事务可见。
- 旧版本等没有事务需要后由 VACUUM 清理。
如果一个报表事务开了很久,旧版本可能长期不能清理,订单表就会膨胀。
常见坑
| 坑 | 后果 | 正确理解 |
|---|---|---|
| 长事务不提交 | VACUUM 清不掉旧版本 | 事务要短,报表不要长时间占用事务 |
| 只建索引不管 VACUUM | 表膨胀仍然慢 | PostgreSQL 性能依赖清理机制 |
| 以为 VACUUM 会缩小文件 | 普通 VACUUM 多数只是空间复用 | 文件收缩要谨慎评估 VACUUM FULL |
| 不看真实计划 | 估算计划可能误导 | 用 EXPLAIN ANALYZE 看实际执行 |
| 把从库当强一致读 | 流复制可能延迟 | 写后读要走主库或做一致性控制 |
面试标准回答
PostgreSQL 的 MVCC 通过 tuple 多版本实现。每个 tuple 有 xmin、xmax 等事务信息,查询开始时生成快照,再根据快照判断某个版本是否可见。更新时通常不是原地覆盖,而是生成新 tuple,旧 tuple 标记为旧版本,所以普通 select 可以读取旧版本,不阻塞 update。旧版本不能立即删除,因为可能还有老事务需要它,VACUUM 会在确认没有事务需要后清理 dead tuple 并让空间可复用。长事务会阻止 VACUUM 清理,导致表和索引膨胀。WAL 是预写日志,数据页刷盘前先写 WAL,事务提交后即使宕机,也可以通过 WAL 重放恢复已提交修改。