数据库基础体系 · 第 22/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

PostgreSQL MVCC 与 Vacuum:Tuple 可见性、膨胀、冻结和诊断

在 PostgreSQL 中,一条逻辑上的“行”通常不是一个永远被原地修改的物理对象。UPDATE 往往会产生新的 tuple,旧 tuple 仍然留在表文件中;事务快照决定当前查询应该看到哪个版本,Vacuum 则负责在安全时机回收不再可能被任何事务看到的旧版本,并处理事务 ID 的长期生命周期。

理解这一机制,需要把几个容易混淆的概念分开:

  • MVCC:多版本并发控制,决定读写并发时哪些版本可见。
  • tuple:表中的物理行版本,而不是抽象意义上的逻辑行。
  • 快照:某个事务或语句观察数据库时使用的可见性边界。
  • Vacuum:清理死 tuple、维护可见性信息并推进冻结状态的后台或手工操作。
  • 膨胀:旧 tuple、索引项或空闲空间导致关系文件长期大于当前有效数据所需空间的现象。
  • 冻结:把足够老的事务 ID 标记为不再需要按普通事务 ID 判断,从而避免事务 ID 回卷。

一、MVCC 的基本模型:更新不是覆盖,而是产生版本

1. 逻辑行与物理 tuple

假设表中有一行:

id = 1, balance = 100

事务 T1 执行:

UPDATE account SET balance = 120 WHERE id = 1;

从逻辑上看,balance 从 100 变成了 120;但从物理上,PostgreSQL 通常会保留旧版本并创建新版本:

旧 tuple: id = 1, balance = 100
新 tuple: id = 1, balance = 120

旧 tuple 的事务元数据会记录它何时被创建、何时失效;新 tuple 则记录它何时被创建。

这使得一个正在运行的查询可以继续看到旧版本,而不必被另一个事务正在进行的更新阻塞。例如:

  1. T1 开始一个长事务,读取到 balance = 100
  2. T2 更新为 120 并提交。
  3. T1 在自己的快照下再次读取。
  4. T1 仍可能看到 100,因为它的快照早于 T2 的提交。

这不是“读到了磁盘上的旧数据”这么简单,而是快照明确判断旧 tuple 对 T1 可见。

物理 tuple 的关键字段

普通 heap tuple 的可见性主要依赖以下信息:

  • xmin:创建该 tuple 版本的事务 ID。
  • xmax:使该 tuple 版本失效的事务 ID,常见于 UPDATEDELETE
  • ctid:该 tuple 当前所在页面和槽位的位置,例如 (42,7)
  • infomask 等标志位:记录锁、提交状态提示、冻结状态等信息。

可以使用系统列观察部分信息:

CREATE TABLE mvcc_demo (
    id integer PRIMARY KEY,
    value text
);

INSERT INTO mvcc_demo VALUES (1, 'old');

SELECT tableoid, ctid, xmin, xmax, * 
FROM mvcc_demo;

一次可能的结果类似:

 tableoid | ctid  | xmin | xmax | id | value
----------+-------+------+------+----+-------
 mvcc_demo| (0,1) |  100 |    0 |  1 | old

这里的数字仅用于说明,实际事务 ID 由数据库运行状态决定。xmax = 0 通常表示该版本当前没有记录一个使其失效的事务,但不能把所有可见性判断简化成只检查 xmax

执行更新:

UPDATE mvcc_demo SET value = 'new' WHERE id = 1;

SELECT ctid, xmin, xmax, id, value
FROM mvcc_demo;

当前普通查询通常只显示新版本。但旧版本可能仍在 heap 页面中,直到 Vacuum 确认它已经不可能被任何需要的快照看到。


二、快照如何判断 tuple 是否可见

1. 快照中的三个核心边界

可以把一个 PostgreSQL 快照抽象为:

  • xmin_snapshot:快照建立时,仍可能影响可见性的最早事务边界。
  • xmax_snapshot:快照建立时,尚未分配或不应被该快照看到的事务边界。
  • in_progress:快照建立时仍处于进行中的事务 ID 集合。

具体内部表示还涉及子事务、事务状态、当前事务自身等情况,下面先讨论普通事务的核心逻辑。

对于一个 tuple 的创建事务 xmin

  1. 如果创建事务已经提交,并且早于快照边界,tuple 可能可见。
  2. 如果 xmin 对应的事务仍在 in_progress 中,tuple 对该快照不可见。
  3. 如果 xmin >= xmax_snapshot,该事务是在快照建立之后才开始或分配的,对该快照不可见。
  4. 如果创建事务是当前正在执行的事务,则需要按当前事务和命令可见性规则处理。

对于 tuple 的失效事务 xmax

  1. xmax 无效,说明没有已知的删除或更新事务,旧版本可以继续参与可见性判断。
  2. xmax 对应事务尚未提交,旧 tuple 通常仍对其他事务可见。
  3. xmax 对应事务已经提交,并且该提交对当前快照可见,则旧 tuple 不可见。
  4. 如果删除事务是在当前快照之后才提交,旧 tuple 仍可能对当前快照可见。

因此,常见的简化形式是:

tuple 可见
= 创建它的事务对快照可见
  且使它失效的事务对快照不可见

这个形式比“xmin 小于某个值就可见”更准确。事务提交状态和快照中的进行中事务集合同样重要。

2. 一个完整并发例子

准备数据:

CREATE TABLE mvcc_account (
    id integer PRIMARY KEY,
    balance integer
);

INSERT INTO mvcc_account VALUES (1, 100);

打开两个会话,分别称为 Session A 和 Session B。

Session A:建立旧快照

BEGIN ISOLATION LEVEL REPEATABLE READ;

SELECT balance FROM mvcc_account WHERE id = 1;

结果:

 balance
---------
     100

REPEATABLE READ 下,该事务后续语句继续使用同一个事务快照。

Session B:更新并提交

BEGIN;

UPDATE mvcc_account
SET balance = 120
WHERE id = 1;

COMMIT;

回到 Session A

SELECT balance FROM mvcc_account WHERE id = 1;

结果仍然是:

 balance
---------
     100

原因不是 Session B 的更新失败,而是:

  1. Session A 建立快照时,旧 tuple 对它可见。
  2. Session B 创建了新 tuple,并提交。
  3. Session B 的提交发生在 Session A 快照之后。
  4. 新 tuple 对 Session A 不可见。
  5. 旧 tuple 的失效事务对 Session A 也不可见,因此旧 tuple 继续可见。

如果在 Session A 中执行:

COMMIT;

SELECT balance FROM mvcc_account WHERE id = 1;

新的事务会建立新快照,此时通常看到:

 balance
---------
     120

3. READ COMMITTED 的边界

默认隔离级别 READ COMMITTED 下,通常每条语句建立自己的快照。因此同一个事务中的两条 SELECT 可能看到不同版本:

BEGIN;

SELECT balance FROM mvcc_account WHERE id = 1;
-- 可能看到 100

-- 另一个事务提交了 balance = 120

SELECT balance FROM mvcc_account WHERE id = 1;
-- 可能看到 120

所以必须区分:

  • 事务快照:如 REPEATABLE READ 中事务范围内复用。
  • 语句快照:如 READ COMMITTED 中每条语句重新建立。

READ COMMITTED 下,单条语句内部仍然使用一致快照;它不是逐行读取时不断改变可见性。


三、UPDATE、DELETE 与索引为什么会留下旧痕迹

1. UPDATE 的物理过程

一次典型的 UPDATE 可以抽象为:

更新前:
heap tuple T1
  xmin = 创建 T1 的事务
  xmax = 0

执行 UPDATE:
  T1.xmax = 当前更新事务
  创建 heap tuple T2
  T2.xmin = 当前更新事务
  T2.xmax = 0

事务提交后:
  T1 对新快照不可见
  T2 对新快照可见

如果更新事务尚未提交,其他事务仍可能看到 T1;如果事务回滚,T1 仍然有效,而 T2 不应被其他事务看到。

2. DELETE 不会立即缩小表文件

DELETE 通常只是让 tuple 进入“对新快照不可见”的状态。它不会同步地把页面压缩,也不会立即把关系文件截短。

因此:

DELETE FROM mvcc_account WHERE id = 1;

执行后可能出现:

  • 逻辑上该行已经不存在;
  • 物理 heap 页面仍然包含旧 tuple;
  • 索引中可能仍有指向该 tuple 的索引项;
  • 后续 Vacuum 才会在安全时机清理这些内容。

3. HOT 更新

PostgreSQL 支持 HOT(Heap-Only Tuple)更新。满足以下条件时,更新可以只在 heap 中形成版本链,而不必为每个索引重新创建索引项:

  1. 更新没有修改被索引的列;
  2. 新 tuple 能放入同一个 heap 页面。

此时索引可以继续指向旧版本链的入口,访问 heap 时沿着链找到当前可见版本。

HOT 的好处是减少索引写入和索引膨胀,但它不是所有更新都能使用:

  • 更新了索引列,通常不能使用 HOT;
  • 页面没有足够空间,也不能使用 HOT;
  • 表上的索引越多,非 HOT 更新的索引维护成本越高。

可以观察表级 HOT 统计:

SELECT
    relname,
    n_tup_upd,
    n_tup_hot_upd,
    CASE
        WHEN n_tup_upd = 0 THEN NULL
        ELSE round(100.0 * n_tup_hot_upd / n_tup_upd, 2)
    END AS hot_update_percent
FROM pg_stat_user_tables
WHERE relname = 'mvcc_account';

这是累计统计,不能把某一时刻的比例直接当作单次操作的保证。


四、Vacuum 到底清理什么

1. 死 tuple 的定义

一个旧 tuple 是否可以清理,不是由“创建它的事务是否提交”单独决定的,而是要确认:

不存在任何仍然有效的事务快照,未来也不需要这个 tuple 版本。

例如:

  1. T1 读取到旧版本,并一直保持事务打开。
  2. T2 更新该行并提交。
  3. T3 执行 Vacuum。

即使 T2 已提交,T1 的快照仍可能需要旧版本。因此 T3 不能删除旧 tuple。

当 T1 提交或回滚后,Vacuum 才可能确认旧版本已经不再需要。

Vacuum 使用一个与当前数据库事务状态和快照有关的清理边界,通常称为 oldest xmin 一类的概念。实际判断还会受到复制槽、Hot Standby feedback、逻辑解码等机制影响。

2. 普通 VACUUM 的主要工作

普通的 lazy VACUUM 通常会:

  1. 扫描 heap 页面;
  2. 判断哪些 tuple 对所有相关快照都不可见;
  3. 清理可回收的 heap tuple;
  4. 处理索引中的死项;
  5. 更新空闲空间信息;
  6. 维护 visibility map;
  7. 在满足条件时冻结足够老的 tuple;
  8. 根据选项或自动维护策略执行统计信息更新。

命令示例:

VACUUM (VERBOSE, ANALYZE) public.mvcc_account;

其中:

  • VERBOSE 输出处理过程和统计信息;
  • ANALYZE 收集优化器统计信息;
  • VACUUM 负责版本回收和存储维护;
  • ANALYZE 主要负责统计信息,两者相关但不是同一件事。

普通 VACUUM 的核心特点是:

  • 通常不需要重写整张表;
  • 通常可以与普通读写并发;
  • 释放的页面空间优先供该表后续写入复用;
  • 一般不会把已分配的文件空间还给操作系统。

3. Vacuum 不一定立刻回收所有空间

即使 n_dead_tup 很高,执行一次 Vacuum 也可能清理有限,原因包括:

  • 长事务仍持有旧快照;
  • 复制槽保留了 xmin 或 catalog xmin;
  • Hot Standby feedback 把备库上的 xmin 反馈给主库;
  • 索引清理被推迟;
  • 页面布局导致空间不能立即截断;
  • 新产生的死 tuple 又很快增加;
  • 表正在高并发写入。

因此,看到“执行过 Vacuum,但文件大小没有下降”并不代表 Vacuum 失效。普通 Vacuum 的目标主要是让空间可重用,而不是压缩文件。


五、表膨胀与索引膨胀

1. 什么是膨胀

膨胀可以从三个层次理解:

Heap 膨胀

表文件中包含大量:

  • 已不可见但尚未清理的 tuple;
  • 页面内部无法有效复用的碎片空间;
  • 由于更新产生的多版本残留。

索引膨胀

更新和删除产生的旧索引项不会总是立即消失。索引可能包含:

  • 指向已死亡 heap tuple 的索引项;
  • 空闲但未归还操作系统的页面;
  • 页面分裂和更新后形成的低利用率空间。

文件空间膨胀

关系文件已经增长到较大尺寸,但当前有效数据并不需要这么多空间。普通 Vacuum 通常不能把这些文件整体缩回操作系统。

2. 膨胀的典型因果链

一个高更新表可能经历:

频繁 UPDATE
    ↓
产生大量新 heap tuple 和旧 tuple
    ↓
索引也产生新索引项
    ↓
Vacuum 因长事务无法清理旧版本
    ↓
页面和索引持续增长
    ↓
表扫描、缓存命中率和写放大恶化

但“更新频繁”并不自动等于“必然膨胀”。如果:

  • 更新可使用 HOT;
  • 页面有足够空间;
  • Vacuum 及时运行;
  • 没有长事务阻挡清理;

空间可能被较好地复用。

3. VACUUM FULL 的含义和风险

VACUUM (FULL, VERBOSE) public.mvcc_account;

VACUUM FULL 会重写表,把仍然有效的 tuple 紧密排列到新的文件布局中,并重建相关索引。它的效果更接近“压缩和重写”,不是普通 Vacuum 的加强扫描。

重要特征:

  • 需要较强的表级锁,通常会阻塞并发读写;
  • 重写期间需要额外磁盘空间;
  • 运行时间可能较长;
  • 失败或空间不足时需要重点检查剩余空间和锁等待;
  • 不适合把它当作日常自动维护手段。

需要回收大量文件空间时,通常要在可接受的停顿窗口中执行,并在执行前确认:

SELECT
    pid,
    usename,
    state,
    wait_event_type,
    wait_event,
    query
FROM pg_stat_activity
WHERE datname = current_database();

同时检查关系大小:

SELECT
    c.oid::regclass AS relation,
    pg_size_pretty(pg_table_size(c.oid)) AS table_size,
    pg_size_pretty(pg_indexes_size(c.oid)) AS index_size,
    pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size
FROM pg_class AS c
WHERE c.oid = 'public.mvcc_account'::regclass;

VACUUM FULL 不是唯一的重写方式。CLUSTER、某些扩展提供的在线重组工具也可能重写关系,但它们的锁、空间、索引和失败恢复特性不同,不能混为一谈。


六、Visibility Map:Vacuum 与索引仅扫描的重要桥梁

PostgreSQL 为 heap 页面维护 visibility map。它以页面为粒度记录重要状态,常见的两个标志是:

  • all-visible:页面中的所有 tuple 对所有事务都可见。
  • all-frozen:页面中的 tuple 已经冻结,不需要按普通事务 ID 判断。

1. all-visible 的用途

如果一个 heap 页面被标记为 all-visible,执行 Index Only Scan 时可以只读取索引,不必访问 heap 验证 tuple 可见性。

例如:

CREATE TABLE ios_demo (
    id integer PRIMARY KEY,
    payload text
);

INSERT INTO ios_demo
SELECT i, md5(i::text)
FROM generate_series(1, 100000) AS g(i);

VACUUM (ANALYZE) ios_demo;

EXPLAIN (ANALYZE, BUFFERS)
SELECT id
FROM ios_demo
WHERE id BETWEEN 100 AND 200;

如果查询只需要索引中已有的列,且相关 heap 页面已经被标记为 all-visible,执行计划可能显示:

Index Only Scan ...
Heap Fetches: 0

如果输出中 Heap Fetches 很高,常见原因是:

  • 页面尚未被 Vacuum 标记为 all-visible;
  • 页面近期发生过更新;
  • 查询需要访问索引中没有的列;
  • 可见性标记因写入而被清除。

因此,Vacuum 不只是“清理旧数据”,它还会影响 Index Only Scan 是否真正只访问索引。

2. all-frozen 与冻结

all-frozen 表示页面上的 tuple 已经冻结。它减少了未来进行事务 ID 可见性检查的需要,也有助于减少某些 Vacuum 扫描成本。

不过,不能仅凭 visibility map 的状态推断整个表都安全。表级冻结进度还需要结合 relfrozenxid、事务年龄和 Vacuum 日志判断。

可以使用 pg_visibility 扩展进行更细粒度检查:

CREATE EXTENSION IF NOT EXISTS pg_visibility;

SELECT *
FROM pg_visibility_map('public.ios_demo'::regclass)
LIMIT 10;

该扩展需要相应权限,并且属于诊断工具,不应将其视为普通业务查询的必要依赖。


七、冻结:为什么事务 ID 不能无限增长

1. 事务 ID 是有限宽度的逻辑时钟

PostgreSQL 使用事务 ID 判断 tuple 的新旧和可见性。普通事务 ID 在实现上是有限宽度的,经过足够长时间会发生回卷。

如果直接把事务 ID 当作无限增长的整数比较:

旧事务 ID < 新事务 ID

当数值回到起点后,这种比较会产生歧义。于是一个非常老的 tuple 可能被误判成“未来创建”,或者新事务被误判成“很老”。

这会破坏 MVCC 的正确性,因此 PostgreSQL 必须在事务 ID 过老之前冻结 tuple。

2. FrozenTransactionId 的直觉

冻结并不是给 tuple 分配一个新的普通事务 ID,而是把它标记为:

这个 tuple 的创建事务已经足够古老,它对未来所有正常事务都视为已提交且不可再按普通 XID 回卷规则解释。

因此,冻结后的 tuple 不再依赖原始创建事务 ID 的正常生命周期。

可以把过程简化为:

普通 tuple:
    xmin = 旧事务 ID

冻结后:
    xmin 的事务状态被标记为 Frozen

实际 tuple header 和 infomask 的表达方式由 PostgreSQL 内部实现决定,应用不应直接修改这些字段。

3. relfrozenxid 和 tuple 冻结的关系

pg_class.relfrozenxid 表示该表仍可能存在的最老未冻结事务 ID 的边界。它不是“表中所有 tuple 的 xmin”,也不是当前最新事务 ID。

查看关系冻结年龄:

SELECT
    c.oid::regclass AS relation,
    age(c.relfrozenxid) AS xid_age,
    age(c.relminmxid) AS multixact_age,
    c.relfrozenxid,
    c.relminmxid
FROM pg_class AS c
WHERE c.relkind IN ('r', 'm')
ORDER BY age(c.relfrozenxid) DESC;

数据库级别可以查看:

SELECT
    datname,
    age(datfrozenxid) AS xid_age,
    mxid_age(datminmxid) AS multixact_age
FROM pg_database
ORDER BY age(datfrozenxid) DESC;

这些年龄是诊断冻结风险的关键指标。真正的危险不是“事务 ID 数字很大”,而是某个表或数据库的冻结年龄接近系统允许的回卷边界。

4. 普通冻结与防回卷 Vacuum

Vacuum 会在合适时机冻结足够老的 tuple。相关配置包括:

  • vacuum_freeze_min_age:tuple 达到一定年龄后,普通 Vacuum 可以考虑冻结。
  • vacuum_freeze_table_age:表年龄达到一定程度后,Vacuum 会更积极地扫描和冻结。
  • autovacuum_freeze_max_age:用于防止事务 ID 回卷的强制维护边界。

具体默认值和参数行为应以目标 PostgreSQL 版本的官方文档为准。生产上不应为了减少 Vacuum 次数而随意把这些阈值调得过大,因为代价可能从“更多维护”变成“必须执行防回卷 Vacuum”。

当表接近回卷风险时,即使该表的普通自动 Vacuum 频率不高,系统也可能启动 anti-wraparound autovacuum。若维护被阻塞,最终可能拒绝分配新的事务 ID,以保护数据正确性。

5. Multixact 冻结

多事务(Multixact)用于表示多个事务共同持有某些行锁等状态。涉及行锁、外键检查或并发锁定时,tuple 的锁信息可能引用 Multixact ID。

因此除了普通 XID 年龄,还要关注:

SELECT
    datname,
    mxid_age(datminmxid) AS multixact_age
FROM pg_database
ORDER BY mxid_age(datminmxid) DESC;

只检查 age(datfrozenxid) 而忽略 Multixact 年龄,可能漏掉另一类回卷风险。


八、为什么 Vacuum 可能无法清理:长事务、复制与快照保留

1. 长事务和 idle in transaction

最常见的阻塞来源是长事务:

SELECT
    pid,
    usename,
    application_name,
    client_addr,
    state,
    xact_start,
    backend_xmin,
    now() - xact_start AS xact_age,
    query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start;

重点关注:

  • state = 'idle in transaction'
  • xact_start 很早;
  • backend_xmin 很早;
  • 应用连接池长时间保持事务未提交。

一个事务即使当前没有执行 SQL,只要事务仍在打开,就可能继续保留旧快照。

2. 复制槽

复制槽可能保留 WAL,也可能保留 xmin,使主库不能清理某些旧版本:

SELECT
    slot_name,
    slot_type,
    active,
    restart_lsn,
    confirmed_flush_lsn,
    xmin,
    catalog_xmin
FROM pg_replication_slots;

需要区分:

  • restart_lsn 主要影响 WAL 保留;
  • xmincatalog_xmin 可能影响 tuple 或系统目录版本清理;
  • 非活动复制槽尤其容易长期积累风险。

删除复制槽是破坏性操作,不能因为它“看起来很旧”就直接删除。必须先确认消费者是否已经永久废弃,必要时先恢复、推进或重新配置消费者。

3. Hot Standby feedback

物理备库可以通过 Hot Standby feedback 把备库上的查询所需 xmin 反馈给主库。这样可以减少备库查询因主库 Vacuum 清理旧版本而产生的冲突,但代价是主库可能保留更多旧 tuple,造成膨胀。

这是一种明确的取舍:

减少备库查询冲突
    ↔
增加主库旧版本保留和膨胀风险

诊断时不能只看主库本地是否有长事务,还要结合备库查询、反馈状态和复制延迟。


九、自动 Vacuum 的工作方式

自动 Vacuum 由 autovacuum launcher 管理工作进程。它通常根据表的修改规模和配置阈值触发,而不是固定地“每隔多少分钟清扫所有表”。

常见触发因素包括:

  • 插入、更新、删除累计达到阈值;
  • 表的死 tuple 达到阈值;
  • 表接近事务 ID 回卷边界;
  • 表接近 Multixact 回卷边界。

相关表级统计可以这样查看:

SELECT
    schemaname,
    relname,
    n_live_tup,
    n_dead_tup,
    n_mod_since_analyze,
    last_vacuum,
    last_autovacuum,
    last_analyze,
    last_autoanalyze,
    vacuum_count,
    autovacuum_count,
    analyze_count,
    autoanalyze_count
FROM pg_stat_user_tables
WHERE relname = 'mvcc_account';

这里的 n_dead_tup 是统计估计值,不是逐 tuple 精确计数。它适合发现趋势,不适合单独作为容量结论。

对于特别大的高更新表,统一的数据库级阈值可能不合适。表级参数可以单独设置,例如:

ALTER TABLE public.mvcc_account SET (
    autovacuum_vacuum_scale_factor = 0.01,
    autovacuum_analyze_scale_factor = 0.005
);

这表示触发条件中的比例阈值更低,但不代表 Vacuum 每次只扫描 1% 的表,也不代表一定能解决膨胀。阈值只是触发调度,实际清理仍受快照、索引、页面空间和 Vacuum 成本限制。


十、诊断 Vacuum 是否正在工作

1. 查看正在运行的 Vacuum

SELECT
    pid,
    datname,
    relid::regclass AS relation,
    phase,
    heap_blks_total,
    heap_blks_scanned,
    heap_blks_vacuumed,
    index_vacuum_count,
    num_dead_tuples,
    max_dead_tuples
FROM pg_stat_progress_vacuum;

可能看到的阶段包括扫描 heap、清理索引、清理 heap、截断 heap 等。不同版本的字段和阶段细节可能有差异,应以目标版本的系统视图文档为准。

几个重要判断:

  • heap_blks_scanned 增长:正在扫描表;
  • heap_blks_vacuumed 增长:正在清理 heap 页面;
  • num_dead_tuples 很高:本轮遇到大量死 tuple;
  • 长时间停在索引清理阶段:可能与索引数量、索引大小或 I/O 有关;
  • 没有进度行:可能没有正在运行的 Vacuum,也可能任务尚未开始或已经结束。

2. 区分“没有运行”和“运行但清不掉”

诊断应按因果链进行:

表是否产生了大量更新/删除?
    ↓
自动 Vacuum 是否触发?
    ↓
Vacuum 是否被锁或资源限制影响?
    ↓
是否有长事务、复制槽或备库反馈保留 xmin?
    ↓
死 tuple 是否已清理?
    ↓
清理后的空间是否能被复用?
    ↓
文件是否需要重写才能缩小?

例如:

  • n_dead_tup 高、last_autovacuum 很久以前:可能是没有及时触发。
  • last_autovacuum 很新,但 n_dead_tup 仍高:可能清理被快照阻塞,或写入速度超过清理速度。
  • n_dead_tup 降低,但文件大小不变:可能是空间已可复用,而非 Vacuum 失败。
  • 表空间不大但索引很大:可能主要是索引膨胀。
  • XID 年龄持续升高:重点转向冻结和回卷,而不是只看 dead tuple。

3. 估算和精确检查

基础诊断:

SELECT
    c.oid::regclass AS relation,
    pg_size_pretty(pg_table_size(c.oid)) AS heap_size,
    pg_size_pretty(pg_indexes_size(c.oid)) AS indexes_size,
    pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size,
    s.n_live_tup,
    s.n_dead_tup
FROM pg_class AS c
JOIN pg_stat_all_tables AS s
  ON s.relid = c.oid
WHERE c.oid = 'public.mvcc_account'::regclass;

需要更细的 heap、空闲空间和死 tuple 估计时,可以使用 pgstattuple

CREATE EXTENSION IF NOT EXISTS pgstattuple;

SELECT *
FROM pgstattuple('public.mvcc_account'::regclass);

该扩展可能需要扫描较多数据,生产环境执行前要评估 I/O 和执行时间。其结果是某一时刻的观测,不是永久准确的实时计量。


十一、常见误解与反例

误解一:DELETE 后磁盘空间马上减少

反例:

DELETE FROM large_table;

即使删除成功,普通 Vacuum 主要会让页面空间可重用,不保证操作系统看到的文件立即缩小。要缩小文件,通常需要关系重写,例如 VACUUM FULL,但这会引入锁和额外空间需求。

误解二:只要事务已经提交,旧 tuple 就能立即清理

反例:

T1: BEGIN;  -- 建立旧快照
T2: UPDATE ...; COMMIT;
T3: VACUUM ...;

T2 已提交,但 T1 仍可能需要旧版本。因此 T3 必须保留旧 tuple。

误解三:n_dead_tup = 0 就表示没有膨胀

n_dead_tup 只是统计估计,且膨胀可能来自:

  • 已清理但未归还的文件空间;
  • 页面内部碎片;
  • 索引低利用率;
  • 长期历史形成的物理布局;
  • 估计统计尚未刷新。

不能只用一个字段判断全部膨胀。

误解四:提高 autovacuum_vacuum_cost_limit 就能解决所有问题

成本参数影响 Vacuum 对 I/O 的节流程度,但不能突破:

  • 长事务保留的快照;
  • 复制槽 xmin;
  • 备库反馈;
  • 锁冲突;
  • 磁盘带宽;
  • 索引清理和表扫描的实际成本。

如果清理边界被阻塞,单纯提高吞吐不会使不可清理的 tuple 变得可清理。

误解五:冻结就是把所有 tuple 的 xmin 改成当前事务 ID

冻结的目的恰恰是让足够老的事务不再依赖普通 XID 生命周期。它不是把数据“更新成当前事务”,也不是业务层面的更新时间操作。

误解六:Vacuum 会读取所有旧 tuple 并删除它们

Vacuum 只能清理已经对所有相关快照不可见的版本。一个仍被长事务需要的旧 tuple,即使业务上看起来已经被更新或删除,也不能清理。


十二、生产故障路径:从膨胀到回卷风险

路径一:长事务导致表持续膨胀

应用开启事务
    ↓
长时间不提交,backend_xmin 停留在旧位置
    ↓
其他事务持续 UPDATE/DELETE
    ↓
旧 tuple 对长事务仍可能可见
    ↓
Vacuum 无法回收这些版本
    ↓
heap 和索引持续增长

验证重点:

SELECT pid, usename, state, xact_start, backend_xmin, query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start;

恢复动作必须先确认事务是否可安全终止:

SELECT pg_terminate_backend(<pid>);

终止后事务会回滚,可能造成额外锁等待、应用错误和重试压力。不能把终止会话作为无条件自动化动作。

路径二:复制槽导致 Vacuum 无法推进

复制消费者停止或严重落后
    ↓
复制槽保留 restart_lsn 或 xmin
    ↓
WAL 或旧 tuple 持续保留
    ↓
磁盘增长、表膨胀或回收延迟

应先确认:

  • 槽对应的消费者是否仍然存在;
  • 消费者能否恢复;
  • 是否有下游系统依赖该槽;
  • 删除或重建槽会丢失什么数据。

路径三:冻结维护受阻

表的 relfrozenxid 年龄持续升高
    ↓
触发更积极的冻结 Vacuum
    ↓
Vacuum 被锁、I/O 或快照阻塞
    ↓
无法推进冻结边界
    ↓
接近 XID 或 Multixact 回卷保护阈值

这条路径比普通膨胀更紧急。诊断时要同时查看:

SELECT
    datname,
    age(datfrozenxid) AS xid_age,
    mxid_age(datminmxid) AS multixact_age
FROM pg_database;

以及关系级别:

SELECT
    c.oid::regclass AS relation,
    age(c.relfrozenxid) AS xid_age,
    age(c.relminmxid) AS multixact_age
FROM pg_class AS c
WHERE c.relkind IN ('r', 'm')
ORDER BY age(c.relfrozenxid) DESC;

处理这类问题时,优先解除阻塞并让 Vacuum 完成,而不是先做表重写。VACUUM FULL 可以改变物理空间布局,但它本身不应被当作冻结风险的替代品。


十三、把 MVCC、Vacuum、WAL 和存储布局连接起来

一次更新大致会同时影响几个层面:

SQL UPDATE
    ↓
创建新 heap tuple,旧 tuple 记录 xmax
    ↓
维护相关索引,或形成 HOT 链
    ↓
生成 WAL,保证崩溃恢复和复制
    ↓
页面可见性标志可能被清除
    ↓
Vacuum 后续清理旧版本、索引死项并推进冻结

因此,MVCC 不是独立于存储和 WAL 的查询层特性:

  • tuple 版本存在于 heap 页面;
  • 索引通常指向 heap tuple 的物理位置;
  • WAL 记录数据页变化以及相关持久化信息;
  • Vacuum 本身也会产生相应的 WAL 活动;
  • visibility map 影响 Index Only Scan;
  • HOT 更新改变 heap 版本链和索引维护路径;
  • 长事务会同时影响旧版本回收和系统整体存储增长。

这也是为什么“表数据量没有明显增长,但磁盘不断增长”在 PostgreSQL 中并不矛盾:逻辑数据量和物理版本数量是两个不同维度。


十四、一个可重复的实验:观察版本、快照和清理边界

以下实验适合在测试数据库中执行。

1. 初始化

DROP TABLE IF EXISTS mvcc_lab;

CREATE TABLE mvcc_lab (
    id integer PRIMARY KEY,
    value text
);

INSERT INTO mvcc_lab VALUES (1, 'v1');

2. Session A 持有快照

BEGIN ISOLATION LEVEL REPEATABLE READ;

SELECT ctid, xmin, xmax, *
FROM mvcc_lab
WHERE id = 1;

记录结果,例如:

ctid  | xmin | xmax | id | value
------+------|------|----|------
(0,1) | 200  | 0    | 1  | v1

3. Session B 连续更新

BEGIN;

UPDATE mvcc_lab SET value = 'v2' WHERE id = 1;
COMMIT;

SELECT ctid, xmin, xmax, *
FROM mvcc_lab
WHERE id = 1;

Session B 的普通查询通常只看到 v2

4. 在 Session B 或第三个会话执行 Vacuum

VACUUM (VERBOSE) mvcc_lab;

由于 Session A 仍持有旧快照,旧版本可能不能被清理。

5. Session A 再次读取

SELECT ctid, xmin, xmax, *
FROM mvcc_lab
WHERE id = 1;

仍可能看到 v1

6. 结束 Session A 后再次 Vacuum

COMMIT;
VACUUM (VERBOSE) mvcc_lab;

此时旧版本通常已经不再被该实验中的快照需要,Vacuum 可以回收其空间。是否立即表现为文件缩小,仍取决于页面位置和是否满足文件截断条件。

这个实验展示了三个关键事实:

  1. 新旧 tuple 可以同时存在;
  2. 不同快照可以看到不同版本;
  3. Vacuum 的清理边界由快照需求决定,而不是由业务语句是否已经提交决定。

十五、诊断时的最小检查顺序

面对“表变大、Vacuum 不工作或数据库事务年龄异常”,可以按以下顺序建立证据链:

第一步:确认业务变化规模

SELECT
    relname,
    n_tup_ins,
    n_tup_upd,
    n_tup_del,
    n_live_tup,
    n_dead_tup,
    last_autovacuum,
    last_autoanalyze
FROM pg_stat_user_tables
WHERE relname = 'target_table';

第二步:确认是否有长事务

SELECT
    pid,
    usename,
    state,
    xact_start,
    backend_xmin,
    now() - xact_start AS age,
    query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start;

第三步:确认复制槽和备库因素

SELECT
    slot_name,
    slot_type,
    active,
    restart_lsn,
    xmin,
    catalog_xmin
FROM pg_replication_slots;

同时检查复制状态和 Hot Standby feedback 相关配置。

第四步:确认冻结年龄

SELECT
    datname,
    age(datfrozenxid) AS xid_age,
    mxid_age(datminmxid) AS multixact_age
FROM pg_database;

第五步:区分 heap、索引和文件空间

SELECT
    pg_size_pretty(pg_table_size('public.target_table')) AS heap,
    pg_size_pretty(pg_indexes_size('public.target_table')) AS indexes,
    pg_size_pretty(pg_total_relation_size('public.target_table')) AS total;

必要时再使用 pgstattuplepg_visibility 等工具做更重的检查。

第六步:观察当前 Vacuum

SELECT *
FROM pg_stat_progress_vacuum;

最后才决定是:

  • 调整表级自动维护阈值;
  • 处理长事务;
  • 修复或清理失效复制槽;
  • 增加维护资源;
  • 执行普通 Vacuum;
  • 在有锁和空间窗口时执行关系重写。

PostgreSQL 的 MVCC 让读取者可以在不阻塞大多数写入的情况下看到一致的数据,但代价是旧 tuple 必须在物理上保留一段时间。Vacuum 不是简单的后台“垃圾回收器”:它同时受事务快照、索引结构、visibility map、复制机制和事务 ID 生命周期约束。

膨胀问题的核心通常不是“Vacuum 没有执行”,而是要进一步回答:

  • 哪些旧版本仍可能被谁看到?
  • Vacuum 是否已经到达这些页面?
  • 清理后的空间是可复用,还是必须重写才能归还?
  • 表和 Multixact 的冻结年龄是否安全?
  • 是否有长事务、复制槽或备库反馈阻止清理?

只有把这些问题分别验证,才能把 MVCC 的可见性、Vacuum 的回收、膨胀的形成和冻结的必要性连接成一条完整的因果链。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。