KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
01 · MVCC 与隔离级别 — keel 龙骨
第 00 章留下的问题是:一行在磁盘上有两个版本,那查询到底该看哪一个?
第 00 章留下的问题是:一行在磁盘上有两个版本,那查询到底该看哪一个?
答案不是「看最新的」,而是「看当前快照允许你看到的那个」。理解 MVCC 的全部难点都在这里——可见性判断不是对行做判断,是拿行的 xmin/xmax 和查询自己携带的快照做比较。
现场
同一张表,同一个事务,两条一模一样的 SELECT count(*),结果不一样。这不是 bug:
###### 场景一:READ COMMITTED,同一事务两次 count ######
初始行数 = 5
A BEGIN -> BEGIN
A 第 1 次 count -> [(5,)]
B BEGIN -> BEGIN
B INSERT -> INSERT 0 1
B COMMIT -> COMMIT
A 第 2 次 count (RC) -> [(6,)]
A COMMIT -> COMMIT
换成 REPEATABLE READ,同样的交错顺序,第二次读还是 6:
###### 场景二:REPEATABLE READ,同一事务两次 count ######
A BEGIN RR -> BEGIN
A 第 1 次 count(此语句建立快照) -> [(6,)]
B BEGIN -> BEGIN
B INSERT -> INSERT 0 1
B COMMIT -> COMMIT
A 第 2 次 count (RR) -> [(6,)]
A COMMIT -> COMMIT
两张会话日志里,唯一的差别是 BEGIN 后面跟的隔离级别。代码层面要解释这个差别,得先知道 PostgreSQL 是怎么表示「一个快照」的。
快照是什么样的
PostgreSQL 的快照不是「一份数据副本」,而是三个数的组合。查询时数据库自己的快照可以直接打印:
SELECT pg_current_xact_id() AS cur_xid,
pg_snapshot_xmin(pg_current_snapshot()) AS snap_xmin,
pg_snapshot_xmax(pg_current_snapshot()) AS snap_xmax;
-- cur_xid | snap_xmin | snap_xmax
-- ---------+-----------+-----------
-- 743 | 743 | 743
pg_current_snapshot() 的完整格式是 xmin:xmax:xip_list:
| 组成 | 含义 | 可见性上的作用 |
|---|---|---|
xmin |
快照里仍活跃的最老事务号 | 小于它的已提交事务,结果一定可见 |
xmax |
第一个「尚未分配」的事务号 | 大于等于它的事务一律不可见 |
xip_list |
快照建立时正在运行的事务号列表 | 落在列表里的事务,无论提交与否都不可见 |
判断规则就一句话:一个行版本可见,当且仅当它的 xmin 对应的插入事务已提交且不在 xip_list 里,并且它的 xmax 要么是 0、要么对应的事务中止或不在快照范围内。
这条规则解释了第 00 章那个例子:旧版本 xmax=742,当 742 提交后,任何「快照建立于 742 提交之后」的查询都会认为旧版本已死、新版本(xmin=742)才是有效版本。而在 742 还没提交时建立的快照里,两个版本一个被 xip 挡掉、一个被 xmax 挡掉,结果是「这一行不存在」——这正是为什么未提交的写看不见。
差别就在一行代码:快照什么时候取
两种隔离级别的实现差别,落到 PostgreSQL 源码里是同一句调用 GetTransactionSnapshot(),但走的分支不同:
- READ COMMITTED:每条语句开始时都重新取一次快照。同一个事务里两条
SELECT,中间别人提交了,第二条就能看见。 - REPEATABLE READ:事务里第一次取快照后把它缓存住,后续所有语句复用同一个快照。别人提交什么都跟你看不见。
所以「隔离级别」听起来抽象,在实现上是「快照是每条语句取一次,还是每个事务取一次」。第 00 章那个 (0,2) 新版本,在 RC 下第二条语句能看见,在 RR 下看不见——差别不在数据,在快照的取值时机。
flowchart TD
ST["事务里执行一条 SELECT"] --> ISO{"隔离级别?"}
ISO -->|READ COMMITTED| NEW["每条语句都调用<br/>GetTransactionSnapshot()<br/>取当前最新快照"]
ISO -->|REPEATABLE READ| CACHE{"本事务已经取过快照?"}
CACHE -->|否,第一条语句| TAKE["取一次快照并缓存"]
CACHE -->|是| REUSE["复用缓存的那份快照"]
NEW --> SCAN["扫描行版本<br/>比对每条记录的 xmin/xmax 与 xip_list"]
TAKE --> SCAN
REUSE --> SCAN
SCAN --> VIS{"xmin 已提交且不在 xip 里,<br/>且 xmax 未生效?"}
VIS -->|是| SEE["这一版可见"]
VIS -->|否| HIDE["跳过,看下一个版本"]
HIDE -.->|"整条链都不可见"| GONE["该行对本快照不存在"]
REUSE -.->|"别人并发改了同一行"| SER["更新时触发<br/>could not serialize access<br/>due to concurrent update"]
style ST fill:#e3f2fd,color:#0d3b66
style ISO fill:#fff3e0,color:#8a4b00
style NEW fill:#e8f5e9,color:#1b5e20
style CACHE fill:#fff3e0,color:#8a4b00
style TAKE fill:#e8f5e9,color:#1b5e20
style REUSE fill:#e8f5e9,color:#1b5e20
style SCAN fill:#f1f8e9,color:#33691e
style VIS fill:#fff3e0,color:#8a4b00
style SEE fill:#e8f5e9,color:#1b5e20
style HIDE fill:#fff3e0,color:#8a4b00
style GONE fill:#e3f2fd,color:#0d3b66
style SER fill:#ffebee,color:#b71c1c
失败路径:RR 下的并发更新
REPEATABLE READ 不是「只读更安全」那么简单,它在写的时候会真的报错。构造方式是两个 RR 事务抢同一行:
###### 场景三:REPEATABLE READ 下的并发更新冲突 ######
A BEGIN -> BEGIN
B BEGIN -> BEGIN
A UPDATE id=1(持有行锁) -> UPDATE 1
--- A 提交,释放锁 ---
A COMMIT -> COMMIT
B UPDATE id=1(会阻塞) -> ERROR: could not serialize access due to concurrent update
B ROLLBACK -> ROLLBACK
B 的行为值得拆开看。B 一开始并没有报错,它是在等 A 的行锁。等 A 提交、锁释放之后,PostgreSQL 面临一个选择:B 的快照里那一行的最新版本是旧的,但磁盘上已经有一个更新的版本了。按 B 的快照去改,等于改一个已经过期的值;按新版本去改,又违背了 B 已承诺的快照。PG 的选择是拒绝,抛 could not serialize access due to concurrent update。
换成 READ COMMITTED,同样的两个事务,B 不会报错:
###### 场景四:READ COMMITTED 下的同一并发更新 ######
A UPDATE id=1(持有行锁) -> UPDATE 1
--- A 提交,释放锁 ---
A COMMIT -> COMMIT
B UPDATE id=1(会阻塞) -> UPDATE 1
B -> 拿到行锁,UPDATE 成功
B COMMIT -> COMMIT
因为 RC 允许「语句级重新取快照」,B 在拿到锁之后重新看了一次最新的行版本,基于新值完成更新。这就是那句经典提醒的由来:RR 下的写事务必须准备好重试——报错不是异常,是隔离级别在履行契约。重试逻辑要放在事务外,不要在事务里 sleep 重试,否则会把冲突放大。
一个容易被忽略的代价
REPEATABLE READ 缓存快照意味着老版本不能被清理。只要还有一个 RR 事务(或者任何持有老快照的事务)开着,早于它快照的 dead tuple 就不能被 VACUUM 回收。一个忘记提交的 idle in transaction 连接,能让整张表在一夜之间膨胀起来。第 02 章会把这条链路完整走一遍。
顺带纠正一个常见说法:PostgreSQL 的 REPEATABLE READ 已经能防住幻读。它的快照是事务级的,两次 SELECT 看到的行集合完全一致,不存在「第二次多出几行」。所谓「RR 要配合间隙锁」是 MySQL InnoDB 的实现细节,不要套到 PG 上来。
生产边界
- 教学里两个会话手工交错,真实系统是几百个连接并发。可见性判断本身很便宜(几个整数比较),贵的是快照变旧带来的清理延迟。
pg_current_snapshot()只反映当前会话,要判断「全局最老快照」得看pg_stat_activity.backend_xmin和pg_stat_replication的backend_xmin——备库的慢查询同样会拖住主库的清理。- 上线要盯的:
idle in transaction会话的运行时长(pg_stat_activity.state_change)、最老的backend_xmin与当前事务号的差值。这两个指标比任何一个慢 SQL 都更能解释「表为什么只涨不缩」。 - 业务上优先用 RC 还是 RR,取决于这条链路能不能容忍写重试。读多写少、且读的是一致快照(比如生成对账单)时,RR 的价值才真正体现出来;纯写入链路用 RR,多数时候只是给自己增加重试代码。
动手
- 开一个 RR 事务,先读一次 count,再插一行并提交这个插入,然后第二次读——确认数字不变;随后在同一事务里更新刚才那一行,观察报错。
- 把上面的实验改成 RR,但让 B 在 A 提交之前就去更新(用两个 psql 窗口手工控制),记录 B 阻塞了多久。
- 查
pg_stat_activity找出所有backend_xmin非空的会话,判断谁在拖住清理。
可观察结果:看到 could not serialize access due to concurrent update 全文,并能指出它由哪两个事务号触发。
自测
- 快照的
xmin/xmax/xip_list各是什么?一个xmin落在xip_list里的行版本可见吗? - RC 和 RR 的差别,在实现上归结为哪一句调用的调用时机不同?
- 为什么 RR 下并发更新同一行会直接报错,而 RC 下能成功?
- 为什么说 PostgreSQL 的 RR 已经没有幻读问题,而不需要间隙锁?
- 一个长时间不提交的事务,为什么会让表膨胀?这条因果链的两端分别是什么?
↓ 下一步:02 章 · VACUUM 与表膨胀 —— 不可见的版本谁来清理,什么时候清,清不动会怎样。