卷 IV · 事务CH 16深度 16/24

MVCC:PostgreSQL 从不原地改行

这是 PostgreSQL 最反直觉、也最漂亮的一个设计。你以为 UPDATE 是把旧值擦掉、写上新值——它不是。PostgreSQL 从不覆盖一行,它写一个新版本,把旧的留在原地打上标记。理解了这一点,「读为什么不会被写卡住」「隔离级别到底怎么实现的」全都通了。

MVCC多版本xmin/xmax快照

老办法的困境:读和写互相卡

先看没有 MVCC 时的世界。一个简单的并发数据库用来保证隔离:你要读一行,就锁住它,别人不能改;你要改一行,就锁住它,别人不能读。这能保证正确,但代价惨重:一个跑很久的报表查询(大量读),会把它读到的行全锁住,让所有想改这些行的写入排队干等;反过来,一个写事务也会挡住读。读写互相卡死,并发能力极差。

能不能让读永远不被写阻塞、写也永远不被读阻塞?能——只要允许「同一行数据,同时存在好几个版本」。这就是 MVCC(Multi-Version Concurrency Control,多版本并发控制)的核心思想。

UPDATE 不是覆盖,是「作废旧的 + 追加新的」

PostgreSQL 的做法惊人地直接:它从不原地修改一行。每一行(每个「元组」)身上带着两个隐藏的系统字段:

  • xmin:创建这个版本的事务号。
  • xmax:删除 / 作废这个版本的事务号(还没被删就是 0 / 空)。

当事务 T1 执行 UPDATE account SET bal=80 WHERE id=1,PostgreSQL 做的是:

  1. 找到旧版本(bal=100),把它的 xmax 设成 T1——意思是「T1 把这个版本作废了」。但旧版本还留在原地,没被删。
  2. 追加一个新版本(bal=80),xmin = T1、xmax = 空。

于是磁盘上同时躺着两个版本:一个旧的(被 T1 作废)、一个新的(被 T1 创建)。DELETE 更简单——只给旧版本打个 xmax,压根不写新版本。数据库里的行,是只增不改的。

快照:每个事务看见「属于它的那个版本」

既然一行有多个版本,那「你 SELECT 时看到哪个」就成了关键。答案是快照(snapshot):每个事务(或每条语句,取决于隔离级别)开始时,拍下一张「此刻哪些事务已经提交」的快照。看某个版本可不可见,靠一条可见性规则,大意是:

  • 这个版本的 xmin(创建者)对我的快照来说,已经提交了吗?没提交 → 看不见。
  • 这个版本的 xmax(作废者)对我的快照来说,已经提交了吗?提交了 → 这个版本对我已经死了,看不见;没提交(或为空)→ 这个版本对我还活着,看得见。

一句话:你只看得见「在你拍快照那一刻、已经确定存在、且尚未被确定删除」的那个版本。别人未提交的改动,你看不见;别人在你拍快照之后的提交,你(在同一快照内)也看不见。

▶ 动手 · 一次 UPDATE,两个版本

下面这台真的 MVCC 引擎,把两个并发事务的交错拆成四步。点「执行下一步」,盯住上方的堆(heap):UPDATE 时你会看到旧版本被打上 xmax、新版本被追加;再看 T2 的两次 SELECT——即使 T1 已经提交,T2 在自己的快照里读到的仍是同一个旧值。蓝色竖条标出「此刻对 T2 可见的版本」。

顺着四步看:① T1 更新,堆里立刻变成两个版本;② T2 开始读,因为 T1 还没提交,T2 看到旧值 100——而且 T2 的读没有被 T1 的写卡住,秒回;③ T1 提交;④ T2 在同一事务里再读一次,还是 100!因为 T2 用的是它开始时那张快照,T1 的提交发生在快照之后,对 T2 不可见。同一事务里两次读结果一致——这就是「可重复读」,下一章的主角。

◆ MVCC 换来了什么
  • 读永不阻塞写,写永不阻塞读:读只是去挑一个对自己可见的旧版本,根本不碰写入方正在造的新版本,谁也不用等谁。这是 PostgreSQL 高并发的地基。
  • 一致的快照视图:一个长查询 / 长事务,全程看到的是一个时间凝固的一致世界,不会读到「查到一半、别人改了、前后对不上」的错乱数据。
  • 隔离级别几乎「免费」:不同隔离级别,本质只是「快照什么时候拍」的区别——每条语句拍一次,还是整个事务拍一次。下一章会看到。
⚠ 天下没有白吃的并发:旧版本要有人清

这套「只增不改」的美妙设计,有个必然的账单:那些被作废的旧版本,不会自己消失。它们变成「死元组」,占着磁盘、占着索引空间,让表越来越臃肿、顺扫越来越慢。得有人定期把「再也没有任何事务需要」的死元组清理掉——这就是 VACUUM,本卷第 18 章的主角。MVCC 的快,是用「事后要打扫」换来的。

⇄ 对照 MySQL

MySQL 的 InnoDB 也用 MVCC——这一点两家相同,都是靠多版本实现「读不阻塞写」。但旧版本存哪,两家走了不同的路,这个差异影响深远:

  • PostgreSQL:新旧版本都存在主表(堆)里。好处是回滚极快(旧版本本来就在,啥也不用做);代价是主表会积累死元组,需要 VACUUM 来清理。
  • InnoDB:主表里只存最新版本,旧版本存在回滚段 / undo log 里。好处是主表不易膨胀;代价是回滚要靠 undo 逆向重建,长事务会让 undo 堆积(著名的「长事务撑爆 undo」问题),也有自己的 purge 线程要清理。
  • 所以「PostgreSQL 需要 VACUUM」不是缺陷,是它 MVCC 实现方式的必然副产品——就像 InnoDB 需要 purge undo 一样。下一章、下下章会把这套账算清楚。

这一章的一句话

PostgreSQL 从不原地改行:UPDATE 作废旧版本(打 xmax)、追加新版本(带 xmin),每个事务靠快照 + 可见性规则挑出「属于它的那个版本」——于是读永不阻塞写。代价是死元组要靠 VACUUM 清理。

既然「隔离级别只是快照拍法的不同」,那不同隔离级别下,并发到底会露出哪些破绽、又挡住哪些?下一章,我们用真引擎复现脏读、不可重复读、幻读,看它们怎么随隔离级别出现又消失。