资讯动态

三大数据库MVCC机制深度对比:MySQL版本链、PostgreSQL vacuum与Oracle undo

发布时间:2026/10/6 4:00:49 来源:尧图企业网站定制
前几天一个后端同学私信我说他们的 MySQL 开始频繁出现锁等待问我要不要切 PostgreSQL。我反问他一句缓存里的死锁日志看过没事务隔离级别改过没版本链有没有拉出来分析他愣了半天反问我版本链是什么。这种场景我见得太多了——很多人天天写 SQL但一碰到数据库内部的并发控制机制就发怵。其实 PostgreSQL、MySQL、Oracle 这三家数据库的 MVCC 机制就是理解并发行为、隔离级别坑点、慢 SQL 根因的钥匙。搞懂它你才能明白为什么 PG 要跑 vacuum为什么 MySQL 长事务会拖垮 undo为什么 Oracle 偶尔蹦出 ORA-01555。这篇文章不绕弯子直接把这三大库的 MVCC 实现逐层拆开最后再放一张多维度的对比表。适合数据库运维、后端开发、架构师看准备数据库面试的兄弟同样能捞到干货。1. MVCC机制核心概念与设计思路1.1 为什么并发更新总是阻塞——MVCC到底解决什么问题先回到原点数据库并发控制的第一性问题是写写冲突。两个事务同时改同一行必须有一个先后顺序否则最终结果没法收敛。写写冲突靠锁解决这没什么争议。真正让各家数据库拉开差距的是读写冲突。早期的数据库系统里读操作和写操作也是互斥的。你要读一行恰好有人在改这行就得等人家提交完你要改一行恰好有人在读也只能等着。这种串行化的代价在低并发时代无所谓到了互联网和交易系统时代就完全扛不住了——读多写少是常态不能让读被写拖死。MVCC 的思路就一句话读不阻塞写写不阻塞读。怎么做到写事务修改数据时不直接把旧数据覆盖掉而是保留历史版本读事务看一眼自己的“快照”判断哪些版本对自己可见。所有人各看各的版本自然就不用互相等待。这套机制的官方全称是 Multi-Version Concurrency Control多版本并发控制。它解决的核心问题就是让高并发场景下的读写操作尽量并行同时保证每个事务看到的数据是一致的、可预期的。1.2 MVCC关键分歧版本存哪、判断靠什么、清理怎么办虽然大家都在喊 MVCC但实现起来各有各的路数。我总结成三个分歧点后面所有细节都是围绕这三件事展开的。版本数据存在哪MySQL 把旧版本扔进 undo log通过回滚指针串成一条版本链PostgreSQL 直接把旧版本留在数据页里一条记录在页面上可能有多份物理拷贝Oracle 则是数据块里永远只放最新版本旧版本前镜像统一写到独立的 undo 表空间。可见性怎么判断MySQL 靠 ReadView也就是生成快照那一刻的活跃事务 ID 集合拿版本上的事务 ID 跟 ReadView 比对PostgreSQL 靠元组头的 xmin、xmax 加上事务提交状态日志 CLOG 来判断Oracle 靠查询起始时刻的 SCN需要旧版本时通过 undo 构造一致性读块。旧版本怎么清理MySQL 有后台 purge 线程异步回收PostgreSQL 靠 vacuum 扫数据页收集死元组Oracle 的 undo 段被新事务循环覆盖靠 undo_retention 参数控制保留时间。这三个分歧就是三家的真实面貌。MySQL 是“逻辑链 可见性快照”PostgreSQL 是“物理多版本 垃圾回收”Oracle 是“差分存储 按需回放”。同样叫 MVCC背后是完全不同的工程取舍。1.3 MVCC与事务隔离级别的关系MVCC 不是为所有隔离级别服务的。读未提交Read Uncommitted级别下事务直接读别人未提交的修改根本不需要版本判断所以 MySQL 和 PG 在 RU 级别都不启用 MVCC 逻辑。读已提交Read Committed和可重复读Repeatable Read才是 MVCC 的主战场。RC 级别要求每条语句只能看到语句开始前已提交的数据所以每执行一条 SELECT 都要重新取一个快照。RR 级别要求同一个事务里多次读取的结果保持一致所以快照必须在事务第一次读数据时生成并且整个事务期间复用。Oracle 的默认隔离级别是 READ COMMITTED但它有个特点每个语句都会自动获取一个语句级的一致性 SCN因此即使在 RC 下单条语句内读到的数据也是一致的。PG 默认同样是 RCRR 级别直接用快照就天然规避了幻读问题。MySQL 情况最特殊默认 RR但除了快照读之外还要配合 next-key lock 处理当前读的幻读问题。从这里就能看到MVCC 不是孤立的技术它和隔离级别、锁机制是一整套组合拳。只看 MVCC 不配合锁和隔离级别理解很容易被面试官带偏。2. MySQL InnoDB的MVCC实现细节与实操要点2.1 InnoDB三件套隐藏列、undo log与版本链InnoDB 的 MVCC 实现可以拆成三个组件聚簇索引记录里的隐藏列、undo log 回滚段、以及内存里的 ReadView。先看隐藏列。InnoDB 的聚簇索引每行记录都藏着三个隐藏字段DB_TRX_ID 表示最近一次修改或插入这行数据的事务 IDDB_ROLL_PTR 是回滚指针指向 undo log 里该行上一个版本的记录位置DB_ROW_ID 是当表没有主键时 InnoDB 自动生成的递增行号有主键的时候这个字段不占实际空间。每次 UPDATE 发生时InnoDB 不会直接覆盖旧值而是把修改前的行写入 undo log同时把数据页上这行的 DB_TRX_ID 更新成当前事务 IDDB_ROLL_PTR 指向刚写入的 undo 记录。这样一来从数据页当前版本出发沿着回滚指针往回走就形成了一条版本链。举个例子。事务 T1 插入一行 id1、namea。事务 T2 把它改成 nameb事务 T3 再改成 namec。此时数据页上显示 namec但它的 DB_ROLL_PTR 指向 T2 生成的 undo 记录 namebT2 的 undo 记录又指向 T1 的 insert undo 记录 namea。这条链就是 T3 → T2 → T1 的版本序列。读事务要根据自己的 ReadView 决定这条链上哪个版本对自己可见。undo log 还分两种类型。insert undo 是插入操作产生的因为插入的记录只被当前事务自己看见回滚和清理都很简单。update undo 是更新或删除操作产生的记录它的前一个版本可能被其他事务的查询需要所以必须保留到所有可能读它的快照都消失才能被 purge 清理掉。2.2 ReadView生成规则与可见性判断流程ReadView 是 InnoDB 判断可见性的核心数据结构。它由事务在生成快照的那一刻创建里面记录四个关键信息m_ids 是生成 ReadView 时当前系统中所有活跃未提交事务的 ID 列表min_trx_id 是活跃列表中最小的事务 IDmax_trx_id 是系统中下一个将要分配的事务 ID也就是当前最大事务 ID 加一creator_trx_id 是生成这个 ReadView 的事务自己的 ID。拿到版本链上某个版本之后判断它对当前事务是否可见规则可以压缩成三步第一步看版本上的事务 ID 是否等于 creator_trx_id。如果等于说明这个版本是当前事务自己改的当然可见。第二步如果版本事务 ID 小于 min_trx_id说明这个版本在快照生成前就已经提交了可见如果大于等于 max_trx_id说明这个版本是在快照生成之后才开启的事务产生的不可见。第三步如果事务 ID 落在 min_trx_id 和 max_trx_id 之间就查它是否在 m_ids 活跃列表里。不在列表里说明它已经提交可见在列表里说明它还没提交不可见。举一个实际场景。假设当前系统活跃事务只有 T2 和 T3T2 的 ID 是 10T3 的 ID 是 20。此时事务 T5ID 30执行第一次 SELECT生成的 ReadView 里 m_ids 就是 [10, 20]min_trx_id10max_trx_id21creator_trx_id30。如果版本链上有个版本的事务 ID 是 15这个事务既不在快照里也不是快照之后开启的说明它在快照生成前已经提交所以可见。如果有个版本的事务 ID 是 20它在 m_ids 里说明还没提交T5 看不到。2.3 RC与RR的差异、purge清理与长期事务隐患RC 和 RR 的差别本质上就是 ReadView 的生成时机不同。RC 级别下每条 SELECT 语句执行前都会重新生成一份 ReadView所以同一事务里两次 SELECT 可能因为其他事务提交而看到不同的数据。RR 级别下ReadView 只在事务第一次执行 SELECT 时生成一次之后整个事务都复用同一个快照所以同一个事务里无论读多少次看到的数据都是第一次读取时的一致视图。这里有个容易混淆的点。RR 只对快照读生效。如果你执行的是 SELECT ... FOR UPDATE、UPDATE、DELETE 这类当前读操作InnoDB 仍然会读取数据页上的最新版本并通过 next-key lock 防止幻读。这也是 MySQL 的 RR 和 PG 的 RR 在语义上最显著的差异MySQL 需要用锁补幻读的空子PG 靠纯快照就足够了。purge 线程负责清理版本链上那些已经没有任何活跃事务需要的旧版本。但要注意如果有一个长事务长时间不提交它的 ReadView 一直存活版本链上对应位置之后的旧版本就不能被 purge。长事务拖得越久undo log 累积越多查询时遍历版本链也会越慢。生产环境里我见过最典型的场景就是开发同学在 RR 模式下开一个大事务做批量更新然后人跑去开会回来一看磁盘暴涨、业务查询全面变慢。排查方式也很直接查 information_schema.innodb_trx 的 trx_started 和 trx_rows_modified基本一眼就能锁定问题事务。3. PostgreSQL的MVCC实现细节与实操要点3.1 页面内多版本xmin、xmax与元组关系PostgreSQL 的实现思路和 MySQL 完全不同。InnoDB 把旧版本搬到 undo log 里数据页上只留一份最新数据PG 索性把每个版本都保留在数据页内部一条逻辑记录在物理上可能同时存在多个元组。每个元组的头部都有一对系统列xmin 记录插入这个版本的事务 IDxmax 记录删除或更新这个版本的事务 ID。插入一条新数据时新元组的 xmin 被设置为当前事务 IDxmax 为空。执行 UPDATE 时PG 不会去修改原元组而是生成一条全新的元组新的 xmin 等于当前事务 ID同时把原元组的 xmax 设置为当前事务 ID表示旧版本从这一时刻起“名义上失效”。DELETE 也类似只是不会生成新元组直接给原元组盖上 xmax。这种设计的直接效果是更新越频繁页面里的残留元组越多。一个物理页面默认 8KB可容纳的元组数量是有限的旧版本不断堆积就会造成表膨胀bloat。所以 PG 必须配套 vacuum 机制来回收死元组这也是 PG 被大家吐槽“需要保姆”的核心原因。HOT 更新是 PG 在页内做的一个关键优化。如果 UPDATE 不涉及任何索引列PG 会在页面空闲空间允许的范围内把新版本直接链接到旧版本后面索引项继续指向旧元组即可。这样索引不需要维护新条目能明显减少索引膨胀。前提是建表时 fillfactor 参数要留出余量比如 70 到 90 之间的值否则页面塞满了无法做 HOT 更新。3.2 可见性判断与事务状态CLOGPG 判断元组可见性除了看 xmin/xmax还要知道这些事务最终提交了没有。提交状态信息记录在 CLOGCommit Log里事务提交时在 CLOG 对应位点上打标。为了避免每次判断都去磁盘查PG 会把 CLOG 常驻共享内存只有内存不足时才刷盘。快照记录当前所有活跃事务 ID 列表再加上元组头的 xmin、xmax就能推出几条基本结论如果 xmin 对应的事务还没提交那这个版本的改动对别人不可见如果 xmin 已提交且事务 ID 在快照之前版本可见如果 xmax 对应的事务已提交说明版本已被删除或更新当前快照下不可见。这整套判断融合了事务 ID 范围比较和 CLOG 状态检查和 MySQL 的 ReadView 思路神似但数据载体完全不一样。顺带说一个 PG 的快照模型带来的好处可重复读级别下因为每个事务只认自己第一次拿到的快照根本不存在 MySQL 那种当前读读到新版本然后产生幻读的可能。PG 的 RR 不需要间隙锁。这也是很多从 MySQL 转 PG 的人第一天就感受到的差异——同样的 RR 事务PG 里几乎不会出现死锁。3.3 vacuum与autovacuum、HOT与表膨胀vacuum 要做的事情有两件回收死元组占用的空间更新统计信息和可见性映射。autovacuum 在后台自动运行触发条件分别是表里死元组数量超过阈值以及更新量达到比例。默认阈值是 50 行比例是 20%对一张一亿行的大表来说意味着要攒到两千万死元组才触发一次明显太迟钝。PG 13 之后可以把 autovacuum_vacuum_scale_factor 调小或者干脆对特定大表单独设置。比如一张频繁更新的热表建议把 scale_factor 调到 0.01 甚至更低让 autovacuum 更勤快一些。另一个重要工具是 vacuum freeze用来推进事务 ID 回卷保护长时间不跑的话会触发强制冻结对刚接手 PG 集群的人来说绝对是个惊吓。表膨胀到一定程度就不是 vacuum 能处理的了。vacuum 只能回收页内空闲空间不能把那些零散的死元组占用的页归还给操作系统。真要让表瘦下来要么 VACUUM FULL 重建表并获取排他锁要么用 pg_repack 在线重建。pg_repack 在空间不足的场景下不能随便用因为重建过程需要额外一倍的表空间这点非常容易踩坑。4. Oracle的MVCC实现细节与实操要点4.1 Undo段与最新版本分离的设计Oracle 走的是一条更“工程化”的路。数据块里只保留行的最新版本修改前的镜像统一写入独立的 undo 段日常查询几乎不感知历史版本。这个设计让数据扫描路径非常干净——去数据块拿数据就行不用像 PG 那样在页面里翻找多个元组。具体到一次 UPDATE 的过程事务修改数据块里的行时会先在块头部的 ITLinterested transaction list事务槽里登记事务 ID、undo 段地址和事务状态。接着把修改前的镜像写入当前事务关联的 undo 段。数据块上的行没有 DB_ROLL_PTR 那样的回滚指针但 ITL 里记录的 undo 地址足以让 Oracle 在需要时找回旧版本。提交事务后ITL 中的事务状态会被标记为已提交锁也随之释放。Oracle 从 9i 开始的自动 undo 管理模式把回滚段抽象成了 undo 表空间DBA 不需要手工管理回滚段。日常维护只需要关心两个问题undo 表空间大小够不够undo_retention 参数设得合不合理。默认的 undo_retention 是 900 秒超过这个时间的已提交 undo 可以被新事务覆盖。4.2 一致性读与CR块构造Oracle 的一致性读逻辑很有意思。SELECT 语句开始执行时会获取一个查询起点的 SCN。扫描数据块的过程中如果发现某行被修改过Oracle 会检查修改它的事务是否已提交如果已提交且提交 SCN 早于查询 SCN直接用数据块里的当前版本就行如果事务未提交、或者提交 SCN 晚于查询 SCN说明当前版本对本次查询“太新了”不能直接使用。怎么拿到旧版本这就是 CR 块的用途。Oracle 会根据 ITL 中记录的 undo 地址从 undo 段读取前镜像在内存里把数据块回滚到查询起点那一刻的形态构造出一个一致性读块。这个 CR 块是临时构造的用完之后就可以释放。由于块里的数据是从最新状态反向回放出来的这个过程也叫 CR 构造。这就是为什么 Oracle 能在默认的 READ COMMITTED 级别下提供语句级一致性读。单个语句的执行过程中哪怕其他事务不断提交新数据这条语句看到的始终是语句开始时的那份数据。它不用像 MySQL 那样维护版本链也不用像 PG 那样在页内堆旧版本它把多版本问题转换成了一个“按需回放”的问题代价是 CR 块的构造需要消耗 CPU 和内存undo 段里的前镜像一旦被覆盖就再也构造不出来了。4.3 隔离级别、闪回查询与落后的坑ORA-01555 就是这个设计下最有名的坑全称叫 snapshot too old。触发原因很简单某个查询需要构造 CR 块但需要用到的 undo 前镜像已经被新事务覆盖了。出现这种错误的场景通常是一个运行了很久的长查询或者一个大事务的回滚过程期间系统更新压力很大把早先的 undo 记录顶掉了。加大 undo 表空间、调大 undo_retention、优化长 SQL是三个最常用的应对手段。Oracle 的隔离级别矩阵和 MySQL、PG 也不一样。默认 READ COMMITTED 只保证语句级一致性SERIALIZABLE 级别下查询使用的快照在事务第一条语句开始时固定类似 PG 的 RR。Oracle 没有单独的 REPEATABLE READ 级别。FLASHBACK QUERY 功能算是 undo 的另一个应用场景——利用 undo 段里的前镜像直接查询过去某个时间点的数据。要做到这一点undo_retention 必须大于你希望回溯的时间范围。生产环境里监控 undo 表空间最直接的是查 v$undostat 里的 undoblks 和 maxquerylen它们能反映单位时间内 undo 生成量和最老活跃查询的执行时长。dba_undo_extents 可以看 undo 段的扩展情况。顺手把 undo 表空间设置成 autoextend 固然省心但必须给数据库文件所在的磁盘容量留够冗余否则文件涨满之后整个实例会直接卡住这个教训我在不少客户现场都见过。5. 三大数据库MVCC核心对比5.1 核心维度对照表聊到这里三家方案的骨架都清楚了。我把它们最关键的区别整理成一张对比表方便你日常查阅和面试前速记。对比维度MySQLInnoDBPostgreSQLOracle旧版本存放位置undo log回滚段逻辑链式存储数据页内部物理多版本元组undo段存储前镜像最新版本位置聚簇索引数据页当前记录数据页内最新元组数据块内当前行版本追踪方式DB_ROLL_PTR 回滚指针串成版本链元组头 xmin / xmax 记录事务块头 ITL 记录事务与 undo 地址可见性判断核心ReadView活跃事务 ID 快照快照 CLOG 提交状态查询起点 SCN 对块内事务状态检查旧版本清理机制purge 线程异步回收不可见版本vacuum / autovacuum 回收死元组undo 循环复用由 undo_retention 控制索引与多版本关系聚簇索引带隐藏列二级索引需回表判断索引指向元组 tidHOT 更新可避免索引变化索引含 rowidCR 构造时用 rowid undo 回放RR 级别幻读处理快照读靠 MVCC当前读靠 next-key lock快照隔离天然无幻读无需额外锁查询 SCN 天然一致SERIALIZABLE 下额外加锁典型生产风险长事务拖垮 undo、版本链过长表膨胀、autovacuum 跟不上更新节奏ORA-01555、undo 表空间耗尽、闪回不可用5.2 一句话总结三家方案与设计取舍我给三家各自总结一句话。MySQL 是“改一行留一条链”把新版本放前面、旧版本通过 undo 串在链上每次读就在链上按 ReadView 规则找合适的一环。PostgreSQL 是“新旧版本同居一室”更新就是插入旧版本原地站着等 vacuum 来收尸。Oracle 是“新版上桌旧版入库”数据块里永远是最新值旧版本全部退回 undo 区读的时候按需把旧版本捏回来。这三套方案没有绝对优劣只有合不合适。MySQL 的版本链设计让当前读非常快但长事务会让链越来越长。PG 的页内多版本让读和写天然隔离代价是空间放大和 vacuum 的持续开销。Oracle 的数据块保持干净查询路径最短但一致性读对 undo 的依赖很重undo 一旦覆盖就会出错。从工程历史上看这三个设计都对应了各自产品的核心诉求InnoDB 是插件式存储引擎要兼顾多种引擎的隔离性PG 追求功能的完整性和学术上的优雅Oracle 则最早走向 undo 分离模式为后来的闪回等功能打下了基础。5.3 隔离级别语义差异和实际影响隔离级别的命名虽然一样但语义有实质差异。MySQL 的 RR 是“快照读 当前读锁保护”RR 下如果应用大量使用 SELECT ... FOR UPDATE锁范围可能比 PG 大不少。PG 的 RR 是“纯快照隔离”SQL 层面几乎不用为了防幻读加锁并发读特别稳。Oracle 没有真正的 RR只有 READ COMMITTED 和 SERIALIZABLE但它的 RC 自带语句级一致性日常业务体验已经很好。实际影响最明显的一个案例是多会话批量更新。MySQL 在 RR 下因为 next-key lock 很容易出现死锁和锁等待PG 一般不会Oracle 则更依赖应用设计是否合理。搭建新系统时如果预期并发读多写少且对一致性要求高PG 的 MVCC 模型能省掉很多锁相关的烦恼。如果团队更熟悉 MySQL 生态愿意接受锁和隔离级别带来的约束MySQL 也完全够用。Oracle 在传统金融、政企系统里长期可靠强一致性、闪回、成熟运维手段是它的护城河。6. 生产环境总结与避坑经验6.1 三个数据库常见MVCC风险排查MVCC 相关的问题一旦爆发往往都是慢查询、锁等待、空间暴涨这些高杀伤力症状。我从三个数据库分别挑一个高频排查场景给出一套可落地的检查方法。MySQL 优先查长事务。登录之后执行 select * from information_schema.innodb_trx where trx_stateRUNNING重点看 trx_started 字段。事务运行时间超过几十分钟甚至数小时的必须立刻找开发确认能不能提交或回滚。同时看一下 undo 表空间大小如果几小时内翻倍基本就是批量更新加长事务造成的版本链堆积。考虑调低隔离级别到 READ COMMITTED在业务允许的情况下能大幅减少间隙锁和 undo 累积。PG 优先查当前活跃事务最老的快照。pg_stat_activity 里的 backend_xmin 字段反映了后端进程持有的最老事务快照这个数值越大autovacuum 能回收的垃圾越少。用 select * from pg_stat_user_tables where n_dead_tup 100000 可以快速找出死元组堆积严重的表。针对热点大表建议单独设置 autovacuum_vacuum_scale_factor 为 0.01并适当缩短 autovacuum_naptime。膨胀已经特别明显时再考虑 pg_repack。Oracle 优先查 undo 使用情况和最老查询。v$undostat 的 maxquerylen 超过 undo_retention 时ORA-01555 风险急剧升高。dba_undo_extents 能看到当前 undo 表空间扩展到多大如果接近上限就排查是否有超长查询或批量任务。对核心系统来说建议把 undo_retention 设置到至少 1800 秒并且给 undo 表空间保留 20% 以上的冗余空间别卡在临界值上。6.2 场景选型建议如果让我给三个数据库选适用场景我的判断是这样的。MySQL 最适合高并发 OLTP、互联网业务、团队规模大且依赖丰富运维工具的团队它的 MVCC 简单直接配合主从架构用得非常顺。PostgreSQL 最适合复杂查询、HTAP、GIS、数据分析和高一致性场景纯快照模型让并发读非常舒服vacuum 的问题可以通过参数调优和定期维护解决。Oracle 更适合传统企业核心系统、金融账务、超大单实例复杂业务它的一致性读和闪回能力仍有不可替代的价值但运维门槛和授权成本也最高。方案没有高下之分关键是你的业务形态更吃哪套模型的优势。6.3 我实际踩过的坑与给读者的建议最后分享几个我自己在线上环境里踩过的真实坑。第一次是 MySQL 的 RR 模式下一个定时任务锁定了两万行数据事务没提交就去调用外部接口结果外部接口超时事务又一直不回滚。undo 表空间半小时内涨了几十 GB业务读全链路变慢。后来我在监控里加了一个规则对 trx_started 超过 10 分钟的事务直接告警并在应用层强制给这类批量任务设置事务超时时间。第二次是 PG 的一张订单表每天 200 多万次更新建的初始 fillfactor 是默认的 100导致 HOT 更新几乎失效索引膨胀到表的四倍查询计划器经常估算错成本。后来重建了索引并设 fillfactor90配合把该表的 autovacuum_vacuum_scale_factor 调到 0.01整个系统才稳定下来。第三次是 Oracle 一个老系统有人为了做闪回查询把 undo_retention 调到了 12 小时结果 undo 表空间不断暴涨还顶到了磁盘上限。实际上那个业务每天最多回看两小时数据把 retention 改成 7200 秒然后加了两块扩展盘问题就解决了。根据我个人经验MVCC 相关排障最核心的一条原则是永远先看长事务。不论 MySQL 的 undo、PG 的 vacuum、还是 Oracle 的 CR 构造最后的瓶颈几乎都会落在某个人为拖长的持有快照上。排查时先把最老事务抓出来再去看空间和性能指标方向基本不会错。还有个实用小技巧把三家数据库的当前最长事务查询语句做成一个固定的巡检脚本每天定时跑一次很多集群问题就能在爆发前被发现。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑