InnoDB存储引擎对MVCC的实现
MVCC 让 InnoDB 的普通 SELECT 读历史快照而不加锁,读写从此互不阻塞,它是 InnoDB 高并发能力的基石。它的实现就三根支柱:行上的隐藏字段、undo log 串起的版本链、以及决定”哪个版本可见”的 ReadView——看懂可见性算法的逐步推演,可重复读就不再是黑盒。
它解决什么问题
如果并发控制只有行锁,那么读和写会互相排队:事务 A 更新了某行未提交,事务 B 想读它就得干等,吞吐随之崩塌。MVCC(多版本并发控制)换了个思路——每行数据保留多个历史版本,读操作不碰最新版本,而是沿着版本链找到”自己该看见”的那个旧版本。读不加锁、写不阻塞读,只有写与写之间才正面冲突,这是 InnoDB 高并发的根基。
核心原理:三大件
隐藏字段。InnoDB 给每行悄悄加了三个字段:DB_TRX_ID(6 字节,最近插入或更新本行的事务 ID,DELETE 在内部也算一次更新,只是在记录头打上 deleted 标记)、DB_ROLL_PTR(7 字节,指向上一个版本的回滚指针)、DB_ROW_ID(6 字节,表既没有主键也没有非空唯一索引时,用它生成聚簇索引)。
undo log 版本链。事务每次 UPDATE,InnoDB 都原地写入新值,把旧值连同旧 DB_TRX_ID 拷进 undo log,并让新版本的 DB_ROLL_PTR 指向这条旧记录。同一行被反复修改,undo log 就串成一条链:链首是最新版本,链尾是最老版本。insert undo log 只对事务自己有意义,提交后即可丢弃;update undo log 可能要为别的事务提供快照,须等 purge 线程确认无人引用后再清理。
ReadView。一致性读那一刻拍下的”事务全景”,四个要素:
m_ids:生成时所有未提交的活跃事务 ID(不含自己);m_up_limit_id:m_ids中的最小值(低水位);m_low_limit_id:下一个待分配的事务 ID,即”目前最大事务 ID + 1”(高水位);m_creator_trx_id:创建本 ReadView 的事务自己的 ID。
深入机制:可见性判断逐步推演
拿到一行数据的某个版本,用它的 DB_TRX_ID(记为 t)对照 ReadView 四步走:
t = m_creator_trx_id:自己改的,可见;t < m_up_limit_id:修改它的事务在快照生成前早已提交,可见;t >= m_low_limit_id:快照生成之后才出现的事务改的,不可见;- 落在两水位之间:去
m_ids里二分查找——在列表里说明快照生成时它还没提交,不可见;不在则说明已提交,可见。
若当前版本不可见,就沿 DB_ROLL_PTR 取上一个版本,回到第 1 步重判,直到找到可见版本或版本链耗尽(说明这行对本事务而言尚不存在)。
推演一遍。行初始版本 DB_TRX_ID = 90,系统中有事务 101、102 先后修改该行但未提交。此刻事务 103 在 RR 下执行第一次 SELECT,生成 ReadView:m_ids = [101, 102],低水位 101,高水位 104。
- 链首版本
t = 101:落在 [101, 104) 之间且在m_ids中,不可见; - 沿版本链退一步,
t = 90 < 101,可见——103 读到的是事务 90 留下的旧值。
随后 101、102 相继提交并继续修改,103 再次 SELECT:RR 下沿用旧 ReadView,m_ids 仍是 [101, 102],101、102 产生的版本依旧不可见,103 读到的还是那份旧值——这就是可重复读的由来。换成 RC,每次 SELECT 都重新生成 ReadView,101 提交后第二次读时它已不在活跃列表里,103 就能看到 101 写入的新值——同一事务两次读结果不同,即不可重复读。
RC 与 RR 的分水岭:ReadView 生成时机
差异只有一处,却决定了整个行为:
- RC:每条一致性读语句执行前都生成新的 ReadView,因此总能看到”这条语句开始前”已提交的最新数据;
- RR:事务内第一次一致性读时生成 ReadView,之后整个事务期沿用同一份,因此看到的是”事务第一张快照时”的世界。
所以 RR 的可重复读和防快照幻读,本质不是”数据没变”,而是”看数据的眼镜一直没换”。
工程实践:快照读、当前读与版本链的代价
-- 快照读:普通 SELECT,走 ReadView,不加锁 |
当前读(含 INSERT/UPDATE/DELETE)永远读最新已提交版本并加锁,RR 下范围当前读还会叠加 Next-Key Lock 防插入。两条纪律要记牢:其一,MVCC 不是无锁,写写冲突照样排队;其二,同一事务里先快照读再当前读,两次结果可能不同,这是读语义切换而非幻读漏洞。
长事务是 MVCC 的天敌:ReadView 钉住不放,所有晚于它产生的 undo 版本都无法 purge,History list length 持续上涨,表现为表空间膨胀、版本链变长、每次一致性读要回溯更多版本。监控 information_schema.innodb_trx 里的长事务,配合短事务原则,比任何参数调优都管用。另外,无意义的全字段 UPDATE 会白白拉长版本链,业务上只更新真正变化的列。
顺带解释一个常见疑惑:为什么大批量 DELETE 之后表文件没有立刻变小?因为 DELETE 只是给记录打上删除标记并生成一个版本,真正的物理清理由 purge 线程在确认没有任何 ReadView 需要这些旧版本之后才会执行;即便清理完成,腾出的页空间也优先留给后续插入复用,而不是还给操作系统。所以 purge 追不上时,”删了数据空间反而涨”并不矛盾。
对比与权衡
| 维度 | 快照读 | 当前读 |
|---|---|---|
| 代表语句 | 普通 SELECT | FOR UPDATE / LOCK IN SHARE MODE / 写操作 |
| 读的版本 | ReadView 可见的历史版本 | 最新已提交版本 |
| 是否加锁 | 不加锁 | 加行锁,RR 下范围读叠加 Next-Key Lock |
| 性能特征 | 读写互不阻塞 | 受锁竞争影响,可能等待 |
面试视角
Q1:MVCC 靠什么实现? 三个部件:行上的 DB_TRX_ID 与 DB_ROLL_PTR 隐藏字段标记版本身份并串链,undo log 保存历史版本,ReadView 在一致性读时圈定可见性边界。
Q2:口述可见性判断算法。 等于自己改的可见;小于低水位说明快照前已提交,可见;大于等于高水位说明快照后才出现,不可见;落在两水位之间查活跃事务列表,在则不可见、不在则可见;不可见就沿回滚指针找上一版本重判。
Q3:RC 和 RR 在 MVCC 上差在哪? ReadView 生成时机:RC 每条一致性读重建,RR 事务内首次一致性读建立后沿用。所以 RC 有不可重复读,RR 没有。
Q4:RR 能完全避免幻读吗? 快照读层面能,因为快照固定后新插入的版本不可见;但当前读读的是最新版本,防插入要靠 Next-Key Lock。先快照读后当前读的同事务混用,仍可能看到”多出来的行”。
Q5:MVCC 是不是无锁并发? 不是。它消除的是读写之间的锁竞争,写与写仍然靠行锁互斥,当前读也会加锁。
小结
MVCC 的优雅在于用空间换并发:隐藏字段给每个版本盖章,undo log 把历史串成链,ReadView 用一次快照划定可见性的疆界。记住推演口诀”自己改的可见、低水位之前可见、高水位之后不可见、中间查活跃表”,再记住 RC 每次读换眼镜、RR 一副眼镜戴到底,InnoDB 的隔离性实现就尽在掌握了。

