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 指向该旧版本,形成一条版本链:
旧版本存储在独立的 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 查到这些列:
② 版本直接写入堆表
以一次 UPDATE 为例,PostgreSQL 的流程是:
- 1 把旧 tuple 的
xmax设为当前事务 XID,标记"这行即将失效"。 - 2 在堆中插入一条新 tuple,其
xmin= 当前事务 XID,xmax= 0。 - 3 旧 tuple 并不立即消失——它就躺在原来的 page 里,成为一条 dead tuple,等待 VACUUM 清理。
③ 可见性判断
PostgreSQL 同样为每个查询维护一份事务快照(Snapshot),记录当前活跃的 XID 集合。 判断某条 tuple 对当前快照是否可见,规则如下:
- 1
xmin未提交 → 不可见(别人还没写完)❌ - 2
xmin已提交,且xmax= 0(从未被删/改)→ 可见 ✅ - 3
xmin已提交,xmax已提交且对当前快照可见 → 已被删除,不可见 ❌ - 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 的运行状况。
理解这两种机制,能帮助你在排查慢查询、长事务、表膨胀等问题时直接找到根因,而不是只会调参。