很多人学习数据库隔离级别时都会接触一个词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。它更像是做了这样一件事旧 tupleid 1, age 18 标记为过期新 tupleid 1, age 20 追加到表中可以用一个简化图表示PostgreSQL Heap Table┌─────────────────────────────────────┐│ Tuple V1: id1, age18, xmin100, xmax200 │ ← 旧版本│ Tuple V2: id1, age20, xmin200, xmax0 │ ← 新版本└─────────────────────────────────────┘这里有两个非常关键的隐藏字段xmin创建这个 tuple 的事务 IDxmax删除或更新这个 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当前行id1, age20, DB_TRX_ID200, DB_ROLL_PTRundo_ptr│▼Undo Logage18, trx_id100InnoDB 每行记录中也有隐藏字段其中最关键的是DB_TRX_ID最后修改这行数据的事务 IDDB_ROLL_PTR回滚指针指向 Undo Log 中的旧版本如果某个事务读取这行数据时发现当前版本太新我不应该看到那么它就会沿着 DB_ROLL_PTR 去 Undo Log 里找旧版本在内存中构造出这个事务应该看到的历史版本。这条版本链大概长这样当前版本 V3│ DB_ROLL_PTR▼Undo V2│▼Undo V1所以InnoDB 的一致性读并不是简单读表里的当前行而是先读当前版本如果当前版本对自己不可见再顺着 Undo Log 回溯历史版本。四、核心区别版本到底放在哪里这是 PostgreSQL 和 MySQL InnoDB MVCC 的最核心差异。维度PostgreSQLMySQL InnoDB当前版本位置表文件 Heap 中聚簇索引记录中历史版本位置仍在表文件 Heap 中Undo Log 中UPDATE 本质追加新 tuple旧 tuple 标记过期更新当前记录并写 Undo Log版本判断依据xmin / xmaxDB_TRX_ID / DB_ROLL_PTR / ReadView历史版本读取在表中扫描不同 tuple 版本沿 Undo Log 版本链回溯可以简单记成PostgreSQL版本在表里。InnoDB版本在 Undo Log 里。这不是实现细节的小差异而是会直接影响数据库运行行为的底层架构差异。五、UPDATE 开销PostgreSQL 更像“追加写”InnoDB 更像“当前行更新 Undo”1. PostgreSQL 的 UPDATE写入一个新 tuplePostgreSQL 每次 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: age18 deadTuple V2: age19 deadTuple V3: age20 deadTuple V4: age21 live这些 dead tuple 在没有被 VACUUM 清理之前仍然占用表空间。所以 PostgreSQL 的 UPDATE 本质是写新版本 标记旧版本过期 等待后续清理这对写入有好处也有代价。好处是回滚简单旧版本天然存在。代价是表膨胀明显必须依赖 VACUUM。2. InnoDB 的 UPDATE修改当前版本同时写 UndoInnoDB 更新时会在数据页中维护当前记录并把旧值写入 Undo Log。简化流程是1. 当前行 age182. UPDATE age203. 当前行变成 age204. Undo Log 记录 age18如果老事务还需要看到 age18就通过 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 有一个非常关键的优化HOTHeap-Only Tuple。如果满足以下条件1. 没有更新任何索引列2. 新 tuple 能放在同一个 heap page 中那么 PostgreSQL 可以不更新索引只在 heap page 内部维护版本链。这叫 HOT Update。简化理解普通 UPDATE旧 tuple → 新 tuple索引也要指向新 tupleHOT UPDATE索引仍指向旧 tuple 所在链条入口Heap 内部跳到新版本所以 PostgreSQL 面对高频 UPDATE 表时经常会考虑设置ALTER TABLE your_table SET (fillfactor 80);目的就是给数据页预留空间提高 HOT Update 的概率。2. InnoDB二级索引存主键非索引列更新成本较低InnoDB 的表本质上是聚簇索引组织表。聚簇索引叶子节点存放完整行数据。二级索引叶子节点存的不是物理地址而是主键值。例如secondary index on name:nameAlice → primary key id1所以如果你只是更新一个非索引列UPDATE user SET age 20 WHERE id 1;而 age 没有索引那么二级索引通常不需要更新。但是如果你更新的是索引列UPDATE user SET name Bob WHERE id 1;那么对应的二级索引仍然需要维护。如果更新主键那成本更高因为 InnoDB 的整行数据挂在聚簇索引主键下面更新主键接近于删除旧行再插入新行。所以InnoDB 的索引维护成本可以总结为更新非索引列索引成本较低更新二级索引列对应索引需要维护更新主键成本很高七、垃圾回收差异PostgreSQL 怕 Table BloatInnoDB 怕 Undo 堆积MVCC 的核心代价是历史版本迟早要被清理。只是 PostgreSQL 和 InnoDB 清理的东西不一样。1. PostgreSQLVACUUM 清理 dead tuplePostgreSQL 的旧版本仍然留在表里。当这些旧版本已经不再被任何事务需要时它们就变成 dead tuple。这些 dead tuple 不会自动从表文件中消失而是需要 VACUUM 清理。PostgreSQL 的 Autovacuum 会在后台扫描表回收这些死 tuple。但是VACUUM 的问题在于它不是可有可无的优化而是 PostgreSQL 正常运行所必需的机制。如果 Autovacuum 跟不上 UPDATE / DELETE 的速度就会出现Table Bloat也就是表膨胀。表膨胀会导致表占用空间变大顺序扫描变慢索引膨胀缓存命中率下降查询扫描更多无效 tupleVACUUM 压力继续增加。这是 PostgreSQL 高更新场景下最常见的性能问题之一。2. InnoDBPurge 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, queryFROM pg_stat_activityWHERE xact_start IS NOT NULLORDER 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最后选择 ROLLBACKPostgreSQL 并不需要把每一行数据都改回去。它只需要在事务状态信息中把这个事务标记为 aborted。事务状态信息存放在 pg_xact 中历史上也常被称为 CLOG。也就是说这个事务写出来的新 tuple 直接变成不可见。后续这些无效 tuple 交给 VACUUM 清理。所以 PostgreSQL 的回滚通常非常快。它的代价不是当场支付而是转移给后续垃圾回收。2. InnoDB 回滚为什么可能慢InnoDB 的 Undo Log 不只是给一致性读用的也用于事务回滚。如果一个大事务更新了 1000 万行最后回滚InnoDB 需要根据 Undo Log 把这些修改一条一条撤销。也就是说你改了多少回滚时就可能需要反向处理多少。所以 InnoDB 里大事务回滚可能非常慢。这也是为什么 MySQL 线上非常忌讳超大事务。一旦大事务回滚可能会造成长时间资源占用和性能抖动。十、读性能差异谁更容易读到“历史包袱”1. PostgreSQL读表时可能遇到 dead tuplePostgreSQL 的历史版本在表里。如果表中 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 年龄常见排查 SQLSELECTrelname,n_live_tup,n_dead_tup,last_vacuum,last_autovacuum,vacuum_count,autovacuum_countFROM pg_stat_user_tablesORDER BY n_dead_tup DESCLIMIT 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 LengthPurge 是否跟得上大事务回滚二级索引维护成本常见查看方式SHOW ENGINE INNODB STATUS;重点看类似信息History list length如果 History List Length 持续升高说明 Undo 历史版本堆积。也可以查看长事务SELECTtrx_id,trx_started,trx_state,trx_queryFROM information_schema.innodb_trxORDER 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 回滚通常很快因为只需要标记事务 abortedInnoDB 大事务回滚可能较慢因为要应用 Undo Log 反向撤销。第四两者都怕长事务。PostgreSQL 的长事务会阻止 VACUUM 回收 dead tupleInnoDB 的长事务会阻止 Purge 清理 Undo 历史版本。第四层补一个加分点PostgreSQL 为了降低 UPDATE 的索引写放大引入了 HOT Update。如果 UPDATE 没有修改索引列并且同一个数据页还有空间新版本可以放在同一个 heap page 内从而避免更新索引。这个回答已经足够覆盖底层原理、工程影响和实践经验。十三、核心对比表维度PostgreSQLMySQL InnoDBMVCC 历史版本位置表文件 Heap 中Undo Log 中当前版本位置Heap tuple 中聚簇索引记录中UPDATE 机制追加新 tuple旧 tuple 标记过期更新当前记录旧值写入 Undo可见性判断xmin / xmax / 事务快照ReadView / DB_TRX_ID / DB_ROLL_PTR垃圾清理VACUUM / AutovacuumPurge Thread主要空间风险Table Bloat、Index BloatUndo 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 在数据库引擎底层的架构差异。