很多人学习数据库隔离级别时,都会接触一个词: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 在数据库引擎底层的架构差异。

 

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐