MYSQL面试八股文-InnoDB的MVCC实现机制
背景
什么是MVCC?
MVCC,全称Multi-Version Concurrency Control,即多版本并发控制。它是一种在数据库管理系统中实现并发控制的方法,用于处理读写冲突,从而提高数据库的并发访问性能。
特别要注意MVCC只在 读已提交(RC) 和 可重复读(RR) 这两种事务隔离级别下才有效。
它是数据库引擎(InnoDB)层面实现的,用来在不加排他锁或共享锁的情况下处理读操作与写操作并行时的冲突机制。
有锁了为什么需要MVCC?
MySQL中同时使用锁和MVCC是为了实现更高效的并发访问并兼顾严谨的事务隔离性。
锁是一种常见的并发控制机制,它可以确保事务之间的数据访问互斥,避免数据的读写冲突。当一个事务对数据进行修改时,会获取相应的锁,其他并发事务在这个锁释放前将无法访问或修改该数据,只能排队等待。
然而,单纯依靠锁机制存在着严重的并发瓶颈。锁会导致并行的事务退化为串行执行;此外,锁如果粒度较大,发生锁冲突的频率会急剧增加,从而严重限制系统的性能和数据吞吐量。
为了解决锁带来的这些性能瓶颈,MySQL引入了基于数据版本链记录的MVCC策略。
当事务需要读取某行数据时,MVCC允许该事务去读取自己启动或自身查询语句执行时那刻可见的历史版本(快照),而不用受到其他事务正在对该数据修改而独占锁的影响。这就巧妙地跳过了锁等待,实现了“读不阻塞写,写不阻塞读”,大幅提高了并发性能。
同时,MVCC支持更高级别的事务隔离特性。在MVCC机制下,每个事务都可以安全地读取到属于自己逻辑视角的确定版本,数据表现完全前后一致,解决了脏读、不可重复读等隔离失效场景。
因此,MySQL组合使用锁和MVCC可以扬长避短。锁继续用于保障对并发写操作的隔离以及强一致性的“当前读”,而MVCC专门解决大量普通的查询操作(快照读),二者有机结合打造了强大且高效的数据库引擎底座。
什么场景和条件下使用MVCC?
MVCC只在 读已提交(RC) 和 可重复读(RR) 这两种事务隔离级别下才有效。
高并发读取:当系统需要支持大量并发并发读取且伴有少部分写的系统时,MVCC能保障读取操作不受更新锁定的影响,并发性能极高。
长事务支持:在批处理等长事务执行大量查询和分析期间,长连接事务在MVCC机制下只会访问该时刻下自己应看到的数据快照版本。避免了传统的长事务导致行记录大面积锁定,造成后来者阻塞的困境。
乐观并发控制:MVCC属于一种无锁乐观并发控制方式,它不主动封锁相关资源记录。系统并发冲突相对较少时,MVCC能完全消除不必要的加锁与锁等待开销及死锁排查。
高事务隔离级别要求:MVCC完美提供对“可重复读(RR)”的支撑。每个独立的事务自第一次查询时就开始使用一致的数据视图隔离判断,不会受到其他已经提交的事务所带来的脏数据干扰或中途更新,从而避免了不可重复读和脏读。
使用MVCC能带来什么好处?
多版本并发控制(MVCC)解决并发读-写冲突的方案,是为每次记录修改生成一个旧版本副本记录,并将该记录版本与更新它的事务ID相互关联。随后的读操作只寻找该事务当前时间线内可见的某个数据库快照副本。
所以在并发读写数据库时,读操作不用排队等待写锁,写操作正常进行写锁排他且不用理会读操作请求。此举提升了整个并发读写的吞吐和体验;更同时顺利避免了脏读、不可重复读等事务并发隐患(但需要注意这不可用于解决应用层的业务“更新丢失”互相覆盖并发)。
什么是当前读和快照读?
当前读
像select … lock in share mode(共享锁), select … for update(排他锁), update, insert, delete这些操作都是一种当前读。
为什么叫当前读?
就是因为它读取的是记录的当前时间线上的“唯一最新”版本数据,而且在它读取及操作期间还需要显式加锁保障排他性,不允许后续并发的事务打断修改过程。
快照读
没有加锁的普通大白话 select就是快照读,也就是非阻塞读。
快照读适用的前提就是必须是在RR或RC下。如果在最高隔离级别Serializable(串行化)模式下,快照读也会被强制退化并转化为排他的当前读。
快照读引入的唯一思考点就是想最大程度减小加锁带来的互斥开销。可以说MVCC是变向削减行互斥锁数量以换取高并发读的“神仙”设计。由于是追根版本进行查找读取,因此快照读读出来的“可能并不是物理行当前刻的绝对最新版本数据”,极有可能是它生成那一刻对应留存下来的某历史快照副本。
当前读,快照读和MVCC的关系
准确地说,MVCC多版本并发控制的核心思想即 “维持一个数据的多个版本,使得读写操作分别独立并避免绝对冲突” 这个抽象概念。
而在MySQL InnoDB 落地时,MVCC概念依靠各种引擎组件功能支持搭建。而快照读,就是MySQL中用于为纯粹查询操作落地MVCC的“无阻塞读取”接口形态。
想更透彻地分析它的全貌,就要知道快照读的无锁实现,源于InnoDB记录内部的3个隐式字段,加上专门构建的历史版本链undo log,以及每次快照读进行历史比对的校验尺 Read View(读视图)。
由于它完美解决查询阻塞问题,而剩余的悲观锁设计就完全负责去处理“必定会有写写冲突”的需求方向,这也就形成了组合拳。
小小结
MVCC的出发点是不局限于死磕性能不佳的悲观锁(排他锁共享锁)形式去粗暴解决一切读写冲突,而提出的更加细分、高明的新式解决方案。正是因为有了MVCC分担了大部分压力,使得:
MVCC统揽大量的读请求解决读写冲突场景,悲观锁则去负责强硬解决写写冲突;或者MVCC负责读写冲突,应用侧辅助实现乐观锁(版本号校验等)去解决写写冲突。
从而发挥出了兼顾稳定性与极限并发能力的数据库优势。
MVCC实现原理
在数据库引擎中实现MVCC这套复杂的体系,它的核心构件可以浓缩为:3个隐式字段,undo log版本链 以及 Read View。
隐式字段
除了你在表结构定义的各项列字段外,InnoDB行存储还会附带以下隐藏的核心字段:
DB_ROW_ID (6byte) 隐含的自增ID(隐藏主键):如果你的数据表既没有设置主键,也没有哪怕一个非空的唯一索引(unique),那么InnoDB会偷偷默认用它来生成隐藏主键和聚集索引;
DB_TRX_ID (6byte) 最近修改相关事务ID:记录最后一次修改该条记录行的事务ID;只要发生insert,update,delete就会分配出唯一ID并将之作为自增标识填写进这个隐式字段;
DB_ROLL_PTR (7byte) 回滚指针:这是非常关键的核心,它负责指向由于这次修改被踢下阵来的那个在 undo log 中的上一个“旧历史版本”数据;
DELETED_BIT (1byte) 删除标记位(部分资料拆分但其实依附于记录头):标记这条数据被删除,而非在磁盘B+树中马上直接清除它的空间。

如上图,DB_ROW_ID可有可无,而 DB_TRX_ID 及 DB_ROLL_PTR 回滚指针是必定存在的。
undo log(版本链)
版本链实际上就是一条内存+磁盘上的链表,存放着从此刻新状态不断延申往下的历史旧记录。
查询操作(SELECT)纯读不留痕,所以快照读是不会去生成任何新的 undo log 的。
undo log的粗略划分一般是:
Insert undo log :插入的时候被记录产生,因为只用于本事务出现错误或手动Rollback的回退使用(只需要根据ID重新删除),所以一旦该执行事务提交完成(Commit),这些 log 可以马上丢弃释放。
Update undo log :由执行update,delete生成。不仅要支持错误回滚,还需要留作为了构建MVCC支持的各个不同查询的Read View呈现快照副本支持。它不能立马随便丢掉,只有等系统中所有可能涉及去浏览这个快照的事务全死了跑完了,有一个专门负责垃圾回收的 purge 线程来定期把它释放清除。
(这里再提一下,删除操作并不是把记录切西瓜式抹去,而是一种特殊update,将老记录旧副本放进undo日志形成新节点,然后当前记录DELETED_BIT改为true等待将来purge掉)
所以对MVCC起到真正维持历史链条作用的基底实际上都是 update undo log。通过rollback segment 回滚段中的排列展示它的流程:
1. 我们预先在user_info表插入一条新记录,记录如下:name=”张三”, gender=”1″,此时作为最新记录,这行的DB_TRX_ID就是分配的事务ID号,假设回滚指针 DB_ROLL_PTR 目前空置无指引或者指向空事务。
2. 然后一个正在运行的事务(假定ID是事务1)盯上了并开启排他锁打算更改name为“张麻子”。
- 获取该行的行写锁;
- 将当前行的数据全部状态原模原样备份复制拷贝到 undo log区域存下来,这形成了一个等待回顾的备份历史。
- 把线上真实的记录name改掉成为“张麻子”,同时 DB_TRX_ID 更新填入这次产生修改的作者“事务1”,最要命的是将隐含的 DB_ROLL_PTR 指针勾连指向刚才被你备份在undo log的那行旧数据!
- 写完提交完事,释放排他锁;
3. 这个时候没消停,一个新事务(假定ID事务2)又来把性别gender从“1”改成了“0”。
- 获取行写锁并拿捏行数据。
- 还是老套路,把这行“张麻子+1”的状态整个拷贝进undo log。发现之前事务1已经在这留了脚印形成过备份对吧?不要紧,把它作为一个新出炉的最热历史旧数据加在最前面做表头,刚才事务1的旧数据被挤在它后面形成串连。
- 把真实数据的 gender 修改成 “0”,DB_TRX_ID 标上大名“事务2”,DB_ROLL_PTR 回滚指针指向了刚刚你塞到undo log作表头那个包含“张麻子”和之前状态的新副本。
- 最终提交解锁收工。
所以就形成了一个套娃,所有被修改过数据的最新那行物理数据作为线头,它的隐式的回滚指针往下拽出去了一个越走越远,越看越古老的历史长链表,这就是 undo log版本链。

Read View(读视图)
Read View 即事务进行快照读生成的判断视窗尺(数据强一致性快照截取范围)。
它是专门拿来做可见性比对标尺的。你一个拿着快照读需求进来的查询,要给你挑哪一个历史版本展示?全依赖这个时候系统把目前还在活蹦乱跳尚未提交的各种活跃事务搜刮出来打一张列表清单 m_ids。
它拿着这个清单属性,去找 undo log 这个版本链表各个节点的 DB_TRX_ID ,做一次简单的匹配和排查,直到找到对你而言安全的那个旧记录并投喂给你为止。这个规则被称为快照读可见性算法。
相关源码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 | /** Check whether the changes by id are visible. @param[in] id transaction id to check against the view @param[in] name table name @return whether the view sees the modifications of id. */ [[nodiscard]] bool changes_visible(trx_id_t id, const table_name_t &name) const { ut_ad(id > 0); if (id < m_up_limit_id || id == m_creator_trx_id) { return (true); } check_trx_id_sanity(id, name); if (id >= m_low_limit_id) { return (false); } else if (m_ids.empty()) { return (true); } const ids_t::value_type *p = m_ids.data(); return (!std::binary_search(p, p + m_ids.size(), id)); } /** Check whether transaction id is valid. @param[in] id transaction id to check @param[in] name table name */ void ReadView::check_trx_id_sanity(trx_id_t id, const table_name_t &name) { if (&name == &dict_sys->dynamic_metadata->name) { /* The table mysql.innodb_dynamic_metadata uses a constant DB_TRX_ID=~0. */ ut_ad(id == (1ULL << 48) - 1); return; } if (id >= trx_sys_get_next_trx_id_or_no()) { ib::warn(ER_IB_MSG_1196) << "A transaction id" << " in a record of table " << name << " is newer than the" << " system-wide maximum."; ut_d(ut_error); #ifndef UNIV_DEBUG THD *thd = current_thd; if (thd != nullptr) { char table_name[MAX_FULL_NAME_LEN + 1]; innobase_format_name(table_name, sizeof(table_name), name.m_name); push_warning_printf(thd, Sql_condition::SL_WARNING, ER_SIGNAL_WARN, "InnoDB: Transaction id" " in a record of table" " %s is newer than system-wide" " maximum.", table_name); } #endif } } |
Read View 的灵魂在它附带的四个核显判断指标属性:
- m_ids: 当快照建立这一瞬间,当前数据库还捏在手里执行未完待续(尚未提交)的所有其他活跃事务ID形成的一个列表。
- m_up_limit_id: 上面这个活跃名单里面最小老资格的那个事务ID值。
- m_low_limit_id: 数据库正要向外派发并即将启用的那个还未发车的新事务的ID值(也就是当前存在过的最大事务ID值+1)。
- m_creator_trx_id: 当前正在发起快照读查询自身所属的事务ID值(如果是单独只含select没修改意向的操作,ID默认为0不算在内)。
当你带着这四项法则去undo log版本链里一个个由新到旧翻箱倒柜查每个节点记录附带的 `DB_TRX_ID` (记为 id)时,它的审核逻辑是一套极为森严的安检流程:
- 当 id == m_creator_trx_id 时 : 这就是你自己刚才自己修改过留下的东西,毫无疑问“可见”。
- 当 id < m_up_limit_id 时 : 这个节点是由一个已经在你开始建视图之前老早完事跑路的祖先事务产生的,并且也不在你记录的未完待续名单里,是安全的已提交遗物,毫无疑问判定“可见”。
- 当 id >= m_low_limit_id 时 : 这是个来自未来的人,也就是它是由一个在你Read View建好之后才生成的新秀干出来的离谱操作,对你这种历史快照来说绝对“不可见”。
- 当 m_up_limit_id <= id < m_low_limit_id 时,这个是最尴尬的处在中间模糊地带,我们需要二次细看安检单子:
- id 在 m_ids 列表里面:它在你的记录单子上说明你建视图的时候它还没结清没提交!它修改的结果不可作数,“不可见”!
- id 不在 m_ids 列表里面:它在这批次老手范围里,但没出现在活人名单里,那只能说明它在视图刚要打出的前一瞬提前完事提交结账退房了!那这就没问题算进既定安全事实,“可见”。
如果上述安检审核过不了不可见,那就直接顺着当前这行的 DB_ROLL_PTR 回滚指针拉上一个老版本旧数据节点重新执行上述对比流程尝试,直到有能通过的旧数据抛出给你,或者啥都没翻着返回空空如也。
注:同样基于Read View体系却造就出了 RR和RC 两种隔离天选的区别,这就是生成的时机!
- 读已提交(read committed RC):每次碰到select执行快照读查询,它都要去临时拍一张系统当前的全局活跃新全家福 Read View 替换掉老的!所以它能持续动态感知到周围事务到底提交没有!
- 可重复读(repeatable read RR):极度固执!只在这个事务生涯里第一次发生select快照读查询的时候生成那一张绝版全家福 Read View(如果是直接通过start transaction with consistent snapshot命令则是开启时就固定)。之后的所有反复查询都是拿着这张旧底片旧名单进去查验匹配,所以数据完全对它定格了锁定恒定状态!
整体流程
走完上面的细化规则流程概念,我们用个鲜活例子从头走到尾。
某刻,有四个同仁事务一起在一个体系里干活:
假设发生快照读查询的就是我们的当前“事务2”。由于大家都掺和进来,这瞬间系统还保留活跃的是事务1和事务3,而有个叫事务4的在上一瞬刚刚干完事撤了(提交完毕),所以数据库系统收集情报:当前的活口 m_ids 集合就等于 [1, 3]。
| 事务1 (进行) | 事务2 (查询发起) | 事务3 (进行) | 事务4 (早早离场已提交) |
|---|---|---|---|
| 事务开始 | 事务开始 | 事务开始 | 事务开始 |
| … | … | … | 大笔一挥修改数据且已提交 |
| 正干得热火朝天 | 大吼一声:快照读! | 热火朝天 | |
| … | … | … |
这时候事务2发起快照视图创建 Read View:
m_ids名单:[1,3], m_up_limit_id(列表中最小)就是1,m_low_limit_id (下个没发的新号最大就是4+1) 就是5。
我们盯准一条叫“目标数据”的行。这行除了以前的某次原始数据,就是只被刚刚溜了的“事务4”动过并且它提交留下的最新版本就安在表上。那么这条新纪录的标头上刻的名字(DB_TRX_ID)就是4!
拿着DB_TRX_ID 取值为 4 这个数,带着刚才的 Read View 进行比对:
4 等于我们自己发起快照的2吗?不等于。
4 是不是小于最小值 1(m_up_limit_id)? 不是。
4 是不是大于等于将来的事 5(m_low_limit_id)? 也不是。
它落尽了最难搞定安检区间 1到5之间!那就拉开那个 m_ids [1,3] 的存活名单比对:
4 有没有记录在它俩之间?查无此人!说明当初它建档的时候,4号它已经提前提交结案退出了!判断出是安全、可靠的事实!可以放心呈现!完全“可见”!
于是事务2通过排查,拿走并且查询打印出了由事务4离场留下的更新最新结果记录页。这就完成了它的使命。
也正是基于这一套Read View安检体系匹配。如果是RC隔离,下次有谁又去更新并撤场,由于随时更新安检活口名单,它又能获取并能查询显示出来,体现出“读已提交”。
如果是RR隔离,它死守第一版的安检存活名单绝不更换,别人死活变动不在考量,它拿着定死的区间指标核对底层记录版本链,这也就是“可重复读”铁板一块恒定展现。
MVCC相关问题
可重复读(RR)是如何在读已提交(RC)级的基础上解决不可重复读的?
答:最精辟的一点总结,RC下无论发生多少次快照读,每次都要去现场重新查一次活口名单重新构建Read View试图,这就导致别人随时提交的东西会被新试图接纳获取而打破前后查询定局展示;
但可重复读(RR)直接封死了试图刷新接口:它的快照是在第一次快照读降生的时候生成并终身锁定的。这就相当于在乱战中拉出了一条横切时空安全线索,整个本事务周期内都是读的这个固定死快照法则比对进行,这就强压下并一举解决了别人改动在后续查出的数据前后颠簸(不可重复读的问题)。
可重复读(RR),读已提交(RC)级别下的InnoDB快照读有什么不同?
在RR稳如泰山级别下,它的第一次快照读就是一盘定音创建Read View。随后的任何复查再次调用快照读口令的时候,都是老配方原汁原味调用那个已经存放下的Read View,所以只要当初创建它的那刻别人还没改完或者才刚刚改的内容,一律屏蔽不可见;快照读的试图生成把外部变动屏蔽。
在RC时刻感知变动级别下,那就纯粹每次去索要一次最新的Read View来替换老眼光。每次都能跟进当前大势走向和已经入库提交的好事者内容同步,做到动态的所谓“我能看到你已提交的事”(这就是RC读已提交产生别人改动自己也同时受影响的根本缘由)。
总结
1. MVCC 是一个在高并发环境下尽可能地减少和规避粗放行锁锁死阻塞带来大面积等待开销的“多版本追溯体系”,并且专宠和配合 可重复读(RR),读已提交(RC)进行落地有效。
2. 所有带控制性、有明确增删改以及强调去排外封锁的指令(附带for update等)都是强硬的“当前读”;顺便轻松普通去进行读取没有任何累赘不夹带私货的 select 即为轻盈并依赖MVCC的“快照读”。
3. 揭去底层面纱,MVCC 就是依靠四个隐式打底机制运转而成的精妙配合:【隐式隐藏内部字段(如记录最后经手ID和指回上步记录的回滚游标DB_ROLL_PTR)】与【专职历史归档保留证据的undo log日志回滚链】形成基因为底。同时配合用于在大量纷杂更新历史节点中按照时空顺序进行截取安检查核放行的【Read View审核快照试图机制算法】。三位一体相互协作实现功能。
4. RC 每次试图刷新拥抱最新事实,从而能读取已落袋提交变化产生隔离动态表现; RR 从一开始建立检查试图后绝不反悔贯穿首尾一生只依一条核查标准线,从而隔离出无视任何后续乱局的数据铁板定格,造就高阶的绝佳体验。
程序猿老龚(龚杰洪)原创,版权所有,转载请注明出处.