PostgreSQL 和 MySQL 的 MVCC 到底有什么区别?从底层存储机制讲透
很多人学习数据库隔离级别时,都会接触一个词:MVCC。
MVCC,全称是 Multi-Version Concurrency Control,也就是多版本并发控制。
它解决的核心问题是:
在高并发场景下,读和写如何尽量互不阻塞?
简单说,数据库不会在一行数据被更新后立刻覆盖掉旧值,而是会保留某种形式的历史版本。这样,老事务可以继续读老版本,新事务可以看到新版本,从而实现:
读不阻塞写,写不阻塞读
但是,很多人容易忽略一个关键点:
PostgreSQL 和 MySQL InnoDB 虽然都实现了 MVCC,但它们保存历史版本的方式完全不同。
这正是数据库底层架构里的经典分水岭。
一句话概括:
PostgreSQL 把新旧版本都放在表文件里;
MySQL InnoDB 把当前版本放在表里,把历史版本放在 Undo Log 里。
这一个差异,会直接决定它们在 UPDATE、索引维护、回滚、垃圾清理和长事务场景下的表现。

一、先理解 MVCC 的本质:数据库为什么要保留“旧版本”?
假设一张表里有一行数据:
id = 1, name = 'Alice', age = 18
事务 A 开始读取数据。
与此同时,事务 B 把 age 从 18 改成 20,并提交。
这时候问题来了:
事务 A 后续再读这行数据时,应该看到 age = 18,还是 age = 20?
这取决于隔离级别。
如果事务 A 是在可重复读隔离级别下,它应该继续看到自己事务开始时的老版本:
age = 18
而不是事务 B 后来提交的新版本。
所以,数据库必须保留历史版本,否则老事务就无法实现一致性读。
MVCC 的核心就是:
同一行逻辑数据,在不同事务眼里可以呈现出不同版本。
但是,这些历史版本到底放在哪里?
PostgreSQL 和 MySQL InnoDB 给出了完全不同的答案。
二、PostgreSQL:历史版本和新版本都留在表里
PostgreSQL 的 MVCC 是典型的 Append-Only / Heap Tuple 多版本模型。
它的核心思想是:
UPDATE 不是原地覆盖,而是追加一个新版本。
也就是说,当你执行:
UPDATE user SET age = 20 WHERE id = 1;
PostgreSQL 底层并不是直接把原来那行 age = 18 改成 age = 20。
它更像是做了这样一件事:
旧 tuple:id = 1, age = 18 标记为过期
新 tuple:id = 1, age = 20 追加到表中
可以用一个简化图表示:
PostgreSQL Heap Table
┌─────────────────────────────────────┐
│ Tuple V1: id=1, age=18, xmin=100, xmax=200 │ ← 旧版本
│ Tuple V2: id=1, age=20, xmin=200, xmax=0 │ ← 新版本
└─────────────────────────────────────┘
这里有两个非常关键的隐藏字段:
xmin:创建这个 tuple 的事务 ID
xmax:删除或更新这个 tuple 的事务 ID
注意,PostgreSQL 里的 UPDATE,本质上可以理解成:
旧 tuple 被当前事务“删除”
新 tuple 被当前事务“插入”
所以旧版本会被设置 xmax,新版本会有新的 xmin。
事务在读取数据时,会根据自己的事务快照,结合 tuple 上的 xmin / xmax 判断:
这个版本对我是否可见?
如果可见,就返回。
如果不可见,就跳过。
三、MySQL InnoDB:当前版本在表里,历史版本在 Undo Log 里
MySQL InnoDB 的 MVCC 路线不同。
InnoDB 更接近:
主表保留当前版本,历史版本通过 Undo Log 追溯。
当你执行:
UPDATE user SET age = 20 WHERE id = 1;
InnoDB 会在聚簇索引记录中保留当前版本,同时把旧值写入 Undo Log。
简化理解如下:
InnoDB Clustered Index
当前行:id=1, age=20, DB_TRX_ID=200, DB_ROLL_PTR=undo_ptr
│
▼
Undo Log:age=18, trx_id=100
InnoDB 每行记录中也有隐藏字段,其中最关键的是:
DB_TRX_ID:最后修改这行数据的事务 ID
DB_ROLL_PTR:回滚指针,指向 Undo Log 中的旧版本
如果某个事务读取这行数据时发现:
当前版本太新,我不应该看到
那么它就会沿着 DB_ROLL_PTR 去 Undo Log 里找旧版本,在内存中构造出这个事务应该看到的历史版本。
这条版本链大概长这样:
当前版本 V3
│ DB_ROLL_PTR
▼
Undo V2
│
▼
Undo V1
所以,InnoDB 的一致性读并不是简单读表里的当前行,而是:
先读当前版本;
如果当前版本对自己不可见;
再顺着 Undo Log 回溯历史版本。
四、核心区别:版本到底放在哪里?
这是 PostgreSQL 和 MySQL InnoDB MVCC 的最核心差异。
| 维度 | PostgreSQL | MySQL InnoDB |
|---|---|---|
| 当前版本位置 | 表文件 Heap 中 | 聚簇索引记录中 |
| 历史版本位置 | 仍在表文件 Heap 中 | Undo Log 中 |
| UPDATE 本质 | 追加新 tuple,旧 tuple 标记过期 | 更新当前记录,并写 Undo Log |
| 版本判断依据 | xmin / xmax | DB_TRX_ID / DB_ROLL_PTR / ReadView |
| 历史版本读取 | 在表中扫描不同 tuple 版本 | 沿 Undo Log 版本链回溯 |
可以简单记成:
PostgreSQL:版本在表里。
InnoDB:版本在 Undo Log 里。
这不是实现细节的小差异,而是会直接影响数据库运行行为的底层架构差异。
五、UPDATE 开销:PostgreSQL 更像“追加写”,InnoDB 更像“当前行更新 + Undo”
1. PostgreSQL 的 UPDATE:写入一个新 tuple
PostgreSQL 每次 UPDATE 都会产生一个新的物理行版本。
这意味着:
逻辑上是一行数据;
物理上可能存在多个 tuple 版本。
例如连续更新三次:
UPDATE user SET age = 19 WHERE id = 1;
UPDATE user SET age = 20 WHERE id = 1;
UPDATE user SET age = 21 WHERE id = 1;
底层可能变成:
Tuple V1: age=18 dead
Tuple V2: age=19 dead
Tuple V3: age=20 dead
Tuple V4: age=21 live
这些 dead tuple 在没有被 VACUUM 清理之前,仍然占用表空间。
所以 PostgreSQL 的 UPDATE 本质是:
写新版本 + 标记旧版本过期 + 等待后续清理
这对写入有好处,也有代价。
好处是回滚简单,旧版本天然存在。
代价是表膨胀明显,必须依赖 VACUUM。
2. InnoDB 的 UPDATE:修改当前版本,同时写 Undo
InnoDB 更新时,会在数据页中维护当前记录,并把旧值写入 Undo Log。
简化流程是:
1. 当前行 age=18
2. UPDATE age=20
3. 当前行变成 age=20
4. Undo Log 记录 age=18
如果老事务还需要看到 age=18,就通过 Undo Log 还原出旧版本。
所以 InnoDB 的 UPDATE 本质是:
当前行更新 + Undo Log 记录旧版本
这里要注意一个细节:
不能粗暴地说 InnoDB 永远都是原地更新。
如果更新导致记录长度变化、页空间不足,或者更新主键,InnoDB 也可能出现删除标记、插入新记录、页分裂等额外操作。
但从 MVCC 版本管理角度看,InnoDB 的核心确实是:
主表保留当前版本,Undo Log 保存历史版本。
六、索引维护差异:PostgreSQL 的写放大更明显
1. PostgreSQL:新 tuple 物理位置变了,索引通常要更新
PostgreSQL 的索引项通常指向 heap tuple 的物理位置,也就是 TID。
当 UPDATE 产生新 tuple 时,新 tuple 的物理位置可能已经变了。
因此,普通 UPDATE 往往需要为新 tuple 写入新的索引项。
即使你更新的不是索引列,也可能引发索引维护。
这就是 PostgreSQL UPDATE 写放大的重要来源。
不过 PostgreSQL 有一个非常关键的优化:HOT,Heap-Only Tuple。
如果满足以下条件:
1. 没有更新任何索引列;
2. 新 tuple 能放在同一个 heap page 中;
那么 PostgreSQL 可以不更新索引,只在 heap page 内部维护版本链。
这叫 HOT Update。
简化理解:
普通 UPDATE:
旧 tuple → 新 tuple
索引也要指向新 tuple
HOT UPDATE:
索引仍指向旧 tuple 所在链条入口
Heap 内部跳到新版本
所以 PostgreSQL 面对高频 UPDATE 表时,经常会考虑设置:
ALTER TABLE your_table SET (fillfactor = 80);
目的就是给数据页预留空间,提高 HOT Update 的概率。
2. InnoDB:二级索引存主键,非索引列更新成本较低
InnoDB 的表本质上是聚簇索引组织表。
聚簇索引叶子节点存放完整行数据。
二级索引叶子节点存的不是物理地址,而是主键值。
例如:
secondary index on name:
name='Alice' → primary key id=1
所以,如果你只是更新一个非索引列:
UPDATE user SET age = 20 WHERE id = 1;
而 age 没有索引,那么二级索引通常不需要更新。
但是,如果你更新的是索引列:
UPDATE user SET name = 'Bob' WHERE id = 1;
那么对应的二级索引仍然需要维护。
如果更新主键,那成本更高,因为 InnoDB 的整行数据挂在聚簇索引主键下面,更新主键接近于删除旧行再插入新行。
所以,InnoDB 的索引维护成本可以总结为:
更新非索引列:索引成本较低
更新二级索引列:对应索引需要维护
更新主键:成本很高
七、垃圾回收差异:PostgreSQL 怕 Table Bloat,InnoDB 怕 Undo 堆积
MVCC 的核心代价是:
历史版本迟早要被清理。
只是 PostgreSQL 和 InnoDB 清理的东西不一样。
1. PostgreSQL:VACUUM 清理 dead tuple
PostgreSQL 的旧版本仍然留在表里。
当这些旧版本已经不再被任何事务需要时,它们就变成 dead tuple。
这些 dead tuple 不会自动从表文件中消失,而是需要 VACUUM 清理。
PostgreSQL 的 Autovacuum 会在后台扫描表,回收这些死 tuple。
但是,VACUUM 的问题在于:
它不是可有可无的优化,而是 PostgreSQL 正常运行所必需的机制。
如果 Autovacuum 跟不上 UPDATE / DELETE 的速度,就会出现:
Table Bloat
也就是表膨胀。
表膨胀会导致:
- 表占用空间变大;
- 顺序扫描变慢;
- 索引膨胀;
- 缓存命中率下降;
- 查询扫描更多无效 tuple;
- VACUUM 压力继续增加。
这是 PostgreSQL 高更新场景下最常见的性能问题之一。
2. InnoDB:Purge Thread 清理 Undo 历史版本
InnoDB 的历史版本主要在 Undo Log 中。
当没有事务需要这些旧版本后,后台 Purge Thread 会清理 Undo 历史记录。
如果系统中存在长事务,Purge Thread 不能清理这些旧版本,因为老事务可能还需要它们来做一致性读。
这会导致:
Undo Log 堆积
History List Length 变长
Undo 堆积会带来:
- 一致性读回溯链条变长;
- Undo 表空间增长;
- Purge 压力变大;
- 查询延迟升高;
- 事务系统负担加重。
所以 InnoDB 不太容易出现 PostgreSQL 那种主表 dead tuple 膨胀问题,但它会遇到 Undo 版本链堆积的问题。
八、长事务:两种数据库共同的敌人
很多线上数据库问题,本质都不是 SQL 写得有多复杂,而是长事务拖垮了 MVCC 清理机制。
1. PostgreSQL 中的长事务
PostgreSQL 中,如果存在一个很老的事务还没结束,那么 VACUUM 就不能清理这个事务可能还看得到的旧 tuple。
结果是:
老事务不结束,dead tuple 就不能真正回收。
这会导致:
- 表膨胀;
- 索引膨胀;
- Autovacuum 压力变大;
- 查询越来越慢;
- 甚至触发事务 ID wraparound 风险。
PostgreSQL 线上排查时,经常要看:
SELECT pid, usename, state, xact_start, query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start;
重点关注长期处于:
idle in transaction
的连接。
这种连接非常危险。
2. InnoDB 中的长事务
InnoDB 中,长事务会阻止 Purge Thread 清理 Undo Log。
结果是:
Undo 版本链越来越长。
这会导致:
- History List Length 升高;
- 一致性读性能下降;
- Undo 表空间膨胀;
- Purge 追不上业务写入;
- 数据库整体性能抖动。
MySQL 里经常需要观察:
SHOW ENGINE INNODB STATUS;
其中可以关注:
History list length
如果这个值持续变大,通常说明 Undo 清理跟不上,或者有长事务阻塞 Purge。
九、回滚速度:PostgreSQL 通常更快,InnoDB 大事务回滚可能很痛苦
1. PostgreSQL 回滚为什么快?
PostgreSQL 的旧版本和新版本都已经在表里了。
如果一个事务执行了很多 UPDATE,最后选择 ROLLBACK,PostgreSQL 并不需要把每一行数据都改回去。
它只需要在事务状态信息中把这个事务标记为 aborted。
事务状态信息存放在 pg_xact 中,历史上也常被称为 CLOG。
也就是说:
这个事务写出来的新 tuple 直接变成不可见。
后续这些无效 tuple 交给 VACUUM 清理。
所以 PostgreSQL 的回滚通常非常快。
它的代价不是当场支付,而是转移给后续垃圾回收。
2. InnoDB 回滚为什么可能慢?
InnoDB 的 Undo Log 不只是给一致性读用的,也用于事务回滚。
如果一个大事务更新了 1000 万行,最后回滚,InnoDB 需要根据 Undo Log 把这些修改一条一条撤销。
也就是说:
你改了多少,回滚时就可能需要反向处理多少。
所以 InnoDB 里大事务回滚可能非常慢。
这也是为什么 MySQL 线上非常忌讳超大事务。
一旦大事务回滚,可能会造成长时间资源占用和性能抖动。
十、读性能差异:谁更容易读到“历史包袱”?
1. PostgreSQL:读表时可能遇到 dead tuple
PostgreSQL 的历史版本在表里。
如果表中 dead tuple 很多,查询扫描时就可能遇到大量已经不可见的 tuple。
这会造成:
读放大
即使最终返回的数据不多,底层也可能扫描了很多无效版本。
这也是为什么 PostgreSQL 中 VACUUM、表膨胀控制、索引膨胀治理非常重要。
2. InnoDB:一致性读可能沿 Undo 链回溯
InnoDB 的当前版本在表里。
如果当前版本对某个事务不可见,事务就需要沿 Undo Log 版本链回溯。
如果 Undo 链很长,查询也会变慢。
所以 InnoDB 的读性能风险主要来自:
长事务 + 大量更新 + Undo 链过长
两者的读放大路径不同:
PostgreSQL:在表里扫到过期 tuple。
InnoDB:在 Undo Log 里回溯旧版本。
十一、运维角度:线上应该关注什么指标?
1. PostgreSQL 重点关注
PostgreSQL 运维要重点关注:
dead tuple 数量
autovacuum 是否及时
表膨胀
索引膨胀
长事务
事务 ID 年龄
常见排查 SQL:
SELECT
relname,
n_live_tup,
n_dead_tup,
last_vacuum,
last_autovacuum,
vacuum_count,
autovacuum_count
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 20;
如果某些表 n_dead_tup 长期很高,说明垃圾 tuple 积压严重。
高更新表可以考虑:
ALTER TABLE your_table SET (
autovacuum_vacuum_scale_factor = 0.02,
autovacuum_analyze_scale_factor = 0.02
);
对于高频 UPDATE 表,还可以考虑:
ALTER TABLE your_table SET (fillfactor = 80);
这样可以给数据页预留空间,提高 HOT Update 概率,降低索引写放大。
2. MySQL InnoDB 重点关注
MySQL InnoDB 运维要重点关注:
长事务
Undo Log 增长
History List Length
Purge 是否跟得上
大事务回滚
二级索引维护成本
常见查看方式:
SHOW ENGINE INNODB STATUS;
重点看类似信息:
History list length
如果 History List Length 持续升高,说明 Undo 历史版本堆积。
也可以查看长事务:
SELECT
trx_id,
trx_started,
trx_state,
trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started;
如果存在运行时间很长的事务,要重点排查。
十二、面试时怎么回答最稳?
如果面试官问:
PostgreSQL 和 MySQL 的 MVCC 有什么区别?
可以按这个结构回答。
第一层:先给一句话结论
PostgreSQL 和 InnoDB 的核心区别在于历史版本存储位置不同。
PostgreSQL 把历史版本和新版本都放在表文件中,通过 xmin/xmax 判断可见性;
InnoDB 把当前版本放在聚簇索引中,把旧版本放在 Undo Log 中,通过 DB_TRX_ID 和 DB_ROLL_PTR 构造一致性读。
第二层:展开 UPDATE 机制
PostgreSQL 的 UPDATE 更像 DELETE + INSERT,会生成新的 tuple,旧 tuple 留在 heap 中等待 VACUUM 清理。
InnoDB 的 UPDATE 会修改当前记录,同时把旧值写入 Undo Log。老事务如果需要旧版本,就沿着回滚指针去 Undo Log 中构造历史版本。
第三层:讲工程影响
这个差异会导致几个工程影响:
第一,PostgreSQL 容易出现 table bloat,所以 VACUUM 非常关键;
InnoDB 主表不会因为 MVCC 历史版本直接堆积,但 Undo Log 可能因为长事务膨胀。
第二,PostgreSQL 非 HOT Update 通常需要更新索引项,写放大较明显;
InnoDB 如果只是更新非索引列,二级索引维护成本相对较低。
第三,PostgreSQL 回滚通常很快,因为只需要标记事务 aborted;
InnoDB 大事务回滚可能较慢,因为要应用 Undo Log 反向撤销。
第四,两者都怕长事务。PostgreSQL 的长事务会阻止 VACUUM 回收 dead tuple;InnoDB 的长事务会阻止 Purge 清理 Undo 历史版本。
第四层:补一个加分点
PostgreSQL 为了降低 UPDATE 的索引写放大,引入了 HOT Update。
如果 UPDATE 没有修改索引列,并且同一个数据页还有空间,新版本可以放在同一个 heap page 内,从而避免更新索引。
这个回答已经足够覆盖底层原理、工程影响和实践经验。
十三、核心对比表
| 维度 | PostgreSQL | MySQL InnoDB |
|---|---|---|
| MVCC 历史版本位置 | 表文件 Heap 中 | Undo Log 中 |
| 当前版本位置 | Heap tuple 中 | 聚簇索引记录中 |
| UPDATE 机制 | 追加新 tuple,旧 tuple 标记过期 | 更新当前记录,旧值写入 Undo |
| 可见性判断 | xmin / xmax / 事务快照 | ReadView / DB_TRX_ID / DB_ROLL_PTR |
| 垃圾清理 | VACUUM / Autovacuum | Purge Thread |
| 主要空间风险 | Table Bloat、Index Bloat | Undo Log 堆积、History List Length 变长 |
| 索引更新成本 | 非 HOT Update 写放大明显 | 非索引列更新成本较低 |
| 回滚速度 | 通常很快,标记事务状态即可 | 大事务回滚可能很慢 |
| 长事务影响 | 阻止 VACUUM 回收 dead tuple | 阻止 Purge 清理 Undo |
| 高更新表优化 | Autovacuum 参数、fillfactor、HOT Update | 控制事务大小、避免长事务、关注 purge |
十四、实际选型怎么看?
如果从工程角度看,不能简单说 PostgreSQL 的 MVCC 更好,或者 InnoDB 的 MVCC 更好。
它们只是取舍不同。
PostgreSQL 更需要关注
高频 UPDATE / DELETE 表的膨胀问题
Autovacuum 配置
HOT Update 命中率
长事务
索引膨胀
适合的优化思路是:
减少无意义 UPDATE;
高更新表设置合理 fillfactor;
调低 autovacuum scale factor;
定期观察 dead tuple;
避免 idle in transaction。
MySQL InnoDB 更需要关注
Undo Log 堆积
History List Length
长事务
大事务回滚
二级索引维护成本
适合的优化思路是:
避免大事务;
批量更新分批提交;
避免长时间开启事务不提交;
观察 innodb_trx;
控制二级索引数量;
关注 purge 能否跟上。
十五、最后总结
PostgreSQL 和 MySQL InnoDB 的 MVCC 都是为了解决同一个问题:
让读写在高并发场景下尽量互不阻塞。
但它们采用了完全不同的底层策略。
PostgreSQL 的思路是:
把所有版本都留在表里,通过 xmin/xmax 判断可见性。
所以它的特点是:
回滚快,版本管理直接,但容易产生表膨胀,强依赖 VACUUM。
MySQL InnoDB 的思路是:
表里保留当前版本,历史版本放在 Undo Log 里,通过回滚指针构造旧版本。
所以它的特点是:
主表历史版本压力较小,但长事务会导致 Undo 堆积,大事务回滚成本高。
真正理解 MVCC,不是背一句“读不阻塞写,写不阻塞读”,而是要回答清楚:
历史版本存在哪里?
事务怎么判断自己能看哪个版本?
旧版本什么时候被清理?
长事务会卡住什么?
UPDATE 会带来什么写放大?
回滚成本由谁承担?
能把这些问题讲明白,才算真正理解了 PostgreSQL 和 MySQL InnoDB 在数据库引擎底层的架构差异。
更多推荐


所有评论(0)