Skip to content

PostgreSQL MVCC、VACUUM 与 WAL 原理

这一页专门讲 PostgreSQL 最核心、也最容易学不懂的三个机制:MVCC、VACUUM、WAL

如果只背“PostgreSQL 更新会产生新版本,VACUUM 清理旧版本,WAL 用于恢复”,面试和排查都不够。真正要理解的是:

  1. 一行数据更新时,旧版本和新版本分别在哪里。
  2. 查询为什么能看到“自己应该看到的版本”。
  3. 为什么普通查询不阻塞更新。
  4. 为什么旧版本不能马上删。
  5. 为什么 VACUUM 跟不上会让表越来越大。
  6. 为什么 WAL 可以保证提交后宕机不丢。

先建立整体图

mermaid
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 版本

示例表:

sql
create table account (
  id bigint primary key,
  balance numeric(12, 2) not null
);

insert into account(id, balance) values (1, 100.00);

当执行:

sql
update account
set balance = 80.00
where id = 1;

PostgreSQL 通常不是直接在原位置把 100.00 覆盖成 80.00,而是写出一个新 tuple 版本。

mermaid
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 插入一行:

text
tuple(id=1, balance=100, xmin=10, xmax=空)

事务 20 更新这行:

text
旧 tuple: id=1, balance=100, xmin=10, xmax=20
新 tuple: id=1, balance=80,  xmin=20, xmax=空

读数据时,PostgreSQL 会结合当前事务快照判断:

  1. 创建它的事务是否已经提交。
  2. 删除或更新它的事务是否对当前查询可见。
  3. 如果当前版本不可见,就跳过它。

快照是什么

快照不是把整张表复制一份。快照更像一份事务可见性规则。

它大致告诉当前查询:

  1. 查询开始时哪些事务已经提交。
  2. 哪些事务还没提交。
  3. 当前查询应该看见哪些版本。
mermaid
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 语句开始时生成自己的快照

mermaid
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 下,事务开始后第一次查询建立的快照会在事务内复用

mermaid
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 只需要找一个对自己可见的版本。它不需要阻止别人更新。

mermaid
sequenceDiagram
    participant R as 读事务
    participant W as 写事务
    R->>R: 生成快照
    W->>W: update 生成新 tuple
    R->>R: 继续读取旧 tuple
    W->>W: commit

读事务读旧版本,写事务写新版本,两者可以并发。

但这不代表所有操作都不阻塞。下面这个会加锁:

sql
select *
from account
where id = 1
for update;

for update 表示“我要读取并准备更新这行”,它必须防止别人同时改这行。

为什么 VACUUM 必须存在

更新、删除产生的旧 tuple 不会马上消失。原因是可能还有老事务需要看见它们。

mermaid
flowchart TD
    A["旧 tuple 产生"] --> B{"是否还有活跃事务可能看见它"}
    B -- "有" --> C["不能清理"]
    B -- "没有" --> D["VACUUM 标记空间可复用"]

如果没有 VACUUM:

  1. 表会持续变大。
  2. 索引可能也膨胀。
  3. 查询扫描更多页。
  4. Buffer Cache 容纳有效数据变少。
  5. 性能逐渐下降。

VACUUM 不是简单删除文件

普通 VACUUM 通常做的是:

  1. 找到 dead tuple。
  2. 确认没有事务需要它。
  3. 标记这些空间可以被后续插入复用。
  4. 更新可见性相关信息。
  5. 帮助避免事务 ID 回卷问题。

它不一定马上把操作系统文件变小。很多时候表文件大小不变,但内部空间可复用。

如果要强制收缩物理文件,可能用 VACUUM FULL,但它会重写表,锁影响更大,生产要谨慎。

长事务为什么危险

长事务会让 PostgreSQL 认为很老的版本仍可能被需要。

mermaid
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 压力大清理追不上变更

排查长事务:

sql
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,意思是:数据页真正刷盘前,描述这次修改的日志要先可靠写入

mermaid
flowchart TD
    A["修改 Buffer 中的数据页"] --> B["生成 WAL 记录"]
    B --> C["提交时 WAL 落盘"]
    C --> D["告诉客户端提交成功"]
    D --> E["数据页稍后刷盘"]
    E --> F["如果宕机,用 WAL 重放恢复"]

为什么这样设计?

做法问题
每次提交都刷完整数据页随机 IO 多,性能差
只改内存不写日志宕机后已提交事务会丢
先写 WAL 再慢慢刷数据页顺序写日志性能好,还能恢复

WAL 把随机写数据页的压力,转化成顺序写日志,再由后台慢慢刷数据页。

一次 update 的完整过程

mermaid
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清理没人需要的旧版本

商业场景:订单状态更新

订单状态从待支付改成已支付:

sql
update orders
set status = 'PAID',
    paid_at = now()
where order_no = 'O202607050001'
  and status = 'WAIT_PAY';

这个 SQL 背后的过程:

  1. 通过订单号索引找到订单 tuple。
  2. 判断当前版本是否可见。
  3. 对目标行加写锁。
  4. 生成新 tuple,状态为 PAID
  5. 旧 tuple 标记为被该事务更新。
  6. 写 WAL。
  7. 提交后新版本对其他事务可见。
  8. 旧版本等没有事务需要后由 VACUUM 清理。

如果一个报表事务开了很久,旧版本可能长期不能清理,订单表就会膨胀。

常见坑

后果正确理解
长事务不提交VACUUM 清不掉旧版本事务要短,报表不要长时间占用事务
只建索引不管 VACUUM表膨胀仍然慢PostgreSQL 性能依赖清理机制
以为 VACUUM 会缩小文件普通 VACUUM 多数只是空间复用文件收缩要谨慎评估 VACUUM FULL
不看真实计划估算计划可能误导EXPLAIN ANALYZE 看实际执行
把从库当强一致读流复制可能延迟写后读要走主库或做一致性控制

面试标准回答

text
PostgreSQL 的 MVCC 通过 tuple 多版本实现。每个 tuple 有 xmin、xmax 等事务信息,查询开始时生成快照,再根据快照判断某个版本是否可见。更新时通常不是原地覆盖,而是生成新 tuple,旧 tuple 标记为旧版本,所以普通 select 可以读取旧版本,不阻塞 update。旧版本不能立即删除,因为可能还有老事务需要它,VACUUM 会在确认没有事务需要后清理 dead tuple 并让空间可复用。长事务会阻止 VACUUM 清理,导致表和索引膨胀。WAL 是预写日志,数据页刷盘前先写 WAL,事务提交后即使宕机,也可以通过 WAL 重放恢复已提交修改。