PostgreSQL 事务与并发系列 · 第二期
深入 MVCC:可见性判断与事务快照揭秘
第一期我们建立了 ACID 和 MVCC 的基本概念。本期将掀开 MVCC 的引擎盖,剖析元组头部的 xmin/xmax、事务快照的内存结构,并通过实战案例看清“读已提交”与“可重复读”的本质差异。
一、回顾:MVCC 的核心思想
PostgreSQL 中,每一行数据(称为元组 tuple)在被修改时不会原地更新,而是插入一个新的行版本,旧的版本仍然留在数据页中。这些版本通过元组头部的两个事务 ID 字段来标记生命周期:
- xmin – 插入该行版本的事务 ID
- xmax – 删除或更新该行版本的事务 ID(0 表示未删除)
当事务开始时,系统会为其生成一个快照(Snapshot),里面记录了当前“哪些事务正在运行、哪些事务已经提交”等信息。随后,对于每一个可见性判断,PostgreSQL 会根据快照中的规则决定是否显示该行版本。
二、元组头中的秘密:xmin 与 xmax
查看一个普通表的元组头部信息,可以使用 pageinspect 扩展(需要超级用户权限):
CREATE EXTENSION pageinspect;
-- 创建一个测试表
CREATE TABLE test_mvcc (id int PRIMARY KEY, name text);
INSERT INTO test_mvcc VALUES (1, 'Alice'), (2, 'Bob');
-- 查看数据页的元组信息
SELECT lp, t_xmin, t_xmax, t_ctid FROM heap_page_items(get_raw_page('test_mvcc', 0));
输出示例:
| lp | t_xmin | t_xmax | t_ctid |
|---|---|---|---|
| 1 | 730 | 0 | (0,1) |
| 2 | 730 | 0 | (0,2) |
这里 t_xmin=730 表示插入这两行的事务 ID 是 730,t_xmax=0 表示当前版本未被删除。
更新时的版本变化
-- 开启一个新事务(假设事务 ID 为 735)
BEGIN;
UPDATE test_mvcc SET name = 'Alice2' WHERE id = 1;
此时数据页内部会变成:
| lp | t_xmin | t_xmax | t_ctid |
|---|---|---|---|
| 1 | 730 | 735 | (0,3) |
| 2 | 730 | 0 | (0,2) |
| 3 | 735 | 0 | (0,3) |
- 旧版本(lp=1)的 t_xmax 被设置为 735,表示它已被事务 735 删除。
- 新版本(lp=3)的 t_xmin = 735,表示它由事务 735 创建。
- t_ctid 指向新版本的位置
(0,3),形成一条版本链。
当事务 735 提交后,旧版本对其他事务可能不可见,具体取决于隔离级别和快照。
三、事务快照(Snapshot)的内存布局
PostgreSQL 中,快照是一个结构体(SnapshotData),其核心成员包括:
xmin– 当前所有未提交事务中最小的 IDxmax– 下一个将要被分配的事务 ID(即大于等于此值的所有事务,肯定未开始)xip– 一个数组,存储所有活跃(未提交)的事务 IDsnapshot_type– 区分SNAPSHOT_MVCC(普通快照)、SNAPSHOT_SELF(包含当前事务)等
快照是调用 GetSnapshotData() 函数生成的。简单流程:
- 记录当前系统活跃事务列表(从
ProcArray中读取) - 计算出最小的活跃事务 ID 作为
xmin - 用
nextXid(下一个未分配的事务 ID)作为xmax - 将活跃事务 ID 列表复制到
xip数组
一个典型的快照内容(人为示例):
xmin = 100
xmax = 105
xip = [101, 103] (事务 102 已经提交,104 尚未开始)
四、可见性判断的核心规则
给定一个行版本,其 xmin 和 xmax,以及当前事务的快照,判断是否可见的简化逻辑如下:
对 xmin 的判断(该版本是否对当前事务可见?)
- 如果 xmin 是 已中止(aborted) 的事务 → 不可见(直接忽略)
- 如果 xmin 是 当前事务自己 且在事务未提交时 → 可见(自己的修改对自己可见)
- 如果 xmin 是 已提交 的事务:
- 如果 xmin < 快照的 xmin → 可见
- 如果 xmin >= 快照的 xmax → 不可见(该事务在快照生成之后才开始)
- 如果 xmin 在快照的 xip 列表中(即活跃事务) → 不可见
- 否则 → 可见
对 xmax 的判断(该版本是否已被删除/更新?)
- 如果 xmax = 0 → 未被删除,通过 xmin 检查后即可见
- 如果 xmax ≠ 0:
- 如果 xmax 是已中止的事务 → 视为未删除
- 如果 xmax 是当前事务自己 → 视为删除(如果当前事务删除了它,就不可见)
- 如果 xmax 是已提交的事务且 xmax 在当前快照中可见 → 视为已删除 → 该版本不可见
核心结论:一个行版本可见的充要条件是
(xmin 有效且提交且不活跃) 并且 (xmax = 0 或 xmax 无效/未提交)。
注意:上述规则是针对 普通 MVCC 快照(SNAPSHOT_MVCC)的,也是READ COMMITTED和REPEATABLE READ隔离级别的基础。
五、读已提交 vs 可重复读:快照生成的差异
虽然两者都使用 MVCC 快照判断可见性,但快照的获取时机完全不同:
| 隔离级别 | 快照生成策略 | 效果 |
|---|---|---|
| 读已提交 | 事务中的每条 SQL 语句开始时,都会重新生成一个全新的快照 | 每条语句都能看到在它开始之前已经提交的所有数据 |
| 可重复读 | 事务中的第一条 SQL 语句开始时生成一个快照,整个事务期间复用该快照 | 事务中所有语句看到的是同一时刻的一致数据快照 |
实战案例:不可重复读现象
首先,创建一个表并插入初始数据:
CREATE TABLE accounts (id INT PRIMARY KEY, balance INT);
INSERT INTO accounts VALUES (1, 100);
场景演示(读已提交)
| 时间 | 事务 A(读已提交) | 事务 B(读已提交) |
|---|---|---|
| T1 | BEGIN; | BEGIN; |
| T2 | SELECT balance FROM accounts WHERE id=1; → 100 | |
| T3 | UPDATE accounts SET balance=200 WHERE id=1; COMMIT; | |
| T4 | SELECT balance FROM accounts WHERE id=1; → 200 | |
| T5 | COMMIT; |
事务 A 在同一次事务内的两次查询结果不同(100 → 200),这就是“不可重复读”。
场景演示(可重复读)
| 时间 | 事务 A(可重复读) | 事务 B(可重复读) |
|---|---|---|
| T1 | BEGIN; | BEGIN; |
| T2 | SELECT balance FROM accounts WHERE id=1; → 100 | |
| T3 | UPDATE accounts SET balance=200 WHERE id=1; COMMIT; | |
| T4 | SELECT balance FROM accounts WHERE id=1; → 仍为 100 | |
| T5 | COMMIT; |
事务 A 在整个事务期间看到的是同一个快照(T2 时刻的快照),因此第二次查询仍然返回 100,实现了“可重复读”。
六、实践:使用 pg_snapshot_xip 观察快照
PostgreSQL 提供 pg_current_snapshot() 函数,返回当前会话的快照文本表示,格式为 xmin:xmax:xip_list。
-- 开启一个可重复读事务
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT pg_current_snapshot();
-- 输出示例: 720:724:720,722
-- 再开另一个会话,执行一些更新并提交
-- 回到原会话,再次获取快照
SELECT pg_current_snapshot();
-- 输出相同:720:724:720,722
-- 在读已提交事务中,每次查询前快照会变化
BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT pg_current_snapshot();
-- 第一次
SELECT pg_current_snapshot();
-- 第二次(可能在另一个事务提交后)会看到不同内容
COMMIT;
通过这种方式可以直观感受快照的生命周期差异。
七、长事务与快照膨胀的风险
当一个事务以 可重复读 或 可串行化 隔离级别运行很长时间时,它会一直持有一个旧的快照。这会导致:
- 表膨胀:所有在快照生成之后被更新/删除的旧版本,都不能被
VACUUM清理,因为旧快照仍然需要看见它们。 - 事务 ID 回卷风险:长时间持有的快照可能会阻止
xid冻结,导致数据库强制关闭。
监控长事务:
SELECT pid, age(backend_xid) AS xid_age,
backend_xmin, state, query_start
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
ORDER BY age(backend_xid) DESC;
若发现长事务,应及时评估是否可以终止(pg_terminate_backend(pid))或让其结束。
八、总结与下期预告
本期我们完成了对 MVCC 机制的深度探索:
- 元组头部
xmin/xmax如何刻画行版本的生命周期 - 事务快照的内存结构与生成原理
- 可见性判断的核心逻辑(结合快照判断)
READ COMMITTED与REPEATABLE READ的本质差异(快照获取时机不同)- 长事务带来的膨胀与事务 ID 回卷风险
这些知识是分析 PostgreSQL 并发问题的“透视镜”。
第三期预告:锁机制与死锁处理实战
我们将分析 PostgreSQL 的表级锁(ACCESS SHARE、ROW EXCLUSIVE、ACCESS EXCLUSIVE 等)、行级锁(FOR UPDATE、FOR NO KEY UPDATE…),以及死锁的检测与预防。同时会给出常用的排查脚本,帮助你在线解决“卡住的事务”问题。
敬请期待第三期!
如果你在使用 PSQL 过程中遇到任何与 MVCC 相关的困惑(比如明明更新了数据却查不到、表膨胀严重),欢迎在评论区交流。下期见!