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(),但走的分支不同:

所以「隔离级别」听起来抽象,在实现上是「快照是每条语句取一次,还是每个事务取一次」。第 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 上来。

生产边界

动手

  1. 开一个 RR 事务,先读一次 count,再插一行并提交这个插入,然后第二次读——确认数字不变;随后在同一事务里更新刚才那一行,观察报错。
  2. 把上面的实验改成 RR,但让 B 在 A 提交之前就去更新(用两个 psql 窗口手工控制),记录 B 阻塞了多久。
  3. 查 pg_stat_activity 找出所有 backend_xmin 非空的会话,判断谁在拖住清理。

可观察结果:看到 could not serialize access due to concurrent update 全文,并能指出它由哪两个事务号触发。

自测

  1. 快照的 xmin/xmax/xip_list 各是什么?一个 xmin 落在 xip_list 里的行版本可见吗?
  2. RC 和 RR 的差别,在实现上归结为哪一句调用的调用时机不同?
  3. 为什么 RR 下并发更新同一行会直接报错,而 RC 下能成功?
  4. 为什么说 PostgreSQL 的 RR 已经没有幻读问题,而不需要间隙锁?
  5. 一个长时间不提交的事务,为什么会让表膨胀?这条因果链的两端分别是什么?

↓ 下一步:02 章 · VACUUM 与表膨胀 —— 不可见的版本谁来清理,什么时候清,清不动会怎样。

进入 keel 阅读