第 02 章

MVCC 与事务

上一章看到一行可以有多个版本、每个 tuple 带 xmin/xmax——这一章讲这些版本如何被看见或隐藏,以及由此产生的死元组与膨胀。主线在这里收口:PG 写从不原地修改,UPDATE/DELETE 写新行版本、旧版本盖 xmax 成死元组;事务读快照。第 1 章的物理载体(tuple、隐藏列、ctid 链)在本章全部被「快照 + 可见性」这套规则激活。

第 1 章把物理地基铺到了 xmin/xmax 这两个隐藏列就停手了:它只说「这一版谁造、谁废」是记录在那里的,没说这条记录怎么被读。本章补上的就是「读」这一侧——一个事务执行查询时,靠一把叫快照(snapshot)的尺子,把堆里同一行的多个版本筛成「此刻该被这个事务看见的那一个」。整个 MVCC(多版本并发控制)就是这把尺子加一条判定规则,别无其他。理解了它,死元组为什么产生、膨胀为什么失控、HOT 为什么省事,全是直接推论。

本章你将建立的 schema

  • 快照(snapshot)= 判断哪些事务已提交的依据——一张「拍照那刻谁已定论、谁还在飞」的清单,可见性判定的全部输入之一。
  • 可见性(visibility)= 拿 tuple 的 xmin/xmax 跟快照比——一个版本对某事务可见与否,是一道纯比对题,不加锁、不等待。
  • 隔离级别(isolation level)= 快照取的时机不同——Read Committed 每条语句取新快照,Repeatable Read 整事务用同一张,差别只在「何时拍照」。
  • 死元组(dead tuple)= 没有任何快照能看到的旧版本——UPDATE/DELETE 留下的失效版本,占着空间和索引项,等 VACUUM 回收。
  • HOT = 不动索引的更新——新版本落在同页、且没改索引列时,更新不产生任何新索引项。

§1快照:判断「谁已定论」的依据

快照是拍照那一刻的一张事务清单:哪些 xid 已经定论、哪些还在进行、哪些尚未开始。

为什么需要它

堆里同一行躺着多个版本,每个版本带着「造它的 xid」和「废它的 xid」。要判断某一版此刻该不该被看见,光看这两个 xid 不够——还得知道这两个事务到当前查询发起的那一刻为止是否已经提交。可这个「是否已提交」的状态是随时间变化的:同一个 xmin=205,在事务 205 提交前看是「未定论」,提交后看是「已生效」。快照就是把「某一刻的提交状态」冻结成一张可比对的清单,让可见性判定有一个稳定、确定的参照系,而不必每比一个版本就去全局事务表里现查一次。

PG 给每个事务分配一个单调递增的事务 ID——xid(也写作 txid)。先有的事务 xid 小,后开的 xid 大。一个快照本质上由三部分构成,记作 (xmin, xmax, xip[]),注意这里的 xmin/xmax 是快照的字段,与 tuple 上同名的隐藏列不是一回事:

  • xmin(快照下界):所有小于它的 xid 都已经定论——要么早已提交、要么早已中止,不再有悬念。它是「已盖棺论定」的水位线。
  • xmax(快照上界):拍快照那一刻,xid 计数器分配到的下一个值。所有大于等于它的 xid 在拍照时还没开始,对本快照一律不可见。
  • xip[](in-progress 列表):落在 [xmin, xmax) 区间内、但拍照那一刻仍在进行(既未提交也未中止)的事务 xid 列表。这是快照里唯一需要逐个列举的部分。

把三者合起来读一个 xid 的状态就一目了然:小于 xmin → 已定论;大于等于 xmax → 尚未开始;落在区间内且在 xip[] 里 → 拍照时还在飞;落在区间内但不在 xip[] 里 → 拍照前已提交。这四种状态覆盖了所有 xid,没有第五种。

底层机制(比文档深一层)。 快照的 xip[] 解释了一个常被忽略的事实:一个事务提交得早不等于它对你可见得早。设想事务 198 在事务 200 之后才提交,而你的快照在 199 时刻拍下。即便 198 的 xid 比 200 小,只要拍照那一刻 198 还在进行,它就被收进 xip[],于是它写的版本对你不可见——可见性看的不是 xid 的大小,而是「拍照时是否已提交」。这也是为什么快照必须显式带一个 xip[] 列表,而不能简单地用一个「提交水位线」一刀切:事务的提交顺序与开始顺序可以不一致,区间内的「空洞」必须逐个记下来。这一点在 §2 的可见性判定里会被直接用到。

§2可见性:一道纯比对题

一个版本对快照是否可见,由它的 xmin/xmax 跟快照比对决定——不加锁、不等待。

为什么需要它

有了快照这把尺子,还需要一条规则把「尺子」量到「版本」上:给定一个 tuple 的 xmin/xmax 和一张快照,判定这一版此刻可见与否。这条规则是 MVCC 的心脏——它一旦确定,「读写互不阻塞」「同一行不同事务看到不同版本」全都是它的副产品。把它写清楚,后面所有现象都能用它推。

判定规则可以浓缩成一句话。一个 tuple 对某快照可见,当且仅当下面两个条件同时成立:

  • 它的创建者已提交、且在快照看来已完成——即 tuple 的 xmin 对应的事务已提交,且该 xid 小于快照上界、不在快照的 xip[] 里。换言之,造出这一版的事务,在拍照那一刻已经盖棺论定为「提交」。
  • 它的废止者尚未生效——即 tuple 的 xmax 要么为 0(没人删它),要么对应一个未提交、已中止或拍照时仍在进行的事务(在快照看来还没真正废掉它)。

反过来,只要创建者在快照看来还没提交,这一版就不可见(还没生效);只要废止者在快照看来已经提交,这一版也不可见(已被废)。两个条件,四种组合,结论唯一。

txid 递增 → 100 205 v1 xmin=100 xmax=205 v2 xmin=205 快照 A · txid 150 205 尚未提交 看到 v1 快照 B · txid 300 205 已提交 看到 v2 同一物理行 · 两张快照 · 谁都没被阻塞
图 2.1同一行两个版本对两张快照的可见性。注意:v1 与 v2 是堆里并存的两份;快照 A(事务 205 提交前拍)量出「v1 可见、v2 的创建者未提交」,快照 B(205 提交后拍)量出「v1 已被 205 废、v2 可见」。同一物理行,不同快照看到不同版本,谁都没被阻塞。

底层机制(比文档深一层)。 这道纯比对题正是「读不阻塞写、写不阻塞读」的全部原因,值得拆开看清两个方向。

读为什么不阻塞写。一次 SELECT 要做的,只是拿快照去逐个比对候选 tuple 的 xmin/xmax,挑出可见的那一版返回。整个过程不申请任何行锁、不关心此刻有没有别的事务正在改这一行——因为它要的版本已经物理存在于堆里,比对完即可读走。与此同时另一个事务尽可以去写新版本,那只是往堆里追加一份 v3,丝毫不影响正在比对 v1/v2 的读者。读者从不等锁,于是读不阻塞写。

写为什么不阻塞读。一次 UPDATE 不去触碰旧版本的数据字节(只给旧版本盖一个 xmax 失效戳、并在别处追加新版本),旧版本因此完整保留。任何持有「能看到旧版本」的快照的读者,照样能读到那份未被销毁的旧数据,无须等写者结束。两个方向合起来,就是 MVCC 用「多版本物理共存 + 快照比对」换掉了「读写互斥锁」——代价是堆里堆积的旧版本(§4 的死元组)。这条因果链是第 1 章「为省并发开销、把清理后移给 VACUUM」那句话的正面兑现。

类比 · 文件的版本历史

把一行想成一份带版本历史的文件:每次「保存」不是覆盖,而是存一个新版本号,旧版本仍在历史里。你打开文件时记下「手里这份是截至某时刻的版本」(快照),之后别人怎么存新版本都不影响你手里这份。类比边界:版本历史工具通常永久保留所有版本;而 PG 的旧版本一旦「对所有人都不可见」,就成了纯粹的垃圾(死元组),必须被 VACUUM 物理回收,否则表会无限胖下去——这正是 §4 与第 3 章的主题,是「版本历史」类比覆盖不到的。

§3隔离级别:差别只在「何时拍快照」

四个隔离级别在 PG 里的真正差异,归结为快照在什么时机被拍下、被沿用多久。

为什么需要它

§2 把「一张快照怎么用」讲透了,但没说快照什么时候拍。这个时机正是隔离级别的全部内容:拍得越频繁,事务越能看到外界的最新提交(但同一事务内两次查询会不一致);拍一次用到底,事务内部绝对一致(但看不到外界中途的提交)。SQL 标准用「能否发生某些异常」来定义隔离级别,PG 用「快照取的时机」来实现它们——理解了实现,标准里那几个异常名词就不必死记。

PG 支持 SQL 标准的四个隔离级别,但实现上有自己的取舍:

  • Read Uncommitted:标准里允许脏读(读到未提交数据)。PG 没有脏读的实现路径——它把这一级直接等同于 Read Committed。声明 Read Uncommitted 不会让你读到任何未提交的版本。
  • Read Committed(默认):每一条语句开始时取一张新快照。于是每条语句都能看到「该语句开始那一刻」已提交的所有数据。这是 PG 的默认级别。
  • Repeatable Read:事务的第一条语句取一张快照,整个事务自始至终沿用它。事务内任何查询都基于同一张快照,看到的是「事务开始那一刻」的数据视图——这就是快照隔离。
  • Serializable:在 Repeatable Read 的快照隔离之上,叠加 SSI(Serializable Snapshot Isolation,可序列化快照隔离)+ 谓词锁,监控事务之间的读写依赖,一旦发现会破坏可序列化的危险模式就让其中一个事务失败。
四个隔离级别 × 四类异常(PG 实现,✗=该级别杜绝 / ✓=该级别允许)
隔离级别脏读不可重复读幻读序列化异常
Read Uncommitted
(PG 等同 RC)
✗✓✓✓
Read Committed
默认 · 每语句新快照
✗✓✓✓
Repeatable Read
事务级单一快照
✗✗✗✓
Serializable
SSI + 谓词锁
✗✗✗✗

底层机制(比文档深一层)。 表里两个值得停下来想的格子。

其一,Read Committed 为什么会「不可重复读」。因为它每条语句重新拍快照。同一个事务里连续两条相同的 SELECT,第一条用快照甲、第二条用快照乙;若两条之间别的事务提交了改动,乙就量得到、甲量不到,两次结果于是不同。这不是 bug,而是「每语句新快照」的直接后果——要消除它,把隔离级别提到 Repeatable Read,让整事务共用一张快照即可。

其二,也是与 MySQL 分道扬镳的一刀:PG 的 Repeatable Read 遇到写冲突会抛错,而不是阻塞。当两个 Repeatable Read 事务试图更新同一行,后提交的那个不会默默覆盖、也不会卡住等锁,而是直接报 40001(serialization_failure,序列化失败),把事务回滚——应用必须捕获它并重试整个事务。这与 InnoDB 的 Repeatable Read「后者阻塞等锁、拿到后基于旧读继续」是根本不同的并发哲学:PG 让快照保持纯粹(事务视图绝不被中途的写污染),代价是把冲突显式抛给应用处理。这条对比是第 6 章的主线之一,详见 第 6 章 · 默认隔离级别与冲突处理。

预测一下

Read Committed 下,同一个事务里执行两次完全相同的 SELECT count(*) FROM t,两次之间另一个事务 INSERT 了一行并 COMMIT。两次的结果一样吗?换成 Repeatable Read 呢?

展开答案

Read Committed:不一样。第二条 SELECT 开始时重新拍了快照,那一刻新行已提交,于是被量到,count 比第一次多 1。这正是「不可重复读 / 幻读」在 RC 下成立的体现。

Repeatable Read:一样。整个事务沿用第一条语句拍下的那张快照。无论中途别人提交了什么,那张快照里 xmax 上界以后开始的事务一律不可见,新行不在视图内,两次 count 完全相同。把隔离级别从 RC 提到 RR,本质就是把「每语句拍照」改成「整事务一张照」。

§4死元组:没有任何快照能看到的旧版本

当一个旧版本的 xmax 已提交、且对所有现存快照都不可见,它就成了死元组——纯垃圾,等回收。

为什么需要它

§2 说写不阻塞读的代价是旧版本被保留;§3 说不同隔离级别下不同事务持有不同快照。把两者叠起来就有了一个关键问题:一个旧版本到底什么时候彻底没用、可以物理删掉?答案不是「它被 UPDATE 取代时」——那一刻往往还有老快照需要它。答案是「再没有任何现存或未来快照能看到它时」。能精确回答这个问题,才能既安全(不删还有人要看的版本)又及时(不留永远没人看的垃圾)地回收空间。死元组就是这个问题的「是」那一侧。

一个 tuple 版本成为死元组(dead tuple)的条件是两点合一:它的 xmax 对应的事务已经提交(即它确实被某个已生效的事务删除或更新取代了),并且当前数据库里没有任何活动快照的视图能再看到它(所有比它的 xmax 更老的、本来还会需要它的事务都已经结束)。满足这两点,这一版对全世界都不可见,永远不会再被任何查询返回——它就是确定的垃圾。

但死元组不会自动消失。它仍然物理地占着所在 Page 的一个槽位和数据空间;如果它对应的行在被索引的列上有值,每个相关索引里也还留着一条指向它的索引项。这些空间和索引项要等 VACUUM 来回收——这是第 3 章的主题(详见 第 3 章 · VACUUM)。在 VACUUM 跑到之前,死元组就是表和索引里实打实的「虚胖」。

把概念钉死

「失效」和「死」是两个时刻,别混。UPDATE 一发生,旧版本立刻被盖上 xmax——这叫失效,但只要还有某个老快照需要它,它就还不是死元组。只有当 xmax 已提交、且再没有快照能看到它,它才升级为死元组。这个「失效 → 死」之间的时间差,恰恰是被最老的活动事务撑开的——§6 的膨胀失败模式整个就建立在这个时间差被一个长事务无限拉长之上。

§5HOT:不动索引的更新

新版本落在同一个 Page、且没改任何被索引的列时,更新不产生任何新索引项。

为什么需要它

「写从不原地改」带来一个尖锐的次生成本:默认情况下,每一次 UPDATE 既要在堆里写新版本,又要在每一个索引里为新版本插一条新索引项(因为新版本的 ctid 和旧版本不同,索引必须指向新位置)。一张表上若有五个索引,一次更新就要动五处索引——这就是「写放大」。HOT(Heap-Only Tuple,仅堆元组)是 PG 针对这个成本的核心优化:在条件满足时,让更新完全跳过所有索引。理解 HOT 的触发条件,是把更新成本从「按索引个数线性放大」压回「只写堆」的关键,也是后面解释「给频繁更新的列加索引为何拖慢写入」的钥匙。

HOT 更新触发需要两个条件同时满足:

  • 被改的列上没有索引——这次 UPDATE 修改的所有列,都不是任何索引的组成列。若改了哪怕一个被索引的列,索引就必须指向新值,HOT 失效。
  • 新版本能放进同一个 Page——旧版本所在的那个 8KB Page 还有足够空闲(这正是第 1 章 fillfactor 预留空间的用途)装下新版本。若同页放不下、新版本只能落到别的 Page,HOT 也失效。

两个条件都满足时,HOT 这样工作:新版本写进同一个 Page;旧版本的 tuple 头被标记上 HEAP_HOT_UPDATED,其 ctid 指向同页内新版本的槽位,于是同一行的版本在页内串成一条 HOT 链;最关键的是——不为新版本插入任何索引项,所有索引仍然指向旧版本的那个 line pointer 槽。查询走索引命中旧 line pointer 后,顺着 HOT 链往下走,找到当前可见的新版本返回。索引「以为」自己还指着原来那行,实际由堆内的链接把它接到了最新版本。

非 HOT(改了带索引的列) 索引 两条项 旧版本 (0,1) 新版本 (0,2) + 新索引项 索引被迫长大 HOT(只改无索引的列) 索引 一条项 · 不变 旧 line ptr (0,1) HOT_UPDATED 新版本 (0,2) 同页 · 无新索引项 HOT 链 索引一动不动 一个索引就能让 HOT 失效 → 退回左侧路径
图 2.2非 HOT 与 HOT 更新的对照。左:改了被索引的列,必须为新版本插新索引项;右:只改无索引列且同页放得下,索引指向旧 line pointer 不变,由页内 HOT 链接到新版本。注意:只要被改的列上存在一个索引,HOT 就失效,退回左侧的「写堆 + 写每个索引」路径。

底层机制(比文档深一层)。 HOT 的红利和它的脆弱性是同一枚硬币的两面,必须一起记。

红利一侧:HOT 把「更新即更新所有索引」这条默认开销,在常见的「只改非索引字段」场景里归零。同时它还顺手缓解膨胀——HOT 链上被淘汰的中间版本,可以由一种轻量的页内清理(heap-only 的剪枝)在普通访问路径上就地回收槽位,不必等完整 VACUUM。对一张「主键 + 几个索引列基本不动、只频繁更新某个非索引计数/状态字段」的表,HOT 是决定其写入吞吐的关键机制。

脆弱性一侧,也是最实用的陷阱:给一个频繁更新的列加上索引,会让该列的更新从此无法走 HOT。因为「被改的列上没有索引」这条触发条件被破坏了——每次更新都退回「写新堆版本 + 给这个新索引插新项」的路径,写入开销陡增,且这个新索引本身也会因不断追加的索引项而快速膨胀。一个看似无害的「为了查询加个索引」,在高频更新列上会同时招来写放大与索引膨胀两笔账。这条权衡是第 4 章索引设计的核心之一,详见 第 4 章 · 索引与 HOT 的相互作用。

预测一下

一张表有个每秒被更新一次的计数器列 hit_count,原本它上面没有索引、更新一直走 HOT。现在有人为了「按热度排序」给 hit_count 建了个索引。这张表的写入为什么会慢一个数量级?

展开答案

因为 HOT 被这个新索引一刀打掉了。建索引前,更新 hit_count 不碰任何被索引的列,满足 HOT 条件:每次更新只在同页写一个新版本、索引一概不动,且页内剪枝还能顺手回收旧版本——写入极轻。建索引后,hit_count 成了被索引的列,每次更新都破坏「被改列无索引」这一条,HOT 失效:每次更新变成「堆里写新版本 + 在 hit_count 索引里插一条新项」,并且旧版本不再能被轻量页内剪枝及时清掉、要积压等 VACUUM。写放大叠加索引膨胀,加上这个高频写的索引页本身的争用,整体写入开销轻松差出一个数量级。结论:高频更新的列,加索引前要先算清它会废掉 HOT 这笔账。

§6膨胀:死元组积累快于回收

死元组和失效索引项的积累速度超过 VACUUM 的回收速度,表与索引就持续虚胖。

为什么需要它

前五节把因果链铺齐了:写产生新版本(§2)、旧版本失效后在条件满足时成死元组(§4)、HOT 能省一部分但不是总能省(§5)。膨胀(bloat)是这条链失衡时的系统性后果——它不是某个操作的瞬时开销,而是「产生垃圾的速度」持续压过「回收垃圾的速度」时,表和索引体积只涨不落的慢性病。它最危险的一种成因,恰恰把本章的快照、可见性、死元组三个概念串成一个能拖垮整库的失败模式,必须讲清。

膨胀指表(以及其索引)里死元组与失效索引项不断积累、占用的空间远超实际存活数据,且回收跟不上产生。适度的死元组是 MVCC 的正常代谢,VACUUM 会把空间标记为可复用;但当产生持续快于回收,体积就单向上涨——缓存里能装的有效行变少、顺序扫描要读更多无用 Page、查询变慢。

最致命的失败模式,是长事务 / idle-in-transaction(事务里执行完语句却迟迟不提交、一直挂着)持有一张老快照。回顾 §4:一个旧版本要升级成可回收的死元组,前提是「再没有任何活动快照能看到它」。只要有一个老事务的快照还活着,那个老快照能看到的所有旧版本就都不能被判定为死元组——VACUUM 即便跑,也不敢回收它们。PG 用一条回收水平线(xmin horizon)来记录「当前最老的、仍被某个活动事务需要的 xid」;一个长事务把这条水平线死死钉在它开始的那个 xid 上,使其无法前进。

idle-in-transaction 长事务 持一张老快照不放 回收水平线 (xmin horizon) 被钉住 · 不前进 死元组无法判定为「对谁都不可见」 VACUUM 跑了也不敢回收 多张表一起膨胀 表 A ↑ 长事务读过 表 B ↑ 没读过 · 照胖 表 C ↑ 没读过 · 照胖
图 2.3长事务阻塞回收的因果链:idle 事务持老快照 → 回收水平线被钉住 → 任何「在该事务开始后变死」的元组都无法被判定为对谁都不可见 → VACUUM 回收不了,多张表一起膨胀。注意:膨胀是系统性的——回收水平线是全局的,长事务从没读过的表 B、表 C 一样回收受阻、跟着胖,不限于它实际读过的行。

底层机制(比文档深一层)。 这个失败模式最反直觉、也最致命的一点:膨胀的范围远超那个长事务实际碰过的数据。回收水平线是数据库全局的单一水位,不是「这个事务读了哪些表」的精细记录。VACUUM 在回收任何一张表时,都要拿这条全局水平线做安全判定:凡 xmax 比水平线新的死元组,一律不敢回收(因为理论上那个老事务还会去读这张表)。于是一个挂在那里、只查过一张小表的 idle-in-transaction 事务,会让全库所有表上、在它开始之后产生的死元组统统无法回收——哪怕那些表它从未访问。一张高频更新的核心表,会在几小时内因为另一个连接上一个被遗忘的未提交事务而膨胀到原来的数倍。这就是为什么生产库要监控并杀掉长时间 idle-in-transaction 的连接,也是为什么「拿到连接就开事务、慢慢干活、忘了提交」是最危险的反模式之一。VACUUM 与回收水平线的完整机制,是第 3 章的正题(详见 第 3 章 · 可见性图与回收)。

§7动手观察:两个会话看快照如何隔离

把 §1–§3 拧成一个可观察的双会话实验。两栏分别是会话 A 和会话 B;先用默认的 Read Committed 看「B 看不到 A 的未提交、A 一提交 B 就看见」,再切到 Repeatable Read 看「B 钉住快照后,A 提交了 B 仍看不见」。下面的输出未在本机执行,用于演示该看哪些时刻、结果如何随快照时机变化。

双会话:Read Committed 下的可见性 sql
-- 会话A                                  -- 会话B
BEGIN;
INSERT INTO accounts (id, balance)
  VALUES (2, 50.00);
-- 尚未 COMMIT
                                          -- 会话B(默认 Read Committed)
                                          SELECT * FROM accounts WHERE id = 2;
                                          --  id | balance
                                          -- ----+---------
                                          -- (0 rows)        <- 看不见 A 的未提交版本
COMMIT;
                                          -- A 提交后,B 再查(新语句 = 新快照)
                                          SELECT * FROM accounts WHERE id = 2;
                                          --  id | balance
                                          -- ----+---------
                                          --   2 |   50.00   <- 现在看见了(未在本机执行)
双会话:Repeatable Read 钉住快照 sql
-- 会话A                                  -- 会话B
                                          BEGIN ISOLATION LEVEL REPEATABLE READ;
                                          -- 第一条 SELECT 拍下整事务沿用的快照
                                          SELECT count(*) FROM accounts;
                                          --  count
                                          -- -------
                                          --      2          <- 此刻的视图被钉住
BEGIN;
INSERT INTO accounts (id, balance)
  VALUES (3, 70.00);
COMMIT;
                                          -- A 已提交,但 B 仍用最初那张快照
                                          SELECT count(*) FROM accounts;
                                          --  count
                                          -- -------
                                          --      2          <- 仍看不见 id=3(未在本机执行)
                                          COMMIT;  -- 事务结束,下次新事务才会看到

逐行解读,把每个时刻映射回概念:

  • B 第一次查 = 空A 的 INSERT 还没 COMMIT,那个新 tuple 的 xmin 对应的事务在 B 的快照里属于「仍在进行」(落在 xip[])。按 §2 的规则,创建者未提交 → 不可见。对上 §1 的 xip[]。
  • A COMMIT → B 看见B 是 Read Committed,第二条 SELECT 重新拍了一张快照(§3 的「每语句新快照」)。这一刻 A 已提交,新 tuple 的创建者不再在 xip[] 里 → 可见。两次结果不同,正是「不可重复读」。
  • RR 下 B 第一条 SELECTBEGIN ISOLATION LEVEL REPEATABLE READ 后的第一条语句拍下快照,整事务沿用(§3 的快照隔离)。这张快照的 xmax 上界,定格在那一刻。
  • RR 下 A 提交后 B 仍 = 2A 的事务 xid 大于等于 B 快照的 xmax 上界,无论它是否提交,对 B 这张钉住的快照一律不可见(§2)。所以 count 不变——这就是 RR 消除不可重复读的机制:不是「锁住了行」,而是「快照根本没把后来的事务纳入视野」。
预测一下

上面 RR 的例子里,把 B 改成「先 UPDATE accounts SET balance = balance + 1 WHERE id = 3」,而 A 在 B 拍下快照之后才 INSERT id=3 并 COMMIT。B 的 UPDATE 会改到这一行吗?若 A 改的是一行 B 快照里已存在、A 也在更新的行呢?

展开答案

改不到 id=3。B 的 RR 快照在 A 插入 id=3 之前就拍定了,id=3 这一版的创建者落在 B 快照的 xmax 上界之外,对 B 不可见;B 的 UPDATE ... WHERE id=3 在自己的快照里根本找不到这行,影响 0 行。

若是双方都更新同一行已存在的数据(写写冲突),结局不同:B 在 RR 下不会默默覆盖、也不会一直阻塞,而是检测到该行已被另一个事务的更新改动,抛出 40001 serialization_failure,B 的事务回滚——应用需捕获并重试整个事务。这正是 §3 里「PG 的 RR 遇写冲突抛错而非阻塞」那条,与 InnoDB 的阻塞式 RR 形成对照(第 6 章)。

自测

  1. 快照的三部分 (xmin, xmax, xip[]) 各表示什么?一个 xid「比快照 xmin 小」和「在 xip[] 里」分别意味着它处于什么状态?
  2. 一个 tuple 对某快照「可见」的两个条件是什么?为什么「读不阻塞写」可以直接从这两个条件推出来?
  3. Read Committed 与 Repeatable Read 的唯一本质差别是什么(用「快照时机」回答)?由此,哪个会发生不可重复读?
  4. 一个 idle-in-transaction 的长事务,为什么会让一张它从未访问过的表也膨胀?
查看参考答案

1. xmin=快照下界,小于它的 xid 全部已定论;xmax=快照上界,大于等于它的 xid 拍照时尚未开始;xip[]=落在区间内、拍照那刻仍在进行的事务列表。「比 xmin 小」= 已定论(提交或中止,无悬念);「在 xip[] 里」= 拍照时还在飞、既未提交也未中止,因此它写的版本对本快照不可见。

2. 两个条件:(a) tuple 的 xmin 已提交且在快照看来已完成(小于上界、不在 xip[]);(b) tuple 的 xmax 为 0,或对应一个未提交/中止/进行中的事务。「读不阻塞写」由此直推:判定一个版本是否可见只是拿 xmin/xmax 跟快照比对,要读的版本已物理存在于堆中,全程不申请行锁、不等待,所以另一个事务并行地写新版本(只是往堆里追加)丝毫不挡读者。

3. 唯一本质差别是快照取的时机:RC 每条语句开始时取新快照;RR 在事务第一条语句取一张快照、整事务沿用。因为 RC 每语句换快照,同一事务里两条相同查询中间若有别的事务提交,结果会变——所以RC 会发生不可重复读,RR 不会。

4. 因为回收水平线(xmin horizon)是全局的单一水位,不区分长事务读过哪些表。VACUUM 回收任何一张表时,都要保证不删「xmax 比水平线新」的死元组(理论上那个老事务还会去读)。长事务把全局水平线钉住,于是全库所有表上、在它开始之后产生的死元组都无法回收——和它是否访问过那张表无关。

进阶挑战

制造一个长事务,亲眼看回收水平线被钉住

开两个会话。会话 1 执行 BEGIN; SELECT 1; 然后挂着不提交(模拟 idle-in-transaction)。会话 2 对某张表反复 UPDATE 同一批行制造大量死元组,然后跑 VACUUM (VERBOSE)。观察 VACUUM 报告里「死元组无法被回收」的计数——证明它们因会话 1 的老快照而被卡住。再回会话 1 执行 COMMIT,重跑 VACUUM (VERBOSE),看那些死元组这次能否被回收。

提示

关键观察点有三处。其一,SELECT backend_xmin, state, query FROM pg_stat_activity WHERE state = 'idle in transaction'; 能看到会话 1 持有的 backend_xmin——这就是被它钉住的回收水平线。其二,会话 2 的 VACUUM (VERBOSE) your_table; 输出里会有一行形如「X dead row versions cannot be removed yet, oldest xmin: N」,那个 oldest xmin 正好等于会话 1 的 backend_xmin——回收被这条水平线挡住了。其三,会话 1 COMMIT 后水平线前移,重跑 VACUUM,同一批死元组的「cannot be removed」计数应大幅下降乃至归零。把「backend_xmin = 老快照」「oldest xmin 卡住 = 回收水平线不前进」与本章 §4/§6 对上,这条因果链就闭合了。完整的 VACUUM 机制见 第 3 章。