主题
事务隔离与 MVCC:快照、可见性与膨胀
一句话:PostgreSQL 把每个行版本都放在堆表里,用
xmin/xmax和快照判断可见性;换来读不阻塞写,代价是 Vacuum 和表膨胀。
适用 PostgreSQL 16/17。隔离级别行为以官方文档为准;示例用两个独立连接观察。
为什么需要
并发事务会遇到四类问题:脏读、不可重复读、幻读、序列化异常。数据库用隔离级别和并发控制来限制它们。
PostgreSQL 的选择是 MVCC(多版本并发控制):读不阻塞写,写不阻塞读。它不在原地覆盖行,而是保留多个版本,让每个事务按自己的快照判断「哪一版可见」。这解决了读写冲突,但留下一个问题:旧版本不会自己消失,必须有 Vacuum 清理。
核心机制
隔离级别与默认值
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 序列化异常 |
|---|---|---|---|---|
| Read Uncommitted | 不会 | 可能 | 可能 | 可能 |
| Read Committed(默认) | 不会 | 可能 | 可能 | 可能 |
| Repeatable Read | 不会 | 不会 | 不会* | 可能 |
| Serializable | 不会 | 不会 | 不会 | 不会 |
PostgreSQL 不实现真正的 Read Uncommitted,它等同 Read Committed。注意默认值:PostgreSQL 默认 Read Committed,MySQL InnoDB 默认 Repeatable Read,迁移或对比时不要写反。
* PostgreSQL 的 RR 用事务级快照,重复执行同一查询不会看到新插入的行,所以标准定义的幻读不出现;但这不等于可串行化,写偏斜(write skew)仍可能发生,需要 Serializable。
行头与可见性
每个堆元组(行版本)头部有:
xmin:创建该版本的事务 ID。xmax:删除/锁定该版本的事务 ID;0 表示未删除,行锁也可能让它指向多事务 ID(MultiXact)。cid/ctid:事务内命令序号 / 物理位置(block, offset);ctid不是稳定的业务主键。t_infomask:记录提交状态、是否冻结等标志。
简化后的可见条件是「xmin 对当前快照可见,且 xmax=0 或 xmax 对当前快照不可见」。快照包含 xmin、xmax 和活跃事务列表 xip;一个事务能看到「在快照建立前已提交」且「不在活跃列表里」的版本。
版本链与索引指针(常见误区)
UPDATE 不是原地修改,而是插入一个新版本,并把旧版本的 xmax 标记为当前事务。旧版本留在堆表里,等待 Vacuum。
关键点:索引项指向具体的元组版本(ctid),不是永远指向最新版本。
- 普通 UPDATE:如果更新了索引列,PostgreSQL 会为新版本插入新的索引项;旧索引项仍指向旧版本。
- HOT UPDATE:如果更新的列没有被任何索引引用,且新版本能放在同一页,PostgreSQL 不新增索引项;旧元组的
t_ctid指向新版本,索引扫描沿 HOT 链找到新版本。 - 索引扫描拿到 ctid 后,仍要回堆检查可见性;不可见就沿版本链继续找。不是「索引永远指向最新版,再回溯」。
ctid会随 UPDATE 改变,VACUUM FULL/pg_repack也会改变物理位置。
Read Committed 与 Repeatable Read 的快照时机
| 维度 | Read Committed | Repeatable Read |
|---|---|---|
| 快照获取 | 每条语句开始时 | 事务第一条查询时 |
| 同一事务多次查询 | 可能看到不同结果 | 看到同一快照 |
| 并发更新同一行 | 等待后基于最新版本更新 | 可能报 could not serialize access |
| 幻读 | 可能 | 标准意义下不会 |
| 写偏斜 | 可能 | 可能 |
RR 下,如果目标行在你的事务开始后被其他事务修改并提交,UPDATE/DELETE 会收到 ERROR: could not serialize access due to concurrent update;应用必须捕获 40001 并重试整个事务,不能只重试这条 SQL。
Serializable 与 SSI
Serializable 建立在快照隔离之上,用 SSI(Serializable Snapshot Isolation)跟踪读写冲突:
- 用 SIREAD 谓词锁标记「这个事务读过这个范围」,它不阻塞写。
- 检测 rw-conflict(读-写)和 wr-conflict(写-读),在提交时检查依赖环。
- 发现环就中止一个事务,返回
40001。
代价是冲突率上升,应用要能重试。适合冲突少、需要强一致的业务,不适合高竞争写入。
Vacuum 与事务 ID 回卷
Vacuum 做三件事:回收死元组、清理索引中的死条目、冻结老事务 ID。autovacuum 还会顺带 ANALYZE 更新统计信息。
触发阈值是 autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor * n_live_tup,默认 50 + 0.2 * 行数(autovacuum_vacuum_scale_factor=0.2,PostgreSQL 16/17)。大表按比例触发太晚,常按表调小到 0.01。
事务 ID 是 32 位,约 40 亿个;可见性比较按 20 亿的安全余量设计。老元组必须被冻结(FrozenTransactionId=2),否则回卷后可见性会混乱,数据库会进入保护性只读。监控:
sql
SELECT datname, age(datfrozenxid) AS freeze_age
FROM pg_database
ORDER BY freeze_age DESC;autovacuum_freeze_max_age=200000000(2 亿)是强制 autovacuum 的阈值;vacuum_freeze_min_age=50000000、vacuum_freeze_table_age=150000000 控制冻结节奏(版本差异以官方文档为准)。
最小上手
用两个独立连接(psql 两个窗口):
sql
-- 会话 A:准备数据
DROP TABLE IF EXISTS account;
CREATE TABLE account (id int PRIMARY KEY, balance numeric NOT NULL);
INSERT INTO account VALUES (1, 1000);
-- 会话 A:RR 快照
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT * FROM account WHERE id = 1; -- 1000
-- 会话 B:另一个连接,先改并提交
UPDATE account SET balance = balance - 100 WHERE id = 1;
COMMIT;
-- 会话 A:仍是 1000;再更新会报序列化失败
SELECT * FROM account WHERE id = 1;
UPDATE account SET balance = balance + 1 WHERE id = 1; -- 40001
ROLLBACK;观察与清理:SELECT xmin, xmax, ctid, * FROM account WHERE id = 1;、SELECT pg_current_snapshot();、VACUUM (VERBOSE, ANALYZE) account;;死元组看 pg_stat_user_tables.n_dead_tup,冻结年龄看 age(datfrozenxid)。
生产动作
- 长事务是 Vacuum 的天敌:
pg_stat_activity里xact_start很早、state='idle in transaction'的会话会阻止死元组回收。设置idle_in_transaction_session_timeout(默认 0,建议 30–60 秒,按业务调整),并监控pg_prepared_xacts。 - 膨胀监控:
n_dead_tup / (n_live_tup + n_dead_tup)超过 20% 关注;大表按表设置autovacuum_vacuum_scale_factor=0.01,必要时pg_repack。VACUUM FULL拿ACCESS EXCLUSIVE,不要在高峰做。 - 回卷监控:
age(datfrozenxid)接近 2 亿就安排VACUUM FREEZE,不要等数据库进入只读保护。 - 重试逻辑:RR/Serializable 下捕获
40001(序列化失败)和40P01(死锁),整个事务重试;重试要有次数上限和退避。 - 隔离级别按需选:默认 RC 适合大多数 OLTP;需要事务内一致读用 RR;需要防写偏斜用 Serializable。不要全局改成 RR 来「更安全」,它会带来序列化失败和更多重试。
- 排障路径:现象「表只涨不降、DELETE 后空间不回收」→
pg_stat_user_tables看n_dead_tup和last_autovacuum→pg_stat_activity看长事务 → 先结束长事务,再调 autovacuum,最后才考虑pg_repack。
边界
- RR 不是 Serializable:它防标准幻读,但不防写偏斜;需要可串行化时用 Serializable 并接受重试。
- 没有间隙锁:PostgreSQL 不用 next-key lock 防幻读,靠快照;并发插入不会被间隙锁阻塞,但写冲突可能以序列化失败的形式出现。
- 快照不是「实时最新」:RR 下看到的是事务开始时的数据;需要最新值用 RC 或
READ COMMITTED的单语句。 - Vacuum 不是可选项:它同时负责空间回收和事务 ID 冻结;关闭 autovacuum 只是把故障推迟。
ctid不是主键:UPDATE、VACUUM FULL、pg_repack 都会改变物理位置;用它做游标要重新取。- 索引不是「永远指向最新版」:普通更新会新增索引项,HOT 更新才复用索引项并沿
t_ctid链找新版本;诊断时要看实际执行计划和行头,不要凭印象。
讲清楚
PostgreSQL 的 MVCC 是「堆内多版本」:UPDATE 插入新行版本,旧版本留在堆表里,行头的 xmin/xmax 记录创建和删除事务,快照决定哪一版可见。读不阻塞写,代价是旧版本要靠 Vacuum 回收,长事务会让表膨胀,事务 ID 还要定期冻结防回卷。RC 每条语句取新快照,RR 整个事务一个快照,所以 RR 下重复查询看不到新插入的行,标准幻读不出现;但 RR 不是 Serializable,写偏斜仍可能发生,并发更新同一行还可能直接报序列化失败,应用要重试。另一个常见误区是「索引永远指向最新版本」:普通更新会新增索引项,只有 HOT 更新才复用索引项并沿链找新版本。生产上最该盯的是长事务、死元组比例和 age(datfrozenxid),而不是把隔离级别一律调高。