前几天一个后端同学私信我,说他们的 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 默认同样是 RC,RR 级别直接用快照就天然规避了幻读问题。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 表示最近一次修改或插入这行数据的事务 ID;DB_ROLL_PTR 是回滚指针,指向 undo log 里该行上一个版本的记录位置;DB_ROW_ID 是当表没有主键时 InnoDB 自动生成的递增行号,有主键的时候这个字段不占实际空间。每次 UPDATE 发生时,InnoDB 不会直接覆盖旧值,而是把修改前的行写入 undo log,同时把数据页上这行的 DB_TRX_ID 更新成当前事务 ID,DB_ROLL_PTR 指向刚写入的 undo 记录。这样一来,从数据页当前版本出发,沿着回滚指针往回走,就形成了一条版本链。
举个例子。事务 T1 插入一行 id=1、name='a'。事务 T2 把它改成 name='b',事务 T3 再改成 name='c'。此时数据页上显示 name='c',但它的 DB_ROLL_PTR 指向 T2 生成的 undo 记录 name='b',T2 的 undo 记录又指向 T1 的 insert undo 记录 name='a'。这条链就是 T3 → T2 → T1 的版本序列。读事务要根据自己的 ReadView 决定这条链上哪个版本对自己可见。
undo log 还分两种类型。insert undo 是插入操作产生的,因为插入的记录只被当前事务自己看见,回滚和清理都很简单。update undo 是更新或删除操作产生的记录,它的前一个版本可能被其他事务的查询需要,所以必须保留到所有可能读它的快照都消失,才能被 purge 清理掉。
2.2 ReadView生成规则与可见性判断流程
ReadView 是 InnoDB 判断可见性的核心数据结构。它由事务在生成快照的那一刻创建,里面记录四个关键信息:
m_ids 是生成 ReadView 时当前系统中所有活跃(未提交)事务的 ID 列表;min_trx_id 是活跃列表中最小的事务 ID;max_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 和 T3,T2 的 ID 是 10,T3 的 ID 是 20。此时事务 T5(ID 30)执行第一次 SELECT,生成的 ReadView 里 m_ids 就是 [10, 20],min_trx_id=10,max_trx_id=21,creator_trx_id=30。如果版本链上有个版本的事务 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 记录插入这个版本的事务 ID,xmax 记录删除或更新这个版本的事务 ID。插入一条新数据时,新元组的 xmin 被设置为当前事务 ID,xmax 为空。执行 UPDATE 时,PG 不会去修改原元组,而是生成一条全新的元组,新的 xmin 等于当前事务 ID,同时把原元组的 xmax 设置为当前事务 ID,表示旧版本从这一时刻起“名义上失效”。DELETE 也类似,只是不会生成新元组,直接给原元组盖上 xmax。
这种设计的直接效果是:更新越频繁,页面里的残留元组越多。一个物理页面默认 8KB,可容纳的元组数量是有限的,旧版本不断堆积就会造成表膨胀(bloat)。所以 PG 必须配套 vacuum 机制来回收死元组,这也是 PG 被大家吐槽“需要保姆”的核心原因。
HOT 更新是 PG 在页内做的一个关键优化。如果 UPDATE 不涉及任何索引列,PG 会在页面空闲空间允许的范围内,把新版本直接链接到旧版本后面,索引项继续指向旧元组即可。这样索引不需要维护新条目,能明显减少索引膨胀。前提是建表时 fillfactor 参数要留出余量,比如 70 到 90 之间的值,否则页面塞满了,无法做 HOT 更新。
3.2 可见性判断与事务状态(CLOG)
PG 判断元组可见性,除了看 xmin/xmax,还要知道这些事务最终提交了没有。提交状态信息记录在 CLOG(Commit 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 的过程:事务修改数据块里的行时,会先在块头部的 ITL(interested 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 核心维度对照表
聊到这里,三家方案的骨架都清楚了。我把它们最关键的区别整理成一张对比表,方便你日常查阅和面试前速记。
| 对比维度 | MySQL(InnoDB) | PostgreSQL | Oracle |
|---|---|---|---|
| 旧版本存放位置 | undo log(回滚段),逻辑链式存储 | 数据页内部,物理多版本元组 | undo段,存储前镜像 |
| 最新版本位置 | 聚簇索引数据页当前记录 | 数据页内最新元组 | 数据块内当前行 |
| 版本追踪方式 | DB_ROLL_PTR 回滚指针串成版本链 | 元组头 xmin / xmax 记录事务 | 块头 ITL 记录事务与 undo 地址 |
| 可见性判断核心 | ReadView(活跃事务 ID 快照) | 快照 + CLOG 提交状态 | 查询起点 SCN + 对块内事务状态检查 |
| 旧版本清理机制 | purge 线程异步回收不可见版本 | vacuum / autovacuum 回收死元组 | undo 循环复用,由 undo_retention 控制 |
| 索引与多版本关系 | 聚簇索引带隐藏列,二级索引需回表判断 | 索引指向元组 tid,HOT 更新可避免索引变化 | 索引含 rowid,CR 构造时用 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_state='RUNNING',重点看 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 更新几乎失效,索引膨胀到表的四倍,查询计划器经常估算错成本。后来重建了索引并设 fillfactor=90,配合把该表的 autovacuum_vacuum_scale_factor 调到 0.01,整个系统才稳定下来。
第三次是 Oracle 一个老系统,有人为了做闪回查询把 undo_retention 调到了 12 小时,结果 undo 表空间不断暴涨还顶到了磁盘上限。实际上那个业务每天最多回看两小时数据,把 retention 改成 7200 秒然后加了两块扩展盘,问题就解决了。
根据我个人经验,MVCC 相关排障最核心的一条原则是:永远先看长事务。不论 MySQL 的 undo、PG 的 vacuum、还是 Oracle 的 CR 构造,最后的瓶颈几乎都会落在某个人为拖长的持有快照上。排查时先把最老事务抓出来,再去看空间和性能指标,方向基本不会错。还有个实用小技巧,把三家数据库的当前最长事务查询语句做成一个固定的巡检脚本,每天定时跑一次,很多集群问题就能在爆发前被发现。