Skip to content

MVCC 概述(多版本并发控制) ​

「读不阻塞写,写不阻塞读」——这是 PostgreSQL(以及所有主流数据库)在并发场景下的核心承诺。实现这个承诺的机制就叫 MVCC(Multi-Version Concurrency Control,多版本并发控制)。它的思路是:更新一行时不覆盖旧值,而是再写一个新版本,让不同事务各自看到自己所处时刻的数据快照。本节先建立 MVCC 的整体心智模型,后续五节再逐层拆开它的实现细节。

前置知识

阅读本节前,建议先了解:Tablespace(表空间)

基础概念 ​

从「锁」到「版本」的演化 ​

最朴素的并发控制是加锁:读的时候加读锁,写的时候加写锁,读写互斥。这实现简单,但代价是读写完全无法并行:

text
基于锁的并发控制(两阶段锁 2PL)
  事务 A:UPDATE users SET balance = 100 WHERE id = 1;   ← 持写锁
  事务 B:SELECT balance FROM users WHERE id = 1;         ← 必须等 A 提交才能读

结果:纯读操作被写操作堵住,读多写多的场景下并发度极低

MVCC 换了一条路:不阻止写,而是让读看到「更新之前」的版本:

text
MVCC
  事务 A:UPDATE users SET balance = 100 WHERE id = 1;   ← 写入新版本,旧版本保留
  事务 B:SELECT balance FROM users WHERE id = 1;         ← 直接读旧版本,不等待 ✔

结果:读与写在同一行上并行推进,彼此不阻塞

这就是 MVCC 的全部价值主张。理解 MVCC 需要先统一以下术语:

术语英文说明
多版本并发控制MVCC通过保留多份行版本来实现读写不互斥
事务 IDXID, Transaction ID32 位无符号整数,标识一个写事务
元组Tuple页内的一行数据,含 xmin/xmax 等系统列
行版本Row Version / Tuple Version一次 INSERT/UPDATE 产生的一个元组
快照Snapshot某一时刻「哪些事务已提交」的记录,用于判断可见性
死亡元组Dead Tuple对任何活跃或未来事务都不可见的旧版本
清理VACUUM回收死亡元组占用的空间
提交日志CLOG / pg_xact记录每个 XID 的最终状态(提交/回滚/进行中)
回卷Wraparound32 位 XID 用尽后循环,可能导致数据「复活」,必须靠冻结规避

一个 UPDATE 到底发生了什么 ​

这是 MVCC 最反直觉、也最关键的一点。假设 users 表里有一行 id = 1, balance = 50:

sql
UPDATE users SET balance = 100 WHERE id = 1;

初学者会以为「这一行被改成了 100」。实际情况是:

text
更新前(1 个元组)
  页内的元组:  [ xmin=100  xmax=0    balance=50  ]  ctid=(0,1)

执行 UPDATE 后(2 个元组并存)
  页内的元组:  [ xmin=100  xmax=205  balance=50  ]  ctid=(0,1)   ← 旧版本,被标记"已删"
                [ xmin=205  xmax=0    balance=100 ]  ctid=(0,2)   ← 新版本

   磁盘上的字节数:原来的 1 行变成了 2 行
  • 旧元组的 xmax 被写入 205(即执行更新的那个事务的 XID),表示「此版本从 205 号事务起不再有效」。
  • 新元组的 xmin 是 205,表示「此版本由 205 号事务创建」。
  • 旧版本的物理数据完全没被改写,只是加了一个标记。

「更新」其实是「插入新版本 + 标记旧版本失效」

这解释了后续所有 MVCC 相关的现象:

text
· UPDATE 后表文件反而变大(旧版本还在)
· UPDATE 产生大量死元组,必须靠 VACUUM 清理
· 行版本靠 ctid 定位,UPDATE 后 ctid 会变
· 索引条目需要指向新 ctid,更新索引列才会改动索引
· 一行的历史版本可能很多,长事务会阻止它们被清理

三个关键的「隐藏列」 ​

MVCC 的记账信息就藏在每一行的元组头(Tuple Header)里。用户 SELECT * 看不到它们,但可以直接查询:

sql
-- 查看 MVCC 的核心系统列
SELECT ctid, xmin, xmax, id, balance
FROM users
WHERE id = 1;
text
 ctid  | xmin | xmax | id | balance
-------+------+------+----+---------
 (0,2) |  205 |    0 |  1 |     100
系统列含义变化规律
ctid物理位置,格式 (页号, 槽位号)UPDATE 后指向新版本,会变化
xmin创建该版本的事务 ID新版本写入当前事务 XID
xmax删除/更新该版本的事务 ID,0 表示仍有效旧版本被写上更新事务的 XID
cmin / cmax同一事务内命令序号同一事务内多次修改同一行时用于区分
infomask标志位集合记录是否有 NULL、是否已冻结等

这些列不是「额外存储的元数据」,而是元组物理结构的一部分,总共约 23 字节的固定头部。

核心机制 ​

快照:可见性的判断依据 ​

每个事务在开始时会拿到一个快照(Snapshot),快照记录了「此刻哪些事务已经提交」:

text
快照的关键字段(由 GetSnapshotData 生成)
  xmin      :当前所有活跃事务中最小的 XID —— 小于它的都必然已结束
  xmax      :下一个将要分配的 XID —— 大于等于它的都还没开始
  xip_list  :此刻"正在运行"的事务 ID 列表

判断一个元组对当前事务是否可见的三条规则:
  ① 元组 xmin 在 xip_list 中(创建它的事务仍在运行)→ 不可见
  ② 元组 xmin >= xmax(创建它的事务在快照之后才开始)→ 不可见
  ③ 元组 xmax != 0 且 xmax 已提交(删除它的操作已生效)→ 不可见
  否则 → 可见

同一时刻不同事务看到的是不同数据,这不是 bug,而是 MVCC 的设计目标:

text
时间轴 →
事务 A(快照在 t1):  BEGIN ────── 查询 balance=50 ──────── COMMIT
事务 B:                        BEGIN ── UPDATE balance=100 ── COMMIT
                                ↑ t2

A 的快照在 t1,B 的提交在 t2 之后 → A 永远看到 50(可重复读)
B 自己看到 100

隔离级别与 MVCC ​

SQL 标准定义了四种隔离级别,PostgreSQL 用 MVCC + 少量锁 实现其中三种:

隔离级别PostgreSQL 实现方式脏读不可重复读幻读序列化异常
读未提交(Read Uncommitted)等同于已提交读✗可能可能可能
已提交读(Read Committed,默认)每个语句取一次新快照✗可能可能可能
可重复读(Repeatable Read)事务级快照,写冲突时报错✗✗✗可能
可串行化(Serializable)SSI 检测 + 可重复读✗✗✗✗

PostgreSQL 的「读未提交」实际就是「已提交读」——因为 MVCC 从物理上就不可能读到未提交的数据(未提交版本的 xmin 事务还在运行,永远判为不可见)。

默认隔离级别的行为差异值得强调:

sql
-- 会话默认隔离级别
SHOW default_transaction_isolation;
text
 default_transaction_isolation
-------------------------------
 read committed

在「已提交读」下,同一个事务内的两条相同查询可能返回不同结果,因为每条语句重新取快照:

sql
BEGIN;
SELECT balance FROM users WHERE id = 1;   -- 返回 50
-- 此时另一个事务把它改成 100 并提交
SELECT balance FROM users WHERE id = 1;   -- 返回 100(同一事务内结果变了!)
COMMIT;

在「可重复读」下,第二条查询仍返回 50;但如果你在 A 事务里 UPDATE 一个已被 B 事务改过的行,会收到串行化错误:

text
ERROR:  could not serialize access due to concurrent update

MVCC 的代价:死亡元组 ​

读写不互斥不是白来的,代价就是磁盘上的旧版本会不断堆积:

text
时间 →
INSERT id=1   → 版本 V1
UPDATE id=1   → 版本 V2(V1 被标记失效)
UPDATE id=1   → 版本 V3(V2 被标记失效)
UPDATE id=1   → 版本 V4(V3 被标记失效)
...
100 次 UPDATE 后:磁盘上有 100 个元组,其中 99 个是死元组

这些死元组:
  · 没有任何活跃事务需要它们(一旦没有旧快照)→ 是垃圾
  · 仍占用页空间 → 表文件持续膨胀
  · 必须由 VACUUM 标记为可复用,由后续 INSERT 覆盖

量化一下死元组的规模:

sql
-- 观察表的活元组与死元组数量
SELECT relname,
       n_live_tup,
       n_dead_tup,
       round(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS dead_ratio_pct,
       last_vacuum,
       last_autovacuum
FROM pg_stat_user_tables
WHERE relname = 'users';
text
 relname | n_live_tup | n_dead_tup | dead_ratio_pct | last_vacuum | last_autovacuum
---------+------------+------------+----------------+-------------+-----------------
 users   |      10000 |       8421 |          45.71 |             | 2026-09-20 11:32

死元组占比 45.71% 意味着表里近一半的空间是垃圾。这就是 MVCC 的日常账单。

长事务为什么是 MVCC 的天敌 ​

死元组能否回收,取决于「还有没有事务需要看到它」。快照的 xmin 就是这条水位线:

text
活跃事务中最老的快照 xmin = 500
  · 所有在 500 之后产生的死元组,都可能被某个活跃事务看到 → 不能回收
  · 只有 500 之前的死元组才安全

如果一个事务开了 8 小时不提交:
  · 水位线卡在 8 小时前
  · 这 8 小时里产生的所有死元组都无法回收
  · 表文件与索引持续膨胀,VACUUM 无能为力
sql
-- 找出长事务与它拖住的水位线(这是排查膨胀的第一个命令)
SELECT pid,
       now() - xact_start AS xact_age,
       state,
       left(query, 60) AS query_snippet
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start
LIMIT 5;
text
  pid  |   xact_age    |        state        |              query_snippet
-------+---------------+---------------------+------------------------------------------
 21043 | 02:14:38.721  | idle in transaction | SELECT * FROM reports;
 21877 | 00:00:03.112  | active              | UPDATE users SET balance = 100 WHERE id = 1;

idle in transaction 状态挂了 2 小时,正是典型的膨胀元凶。

相关参数 ​

参数默认值生效方式说明
default_transaction_isolationread committeduser默认隔离级别
default_transaction_read_onlyoffuser是否默认只读
old_snapshot_threshold-1重启允许快照「变旧」的时间上限,超过后旧版本可被回收(注意副作用)
idle_in_transaction_session_timeout0user空闲事务自动断开时间,防长事务的利器
statement_timeout0user单语句超时
vacuum_freeze_min_age50000000user冻结阈值,见后续 VACUUM 章节
autovacuumonsighup是否启用自动清理
track_commit_timestampoff重启是否记录每个 XID 的提交时间,用于 pg_xact_commit_timestamp()
ini
# postgresql.conf
# 默认隔离级别(一般不改,保持 read committed)
default_transaction_isolation = 'read committed'

# 防护:任何"开着事务不干活"的会话最多挂 5 分钟
idle_in_transaction_session_timeout = 5min

# 防止单条查询把 MVCC 水位线拖住太久
statement_timeout = 30s
sql
-- 会话级临时指定隔离级别(只影响当前事务)
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM users WHERE id = 1;
COMMIT;

详细说明 ​

MVCC 在磁盘上的完整链条 ​

把前面分散的知识点串成一条链:

text
① 事务开始
      BEGIN → 分配 XID(第一次写操作时才分配,只读事务不分配)
      ↓
② 取快照
      GetSnapshotData() → 得到 xmin / xmax / xip_list
      ↓
③ 执行写操作
      INSERT:写入新元组,xmin = 当前 XID
      UPDATE:写入新元组(xmin=当前 XID),旧元组 xmax = 当前 XID
      DELETE:旧元组 xmax = 当前 XID
      ↓
④ 提交 / 回滚
      把当前 XID 的最终状态写入 CLOG(pg_xact)
      提交 → COMMITTED;回滚 → ABORTED
      ↓
⑤ 其他事务判断可见性
      读元组 → 看 xmin/xmax → 结合快照与 CLOG → 决定是否可见
      ↓
⑥ 死元组积累
      没有任何快照需要它的元组 → 死元组
      ↓
⑦ VACUUM 回收
      把死元组占用的空间标记为可复用,并更新 FSM
      ↓
⑧ 空间真正被复用
      后续 INSERT 通过 FSM 找到这些页并覆盖使用

理解这条链,就理解了 MVCC 的全部:任何一步被卡住(长事务、CLOG 膨胀、VACUUM 不跑、FSM 未更新),都会在最终表现上体现为「表膨胀」。

CLOG:事务状态的持久记录 ​

快照只告诉你「谁在运行」,但判断一个事务最终是提交还是回滚,需要另一份记录——CLOG(Commit Log),物理上就是 PGDATA/pg_xact/:

text
pg_xact/0000         ← 每字节存 4 个事务的状态(每事务 2 bit)
状态编码:
  00 = IN_PROGRESS(进行中)
  01 = COMMITTED (已提交)
  10 = ABORTED   (已回滚)
  11 = SUB_COMMITTED(子事务,由父事务决定)

pg_xact 消耗速率极快:一个实例只要在写,就一直在消耗
  · 32 位 XID 空间约 42 亿
  · 高频写入(如 1000 事务/秒)下,约 50 天用完一轮
  · 所以 CLOG 目录只能记录"最近"的状态,老 XID 必须靠"冻结"来消除
sql
-- 查看当前 XID 使用进度与回卷距离(这个查询要定期跑)
SELECT datname,
       age(datfrozenxid) AS xid_age,
       datfrozenxid
FROM pg_database
ORDER BY age(datfrozenxid) DESC
LIMIT 3;
text
  datname  | xid_age  | datfrozenxid
-----------+----------+--------------
 demo      | 184231002 |       412349801
 postgres  |    98213 |       654112301

xid_age 接近 autovacuum_freeze_max_age(默认 2 亿)时,autovacuum 会强制启动冻结;接近 20 亿时实例会拒绝新的写事务。这属于 MVCC 最危险的运维陷阱,后续 VACUUM 章节会详细展开。

快照隔离 vs 串行化快照隔离(SSI) ​

PostgreSQL 的「可串行化」不是靠锁实现,而是SSI(Serializable Snapshot Isolation,串行化快照隔离):

text
SSI 的核心:检测"危险结构"
  读写依赖(rw-dependency)链条构成的环 → 可能产生不可串行化的结果
  PostgreSQL 维护 SIREAD 锁(谓词锁)来追踪读操作,发现环就中止其中一方

代价:需要额外内存记录读集合,且可能主动报错要求重试
隔离级别冲突处理方式应用需要做什么
已提交读无冲突检测,语句级快照无需重试
可重复读写冲突时直接报错必须能重试整个事务
可串行化SSI 检测到危险结构时报错必须能重试整个事务
sql
-- 开启可串行化事务
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT sum(amount) FROM orders WHERE user_id = 1;
INSERT INTO audit_log VALUES (1, now());
COMMIT;

应用层必须写「重试循环」,否则会因 40P01 / 40001 报错直接失败。

观察 MVCC 行为的实验 ​

不借助任何扩展,用两个会话就能亲手看到 MVCC 的工作过程:

会话 1:

sql
-- 建表并初始化一行
DROP TABLE IF EXISTS mvcc_demo;
CREATE TABLE mvcc_demo (id int PRIMARY KEY, note text);
INSERT INTO mvcc_demo VALUES (1, 'v1');

-- 查看初始状态
SELECT xmin, xmax, ctid, * FROM mvcc_demo;
text
 xmin  | xmax | ctid  | id | note
-------+------+-------+----+------
   741 |    0 | (0,1) |  1 | v1

会话 2:

sql
-- 开始一个可重复读事务,固定快照
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT xmin, xmax, ctid, * FROM mvcc_demo;
text
 xmin  | xmax | ctid  | id | note
-------+------+-------+----+------
   741 |    0 | (0,1) |  1 | v1

会话 1:

sql
-- 更新这一行(注意:旧版本的 xmax 会变成新事务的 XID)
UPDATE mvcc_demo SET note = 'v2' WHERE id = 1;

-- 现在表里有两个"版本",但只看得到新的那个
SELECT xmin, xmax, ctid, * FROM mvcc_demo;
text
 xmin  | xmax | ctid  | id | note
-------+------+-------+----+------
   748 |    0 | (0,2) |  1 | v2

会话 2:

sql
-- 会话 2 的快照在更新之前,所以仍看到旧值
SELECT xmin, xmax, ctid, * FROM mvcc_demo;
text
 xmin  | xmax | ctid  | id | note
-------+------+-------+----+------
   741 |  748 | (0,1) |  1 | v1      ← 仍是 v1,且 xmax 已是 748
sql
COMMIT;

结果解读:

  • 会话 2 在 REPEATABLE READ 下,即使会话 1 已经提交了新值,它仍然看到 v1——这是快照隔离的直接体现。
  • 会话 2 看到的旧版本 xmax = 748,说明该版本已被 748 号事务标记失效。但因为它的事务还在运行,这个死版本不能回收。
  • 两次查询得到的 ctid 不同((0,1) vs (0,2)),直观证明了「一行更新 = 两个物理版本」。
  • 只有在会话 2 COMMIT 后,(0,1) 才真正成为可回收的死元组。

实战示例 ​

示例 1:量化 MVCC 造成的表膨胀并定位原因 ​

这是生产环境最常见的 MVCC 相关问题。完整走一遍「发现问题 → 定位原因 → 处理」。

第一步:发现膨胀

sql
-- 1) 查看表的活/死元组比例与最后清理时间
SELECT relname,
       n_live_tup,
       n_dead_tup,
       round(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS dead_pct,
       last_vacuum,
       last_autovacuum,
       n_mod_since_analyze
FROM pg_stat_user_tables
WHERE relname = 'users';
text
 relname | n_live_tup | n_dead_tup | dead_pct | last_vacuum |  last_autovacuum  | n_mod_since_analyze
---------+------------+------------+----------+-------------+-------------------+---------------------
 users   |     100000 |     284119 |    73.97 |             | 2026-09-20 08:15  |               15204

第二步:确认物理空间浪费

sql
-- 2) 对比"表实际大小"与"去掉死元组后应有的大小"
SELECT pg_size_pretty(pg_relation_size('users'))                               AS real_size,
       pg_size_pretty(pg_relation_size('users')
                      - (SELECT pg_relation_size('users') * n_dead_tup
                                / NULLIF(n_live_tup + n_dead_tup, 0)
                         FROM pg_stat_user_tables WHERE relname = 'users')::bigint)
                                                                                AS used_size,
       pg_size_pretty(pg_total_relation_size('users'))                          AS total_size;
text
 real_size | used_size | total_size
-----------+-----------+------------
 2304 MB   | 600 MB    | 2736 MB

第三步:定位为什么死元组清不掉

sql
-- 3) 检查是否存在长事务(MVCC 水位线被拖住的头号原因)
SELECT pid, usename, state,
       now() - xact_start AS xact_age,
       now() - state_change AS idle_age,
       left(query, 50) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
  AND now() - xact_start > interval '5 minutes'
ORDER BY xact_start;
text
  pid  | usename  |        state        |   xact_age   | idle_age |       query
-------+----------+---------------------+--------------+----------+--------------------
 33102 | app_user | idle in transaction | 03:42:11.902 | 03:41:58 | SELECT * FROM users WHERE id = 1;
sql
-- 4) 检查是否存在未推进的复制槽(另一个常见元凶)
SELECT slot_name, active, restart_lsn,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal
FROM pg_replication_slots
WHERE NOT active;
text
 slot_name | active | restart_lsn | retained_wal
-----------+--------+-------------+--------------
 (0 rows)

第四步:处理并验证

sql
-- 5) 终止卡住的长事务(确认后执行)
SELECT pg_terminate_backend(33102);

-- 6) 手工执行一次 VACUUM(普通 VACUUM 不阻塞读写,只标记空间可复用)
VACUUM (VERBOSE, ANALYZE) users;
text
INFO:  vacuuming "public.users"
INFO:  scanned index "users_pkey" to remove 284119 row versions
DETAIL:  CPU: user: 0.94 s, system: 0.12 s, elapsed: 2.31 s
INFO:  "users": removed 284119 row versions in 294912 pages
DETAIL:  CPU: user: 1.82 s, system: 0.31 s, elapsed: 4.05 s
INFO:  "users": found 284119 removable, 100000 nonremovable row versions in 294912 out of 294912 pages
DETAIL:  0 dead row versions cannot be removed yet, oldest xmin: 412998321
INFO:  analyzing "public.users"
INFO:  "users": scanned 30000 of 294912 pages, containing 100000 live rows and 0 dead rows; 30000 rows in sample
sql
-- 7) 验证死元组已清空
SELECT relname, n_live_tup, n_dead_tup,
       last_vacuum, last_autovacuum
FROM pg_stat_user_tables
WHERE relname = 'users';
text
 relname | n_live_tup | n_dead_tup |         last_vacuum         |  last_autovacuum
---------+------------+------------+-----------------------------+-------------------
 users   |     100000 |          0 | 2026-09-20 13:05:41.201+08  | 2026-09-20 08:15

结果解读:

  • n_dead_tup 从 284119 降为 0,removed 284119 row versions 印证了回收量。
  • 但 pg_relation_size 不会立刻变小!普通 VACUUM 只是把空间标记为「可复用」并更新 FSM,文件本身不收缩。只有 VACUUM FULL 才会真正缩小文件(代价是排他锁 + 全表重写)。
  • oldest xmin: 412998321 说明此时的水位线正常,没有更老的快照卡住回收。
  • 想真正缩小文件:
sql
-- 8) 只有 VACUUM FULL 或 pg_repack 才能真正缩小物理文件(慎用,需要排他锁)
-- VACUUM FULL users;   -- 生产环境慎用:全表重写 + ACCESS EXCLUSIVE 锁
text
 real_size(before) | real_size(after VACUUM) | real_size(after VACUUM FULL)
-------------------+-------------------------+------------------------------
 2304 MB           | 2304 MB                 | 600 MB

示例 2:观察隔离级别对可见性的影响 ​

sql
-- 1) 准备数据
DROP TABLE IF EXISTS iso_demo;
CREATE TABLE iso_demo (id int PRIMARY KEY, balance numeric);
INSERT INTO iso_demo VALUES (1, 50);

会话 A:

sql
-- 2) 会话 A:可重复读事务
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT id, balance FROM iso_demo WHERE id = 1;
text
 id | balance
----+---------
  1 |      50

会话 B:

sql
-- 3) 会话 B:更新并提交
UPDATE iso_demo SET balance = 100 WHERE id = 1;
COMMIT;

会话 A:

sql
-- 4) 会话 A:再读一次(可重复读下结果不变)
SELECT id, balance FROM iso_demo WHERE id = 1;
text
 id | balance
----+---------
  1 |      50      ← 仍是 50,快照隔离生效
sql
-- 5) 会话 A:此时尝试更新这一行会怎样?
UPDATE iso_demo SET balance = 200 WHERE id = 1;
text
ERROR:  could not serialize access due to concurrent update
sql
-- 6) 会话 A 必须回滚并重试
ROLLBACK;

-- 7) 重试(此时能看到 B 的结果)
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT id, balance FROM iso_demo WHERE id = 1;
UPDATE iso_demo SET balance = 200 WHERE id = 1;
COMMIT;
text
 id | balance
----+---------
  1 |     100

结果解读:

  • 第 4 步证明了可重复读的语义:同一事务内多次读取结果稳定。
  • 第 5 步的报错是 MVCC 的写冲突保护:两个事务同时修改同一行时,后到的那个必须失败,否则会产生不可串行化的结果。
  • 应用必须捕获 40001(serialization_failure)并重试,这是使用可重复读/可串行化的强制要求。
  • 若用默认的「已提交读」,第 5 步不会报错——PostgreSQL 会自动定位到最新版本并更新(EvalPlanQual 机制)。

示例 3:用 pageinspect 直接看两个版本共存 ​

sql
-- 1) 安装扩展(需要超级用户)
CREATE EXTENSION IF NOT EXISTS pageinspect;
CREATE EXTENSION IF NOT EXISTS pgstattuple;

-- 2) 造出"一行多版本"的局面
DROP TABLE IF EXISTS ver_demo;
CREATE TABLE ver_demo (id int, note text);
INSERT INTO ver_demo VALUES (1, 'v1');
UPDATE ver_demo SET note = 'v2' WHERE id = 1;
UPDATE ver_demo SET note = 'v3' WHERE id = 1;

-- 3) 直接翻阅第 0 页的所有元组(含死元组)
SELECT lp, lp_off, lp_flags,
       t_xmin, t_xmax,
       t_infomask2,
       encode(t_data, 'escape') AS raw_data
FROM heap_page_items(get_raw_page('ver_demo', 0))
ORDER BY lp;
text
 lp | lp_off | lp_flags | t_xmin | t_xmax | t_infomask2 |      raw_data
----+--------+----------+--------+--------+-------------+---------------------
  1 |   8152 |        1 |    751 |    752 |           2 | \x01000000...
  2 |   8120 |        1 |    752 |    753 |           2 | \x01000000...
  3 |   8088 |        1 |    753 |      0 |           2 | \x01000000...
sql
-- 4) 统计表的实际情况:活元组只有 1 个,却占了 3 个槽位
SELECT * FROM pgstattuple('ver_demo');
text
 table_len | tuple_count | tuple_len | tuple_percent | dead_tuple_count | dead_tuple_len | dead_tuple_percent | free_space
-----------+-------------+-----------+---------------+------------------+----------------+--------------------+------------
      8192 |           1 |        36 |          0.44 |                2 |             72 |               0.88 |       8084

结果解读:

  • heap_page_items 返回了 3 个元组:t_xmax 依次是 752、753、0,说明前两个版本已被后续事务标记失效,第三个是当前有效版本。
  • pgstattuple 给出结论:tuple_count = 1(活元组 1 个)、dead_tuple_count = 2(死元组 2 个)。两次 UPDATE 让页里多了一份垃圾。
  • dead_tuple_percent = 0.88% 在单行情况下看起来微不足道,但按「每行 U 次更新就产生 U-1 个死版本」推算,高频更新表很容易达到几十个百分点。
  • 执行 VACUUM ver_demo 后再跑一次 pgstattuple,dead_tuple_count 会归零,free_space 会增加——这就是「空间被标记为可复用」的可视化证据。

注意事项 ​

  • UPDATE 的物理代价是「插入 + 标记」,不是「原地改写」。一次 UPDATE 100 万行 会写入 100 万个新版本,并让旧版本全部变成死元组。
  • 长事务会阻止死元组回收,无论 VACUUM 跑得多勤都没用。
sql
-- 不推荐:开着事务去做耗时操作(如等外部 API、人工确认)
-- BEGIN;
-- SELECT * FROM orders WHERE id = 1;   -- 之后挂在那里几小时
-- ...
  • 不要依赖 SELECT ... FOR UPDATE 来"防并发",它是悲观锁,会引入等待与死锁风险;MVCC 场景下优先考虑乐观更新或 ON CONFLICT。
sql
-- 不推荐:用 FOR UPDATE 手写"先查后改"的并发控制
-- BEGIN;
-- SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;   -- 持锁到事务结束
-- UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- COMMIT;

-- 推荐:用原子更新,把并发交给数据库
UPDATE accounts SET balance = balance - 100 WHERE id = 1 AND balance >= 100;
  • ctid 是不稳定的,UPDATE 之后同一行的 ctid 会变;不要把它当作持久主键保存下来。
  • VACUUM FULL 与 pg_repack 都会重写表,前者持 ACCESS EXCLUSIVE 锁,后者需要额外磁盘空间;不要在小窗口里对超大表盲用。
  • 32 位 XID 会回卷,age(datfrozenxid) 接近 2 亿时必须让 autovacuum 完成冻结,不要长期禁用 autovacuum。
  • old_snapshot_threshold 不是解决膨胀的银弹,开启后旧快照可能读到「已被回收的数据」而报 snapshot too old,多数 OLTP 场景不该开。
  • 只读事务不分配 XID,因此大量纯读查询不会推动 XID 消耗;但如果用了 FOR UPDATE 或 SELECT ... FOR SHARE,就会分配。

最佳实践 ​

  • 把「死元组比例」和「最老事务年龄」作为一级监控指标,前者看膨胀,后者看膨胀的根因。
  • 给应用连接加 idle_in_transaction_session_timeout(如 5~10 分钟),自动清理忘记提交的事务。
  • 永远不要在生产库上长期关闭 autovacuum;需要时只对单表调整参数。
  • 高频更新的表单独设置更激进的 autovacuum 参数(提高 autovacuum_vacuum_scale_factor 灵敏度)。
  • 批量更新后主动执行 VACUUM,不要等 autovacuum 慢慢追。
  • 用 pg_stat_statements 找出「单次影响巨量行」的 UPDATE,它们通常是 MVCC 膨胀的最大来源。
  • 优先把随机的 UPDATE 改造成自然的 INSERT + 分区(如状态流转表改为追加日志),从源头减少死元组。
  • 可重复读/可串行化事务必须实现重试逻辑,否则会因序列化冲突让业务失败率飙升。
  • 需要回收磁盘空间时优先 pg_repack,它能在不停机的前提下做到 VACUUM FULL 的效果。
  • 理解 MVCC 的取舍:用「磁盘空间 + VACUUM 成本」换「读写不互斥」。这个交换在绝大多数 OLTP 场景下是值得的,但要为它准备好监控与维护例程。

下一节

继续学习:事务 ID 与快照

参考链接 ​