← 返回文章
MySQLPostgreSQLDatabase 2026-05-12 10:00 8 min read

MVCC 原理:MySQL 与 PostgreSQL 对比

Multi-Version Concurrency Control 是关系型数据库实现高并发的核心机制。本文拆解 MySQL InnoDB 与 PostgreSQL 各自的实现思路,并给出两者的核心差异对比。

什么是 MVCC?

MVCC(Multi-Version Concurrency Control,多版本并发控制)是数据库处理并发读写的一种策略。 它的核心思想是:写操作不阻塞读操作,读操作不阻塞写操作—— 做到这一点的方式是给每行数据保留多个历史版本,读事务按自己的"快照时间点"去读对应版本,而不是等待写锁释放。

MySQL InnoDB 和 PostgreSQL 都实现了 MVCC,但在版本存储位置旧版本清理方式上走了两条截然不同的路。

MySQL InnoDB 的实现

InnoDB 在读已提交(RC)可重复读(RR)两个隔离级别下依赖 MVCC 实现一致性非锁定读(Consistent Non-Locking Read)。其核心由三部分协同完成。

① 隐藏字段

InnoDB 在每行记录后面追加了几个对用户不可见的字段:

字段 大小 含义
DB_TRX_ID 6 字节 最后一次修改该行的事务 ID
DB_ROLL_PTR 7 字节 回滚指针,指向 Undo Log 中该行的上一个版本
DB_ROW_ID 6 字节 无主键时 InnoDB 自动生成的行 ID(有主键时不存在)

② Undo Log — 版本链

每次 UPDATE / DELETE 时,InnoDB 不覆盖原数据,而是把旧版本写入 Undo Log, 再将新行的 DB_ROLL_PTR 指向该旧版本,形成一条版本链

当前行(堆) trx_id=105, name="Charlie"
↓ ROLL_PTR
Undo v2 trx_id=102, name="Bob"
↓ ROLL_PTR
Undo v1 trx_id=98, name="Alice"

旧版本存储在独立的 Undo Log 段里,与数据表文件分离。 当没有任何活跃事务再需要某个旧版本时,后台 Purge 线程会异步将其清理。

③ Read View — 可见性快照

Read View 在事务执行快照读(普通 SELECT)时生成,记录四个关键值:

  • m_ids — 此刻所有尚未提交的活跃事务 ID 列表。
  • min_trx_id — m_ids 中最小的事务 ID。
  • max_trx_id — 系统下一个将分配的事务 ID(已分配最大值 + 1)。
  • creator_trx_id — 创建该 Read View 的事务自身 ID。

读事务沿版本链从新到旧遍历,对每个版本的 DB_TRX_ID 依次判断: 等于 creator → 可见 ✅;小于 min → 已提交可见 ✅;大于等于 max → 不可见 ❌; 在 min~max 之间且在 m_ids 中 → 未提交不可见 ❌,否则可见 ✅。 找到第一个可见版本即返回。

RC 与 RR 的区别

两者都用 MVCC,区别只在于 Read View 何时生成

隔离级别 Read View 生成时机 效果
读已提交(RC) 每次 SELECT 重新生成 能读到最新已提交数据,但同一事务内两次读可能不同(不可重复读)
可重复读(RR) 事务首次 SELECT 时生成,之后复用 同一事务内多次读结果一致;配合 Gap Lock 防止幻读

RR 是 MySQL InnoDB 的默认隔离级别,"可重复读"就是靠整个事务只用一个冻结的 Read View 来保证的。

PostgreSQL 的实现

PostgreSQL 的 MVCC 思路与 MySQL 根本不同:它不维护独立的 Undo Log, 而是把每一个历史版本作为一条新 tuple 直接追加写入数据表的堆(Heap)文件里。

① 系统列 xmin / xmax

PostgreSQL 在每行(tuple)上内置了几个对用户只读的系统列:

列名 含义
xmin 插入该版本行的事务 ID(XID)——"我是谁创建的"
xmax 删除或更新该版本行的事务 ID;若仍有效则为 0
ctid 该 tuple 在磁盘上的物理位置(page, offset),UPDATE 后指向新版本

你可以直接用 SELECT 查到这些列:

SELECT xmin, xmax, ctid, * FROM your_table;

② 版本直接写入堆表

以一次 UPDATE 为例,PostgreSQL 的流程是:

  1. 1 把旧 tuple 的 xmax 设为当前事务 XID,标记"这行即将失效"。
  2. 2 在堆中插入一条新 tuple,其 xmin = 当前事务 XID,xmax = 0。
  3. 3 旧 tuple 并不立即消失——它就躺在原来的 page 里,成为一条 dead tuple,等待 VACUUM 清理。
旧 tuple xmin=98, xmax=105 → dead tuple
新 tuple xmin=105, xmax=0 → live
两条都在同一个堆文件里

③ 可见性判断

PostgreSQL 同样为每个查询维护一份事务快照(Snapshot),记录当前活跃的 XID 集合。 判断某条 tuple 对当前快照是否可见,规则如下:

  1. 1 xmin 未提交 → 不可见(别人还没写完)❌
  2. 2 xmin 已提交,且 xmax = 0(从未被删/改)→ 可见 ✅
  3. 3 xmin 已提交,xmax 已提交且对当前快照可见 → 已被删除,不可见 ❌
  4. 4 xmin 已提交,xmax 未提交或对快照不可见 → 删除尚未生效,可见 ✅

事务提交状态记录在 pg_xact(旧版叫 pg_clog)文件中, 每个 XID 对应 2 bit,标记 in-progress / committed / aborted。

④ VACUUM — 清理 dead tuple

由于旧版本直接堆积在表文件里,PostgreSQL 需要定期运行 VACUUM 来回收空间:

  • VACUUM:扫描表,将不再被任何活跃事务可见的 dead tuple 标记为可复用空间,但不缩小文件。
  • VACUUM FULL:重写整张表,真正回收磁盘空间,但需要排他锁,期间表不可访问。
  • Autovacuum:后台守护进程,根据表的更新频率自动触发 VACUUM,生产环境依赖它维持表的健康。

如果 VACUUM 跟不上写入速度,dead tuple 大量堆积,表文件会持续膨胀(Table Bloat),这是 PostgreSQL 运维中最常见的性能问题之一。

MySQL vs PostgreSQL 核心差异

维度 MySQL InnoDB PostgreSQL
旧版本存储位置 独立 Undo Log 段 原地写入堆表文件(dead tuple)
版本标识 DB_TRX_ID + DB_ROLL_PTR 链 每 tuple 的 xmin / xmax
旧版本清理 后台 Purge 线程自动清理 Undo Log 需要手动或 Autovacuum 定期 VACUUM
写放大 写 Undo Log + 更新主表 在堆中追加新 tuple(索引也需同步更新)
表膨胀风险 低(Undo Log 独立,不影响主表大小) 高(dead tuple 堆积在表文件中)
回滚开销 读 Undo Log 恢复旧版本,开销随修改量增长 旧版本已在堆里,回滚只需更新 xmax,较轻

小结

两种实现都达成了"读写不互斥"的目标,但取舍方向相反:

  • MySQL 把历史版本挪到 Undo Log,主表保持干净,但需要在版本链上追溯,长事务会让版本链变长拖慢读取。
  • PostgreSQL 历史版本原地堆放,回滚和读取旧版本都很快,但大量写入时 dead tuple 会造成表膨胀,运维上需要关注 Autovacuum 的运行状况。

理解这两种机制,能帮助你在排查慢查询、长事务、表膨胀等问题时直接找到根因,而不是只会调参。