MVCC 概述(多版本并发控制)
「读不阻塞写,写不阻塞读」——这是 PostgreSQL(以及所有主流数据库)在并发场景下的核心承诺。实现这个承诺的机制就叫 MVCC(Multi-Version Concurrency Control,多版本并发控制)。它的思路是:更新一行时不覆盖旧值,而是再写一个新版本,让不同事务各自看到自己所处时刻的数据快照。本节先建立 MVCC 的整体心智模型,后续五节再逐层拆开它的实现细节。
前置知识
阅读本节前,建议先了解:Tablespace(表空间)
基础概念
从「锁」到「版本」的演化
最朴素的并发控制是加锁:读的时候加读锁,写的时候加写锁,读写互斥。这实现简单,但代价是读写完全无法并行:
基于锁的并发控制(两阶段锁 2PL)
事务 A:UPDATE users SET balance = 100 WHERE id = 1; ← 持写锁
事务 B:SELECT balance FROM users WHERE id = 1; ← 必须等 A 提交才能读
结果:纯读操作被写操作堵住,读多写多的场景下并发度极低MVCC 换了一条路:不阻止写,而是让读看到「更新之前」的版本:
MVCC
事务 A:UPDATE users SET balance = 100 WHERE id = 1; ← 写入新版本,旧版本保留
事务 B:SELECT balance FROM users WHERE id = 1; ← 直接读旧版本,不等待 ✔
结果:读与写在同一行上并行推进,彼此不阻塞这就是 MVCC 的全部价值主张。理解 MVCC 需要先统一以下术语:
| 术语 | 英文 | 说明 |
|---|---|---|
| 多版本并发控制 | MVCC | 通过保留多份行版本来实现读写不互斥 |
| 事务 ID | XID, Transaction ID | 32 位无符号整数,标识一个写事务 |
| 元组 | Tuple | 页内的一行数据,含 xmin/xmax 等系统列 |
| 行版本 | Row Version / Tuple Version | 一次 INSERT/UPDATE 产生的一个元组 |
| 快照 | Snapshot | 某一时刻「哪些事务已提交」的记录,用于判断可见性 |
| 死亡元组 | Dead Tuple | 对任何活跃或未来事务都不可见的旧版本 |
| 清理 | VACUUM | 回收死亡元组占用的空间 |
| 提交日志 | CLOG / pg_xact | 记录每个 XID 的最终状态(提交/回滚/进行中) |
| 回卷 | Wraparound | 32 位 XID 用尽后循环,可能导致数据「复活」,必须靠冻结规避 |
一个 UPDATE 到底发生了什么
这是 MVCC 最反直觉、也最关键的一点。假设 users 表里有一行 id = 1, balance = 50:
UPDATE users SET balance = 100 WHERE id = 1;初学者会以为「这一行被改成了 100」。实际情况是:
更新前(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 相关的现象:
· UPDATE 后表文件反而变大(旧版本还在)
· UPDATE 产生大量死元组,必须靠 VACUUM 清理
· 行版本靠 ctid 定位,UPDATE 后 ctid 会变
· 索引条目需要指向新 ctid,更新索引列才会改动索引
· 一行的历史版本可能很多,长事务会阻止它们被清理三个关键的「隐藏列」
MVCC 的记账信息就藏在每一行的元组头(Tuple Header)里。用户 SELECT * 看不到它们,但可以直接查询:
-- 查看 MVCC 的核心系统列
SELECT ctid, xmin, xmax, id, balance
FROM users
WHERE id = 1; 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),快照记录了「此刻哪些事务已经提交」:
快照的关键字段(由 GetSnapshotData 生成)
xmin :当前所有活跃事务中最小的 XID —— 小于它的都必然已结束
xmax :下一个将要分配的 XID —— 大于等于它的都还没开始
xip_list :此刻"正在运行"的事务 ID 列表
判断一个元组对当前事务是否可见的三条规则:
① 元组 xmin 在 xip_list 中(创建它的事务仍在运行)→ 不可见
② 元组 xmin >= xmax(创建它的事务在快照之后才开始)→ 不可见
③ 元组 xmax != 0 且 xmax 已提交(删除它的操作已生效)→ 不可见
否则 → 可见同一时刻不同事务看到的是不同数据,这不是 bug,而是 MVCC 的设计目标:
时间轴 →
事务 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 事务还在运行,永远判为不可见)。
默认隔离级别的行为差异值得强调:
-- 会话默认隔离级别
SHOW default_transaction_isolation; default_transaction_isolation
-------------------------------
read committed在「已提交读」下,同一个事务内的两条相同查询可能返回不同结果,因为每条语句重新取快照:
BEGIN;
SELECT balance FROM users WHERE id = 1; -- 返回 50
-- 此时另一个事务把它改成 100 并提交
SELECT balance FROM users WHERE id = 1; -- 返回 100(同一事务内结果变了!)
COMMIT;在「可重复读」下,第二条查询仍返回 50;但如果你在 A 事务里 UPDATE 一个已被 B 事务改过的行,会收到串行化错误:
ERROR: could not serialize access due to concurrent updateMVCC 的代价:死亡元组
读写不互斥不是白来的,代价就是磁盘上的旧版本会不断堆积:
时间 →
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 覆盖量化一下死元组的规模:
-- 观察表的活元组与死元组数量
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'; 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 就是这条水位线:
活跃事务中最老的快照 xmin = 500
· 所有在 500 之后产生的死元组,都可能被某个活跃事务看到 → 不能回收
· 只有 500 之前的死元组才安全
如果一个事务开了 8 小时不提交:
· 水位线卡在 8 小时前
· 这 8 小时里产生的所有死元组都无法回收
· 表文件与索引持续膨胀,VACUUM 无能为力-- 找出长事务与它拖住的水位线(这是排查膨胀的第一个命令)
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; 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_isolation | read committed | user | 默认隔离级别 |
default_transaction_read_only | off | user | 是否默认只读 |
old_snapshot_threshold | -1 | 重启 | 允许快照「变旧」的时间上限,超过后旧版本可被回收(注意副作用) |
idle_in_transaction_session_timeout | 0 | user | 空闲事务自动断开时间,防长事务的利器 |
statement_timeout | 0 | user | 单语句超时 |
vacuum_freeze_min_age | 50000000 | user | 冻结阈值,见后续 VACUUM 章节 |
autovacuum | on | sighup | 是否启用自动清理 |
track_commit_timestamp | off | 重启 | 是否记录每个 XID 的提交时间,用于 pg_xact_commit_timestamp() |
# postgresql.conf
# 默认隔离级别(一般不改,保持 read committed)
default_transaction_isolation = 'read committed'
# 防护:任何"开着事务不干活"的会话最多挂 5 分钟
idle_in_transaction_session_timeout = 5min
# 防止单条查询把 MVCC 水位线拖住太久
statement_timeout = 30s-- 会话级临时指定隔离级别(只影响当前事务)
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM users WHERE id = 1;
COMMIT;详细说明
MVCC 在磁盘上的完整链条
把前面分散的知识点串成一条链:
① 事务开始
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/:
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 必须靠"冻结"来消除-- 查看当前 XID 使用进度与回卷距离(这个查询要定期跑)
SELECT datname,
age(datfrozenxid) AS xid_age,
datfrozenxid
FROM pg_database
ORDER BY age(datfrozenxid) DESC
LIMIT 3; datname | xid_age | datfrozenxid
-----------+----------+--------------
demo | 184231002 | 412349801
postgres | 98213 | 654112301xid_age 接近 autovacuum_freeze_max_age(默认 2 亿)时,autovacuum 会强制启动冻结;接近 20 亿时实例会拒绝新的写事务。这属于 MVCC 最危险的运维陷阱,后续 VACUUM 章节会详细展开。
快照隔离 vs 串行化快照隔离(SSI)
PostgreSQL 的「可串行化」不是靠锁实现,而是SSI(Serializable Snapshot Isolation,串行化快照隔离):
SSI 的核心:检测"危险结构"
读写依赖(rw-dependency)链条构成的环 → 可能产生不可串行化的结果
PostgreSQL 维护 SIREAD 锁(谓词锁)来追踪读操作,发现环就中止其中一方
代价:需要额外内存记录读集合,且可能主动报错要求重试| 隔离级别 | 冲突处理方式 | 应用需要做什么 |
|---|---|---|
| 已提交读 | 无冲突检测,语句级快照 | 无需重试 |
| 可重复读 | 写冲突时直接报错 | 必须能重试整个事务 |
| 可串行化 | SSI 检测到危险结构时报错 | 必须能重试整个事务 |
-- 开启可串行化事务
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:
-- 建表并初始化一行
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; xmin | xmax | ctid | id | note
-------+------+-------+----+------
741 | 0 | (0,1) | 1 | v1会话 2:
-- 开始一个可重复读事务,固定快照
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT xmin, xmax, ctid, * FROM mvcc_demo; xmin | xmax | ctid | id | note
-------+------+-------+----+------
741 | 0 | (0,1) | 1 | v1会话 1:
-- 更新这一行(注意:旧版本的 xmax 会变成新事务的 XID)
UPDATE mvcc_demo SET note = 'v2' WHERE id = 1;
-- 现在表里有两个"版本",但只看得到新的那个
SELECT xmin, xmax, ctid, * FROM mvcc_demo; xmin | xmax | ctid | id | note
-------+------+-------+----+------
748 | 0 | (0,2) | 1 | v2会话 2:
-- 会话 2 的快照在更新之前,所以仍看到旧值
SELECT xmin, xmax, ctid, * FROM mvcc_demo; xmin | xmax | ctid | id | note
-------+------+-------+----+------
741 | 748 | (0,1) | 1 | v1 ← 仍是 v1,且 xmax 已是 748COMMIT;结果解读:
- 会话 2 在
REPEATABLE READ下,即使会话 1 已经提交了新值,它仍然看到v1——这是快照隔离的直接体现。 - 会话 2 看到的旧版本
xmax = 748,说明该版本已被 748 号事务标记失效。但因为它的事务还在运行,这个死版本不能回收。 - 两次查询得到的
ctid不同((0,1)vs(0,2)),直观证明了「一行更新 = 两个物理版本」。 - 只有在会话 2
COMMIT后,(0,1)才真正成为可回收的死元组。
实战示例
示例 1:量化 MVCC 造成的表膨胀并定位原因
这是生产环境最常见的 MVCC 相关问题。完整走一遍「发现问题 → 定位原因 → 处理」。
第一步:发现膨胀
-- 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'; 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第二步:确认物理空间浪费
-- 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; real_size | used_size | total_size
-----------+-----------+------------
2304 MB | 600 MB | 2736 MB第三步:定位为什么死元组清不掉
-- 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; 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;-- 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; slot_name | active | restart_lsn | retained_wal
-----------+--------+-------------+--------------
(0 rows)第四步:处理并验证
-- 5) 终止卡住的长事务(确认后执行)
SELECT pg_terminate_backend(33102);
-- 6) 手工执行一次 VACUUM(普通 VACUUM 不阻塞读写,只标记空间可复用)
VACUUM (VERBOSE, ANALYZE) users;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-- 7) 验证死元组已清空
SELECT relname, n_live_tup, n_dead_tup,
last_vacuum, last_autovacuum
FROM pg_stat_user_tables
WHERE relname = 'users'; 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说明此时的水位线正常,没有更老的快照卡住回收。- 想真正缩小文件:
-- 8) 只有 VACUUM FULL 或 pg_repack 才能真正缩小物理文件(慎用,需要排他锁)
-- VACUUM FULL users; -- 生产环境慎用:全表重写 + ACCESS EXCLUSIVE 锁 real_size(before) | real_size(after VACUUM) | real_size(after VACUUM FULL)
-------------------+-------------------------+------------------------------
2304 MB | 2304 MB | 600 MB示例 2:观察隔离级别对可见性的影响
-- 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:
-- 2) 会话 A:可重复读事务
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT id, balance FROM iso_demo WHERE id = 1; id | balance
----+---------
1 | 50会话 B:
-- 3) 会话 B:更新并提交
UPDATE iso_demo SET balance = 100 WHERE id = 1;
COMMIT;会话 A:
-- 4) 会话 A:再读一次(可重复读下结果不变)
SELECT id, balance FROM iso_demo WHERE id = 1; id | balance
----+---------
1 | 50 ← 仍是 50,快照隔离生效-- 5) 会话 A:此时尝试更新这一行会怎样?
UPDATE iso_demo SET balance = 200 WHERE id = 1;ERROR: could not serialize access due to concurrent update-- 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; id | balance
----+---------
1 | 100结果解读:
- 第 4 步证明了可重复读的语义:同一事务内多次读取结果稳定。
- 第 5 步的报错是 MVCC 的写冲突保护:两个事务同时修改同一行时,后到的那个必须失败,否则会产生不可串行化的结果。
- 应用必须捕获
40001(serialization_failure)并重试,这是使用可重复读/可串行化的强制要求。 - 若用默认的「已提交读」,第 5 步不会报错——PostgreSQL 会自动定位到最新版本并更新(
EvalPlanQual机制)。
示例 3:用 pageinspect 直接看两个版本共存
-- 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; 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...-- 4) 统计表的实际情况:活元组只有 1 个,却占了 3 个槽位
SELECT * FROM pgstattuple('ver_demo'); 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跑得多勤都没用。
-- 不推荐:开着事务去做耗时操作(如等外部 API、人工确认)
-- BEGIN;
-- SELECT * FROM orders WHERE id = 1; -- 之后挂在那里几小时
-- ...- 不要依赖
SELECT ... FOR UPDATE来"防并发",它是悲观锁,会引入等待与死锁风险;MVCC 场景下优先考虑乐观更新或ON CONFLICT。
-- 不推荐:用 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 与快照