MySQL MVCC核心机制详解:ReadView、undo log与隔离级别实战
这两年面试Java后端MySQL的MVCC基本是必考题。很多人能背出“快照读”“当前读”“undo log版本链”这些名词但被问到“ReadView到底怎么生成的”“RR级别下为什么能部分解决幻读”时就卡壳了。这篇文章我不想堆砌八股文而是从实际使用场景出发把MVCC这套机制的来龙去脉拆开揉碎讲清楚。无论你是准备面试还是纯粹想把数据库并发原理搞明白看完应该都能建立起一个相对完整的认知框架。1. 从并发冲突说起MVCC到底解决了什么问题1.1 读写并发时的两种极端方案先想一个最简单的场景事务A正在读取一行数据事务B同时要修改这一行。如果数据库没有并发控制机制A可能读到B修改到一半的中间状态数据就乱了。传统方案有两种一种是加锁读之前把数据锁住写完再释放另一种是互斥读写完全串行化。这两种方案都能保证一致性但代价是并发能力被严重削弱——读操作会被写操作阻塞写操作也会被读操作阻塞。MVCC的思路完全不同它不让读操作去等锁而是让读操作读取数据的历史版本。事务B修改数据时不直接覆盖原值而是生成一个新版本事务A读取时根据自己事务的启动时间决定是读老版本还是新版本。这样一来读和写互不干扰并发能力大幅提升。1.2 MVCC与事务隔离级别的关系MVCC不是独立存在的它是InnoDB为了支撑事务隔离级别而设计的底层机制。SQL标准定义了四个隔离级别读未提交Read Uncommitted、读已提交Read Committed、可重复读Repeatable Read、串行化Serializable。其中读已提交和可重复读主要就是靠MVCC实现的。MySQL默认的隔离级别是Repeatable Read。MVCC在这个级别下能让同一个事务内多次执行相同的查询返回结果保持一致也就是“可重复读”的效果。而在Read Committed级别下每次查询都能看到其他事务最新已提交的数据。为什么同样的机制在不同隔离级别下表现不同关键在于ReadView的生成时机。这个细节文章后面会专门展开。1.3 快照读与当前读两条并行的读取路径用MySQL的人应该都听说过这两个词。快照读consistent read是指普通的SELECT语句读取的是事务启动时或第一次查询时生成的快照当前读locking read是指SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE以及INSERT、UPDATE、DELETE这些写操作读取的是数据的最新版本并且会对记录加锁。这两条路径并行存在是理解MVCC的关键。快照读依赖版本链和ReadView实现无锁查询当前读依赖锁机制保证写入安全。很多人在面试时被问“MVCC是解决读写冲突的吗”其实不准确更严格的说法是MVCC解决的是快照读与写操作之间的冲突让普通查询不需要等待写锁但对于写操作之间、以及当前读与写操作之间仍然依赖锁机制。2. MVCC的三块基石隐藏字段、undo log、ReadView2.1 InnoDB聚簇索引里的三个隐藏字段InnoDB的每行数据除了用户定义的字段之外还藏着三个额外字段这是MVCC能工作的物理基础。DB_TRX_ID最近一次修改插入或更新这行记录的事务ID6字节。每次事务修改记录时都会把这个字段更新为当前事务的ID。DB_ROLL_PTR回滚指针7字节。指向该记录上一个版本在undo log中的位置通过它可以把多个版本串成一条链表。DB_ROW_ID行ID6字节。如果表没有显式定义主键也没有非空唯一索引InnoDB会自动生成一个隐藏的聚簇索引使用这个字段作为索引键值。注意这里说的都是聚簇索引主键索引里的隐藏字段。对于二级索引InnoDB的MVCC处理会更复杂需要通过回表到聚簇索引来获取DB_TRX_ID判断可见性。这一点在分析慢查询或死锁问题时偶尔会遇到。这三个字段平时查询是看不到的但可以通过特殊手段验证它们的存在后文实操部分会讲。2.2 undo log版本链的载体undo log中文常叫回滚日志分为两种类型insert undo log插入操作产生的日志。插入的数据只对当前事务可见所以这种日志在事务提交后直接可以删除不需要参与MVCC版本链。update undo log更新或删除操作产生的日志。删除操作本质上也是更新只是把记录标记为已删除。这种日志需要保留因为其他事务可能还需要读取老版本。每次事务修改一行记录InnoDB都会把修改前的旧值写入undo log然后通过DB_ROLL_PTR把旧版本串起来。多个事务依次修改同一行时就形成了一条从最新版本到最老版本的链表这就是常说的“版本链”。版本链的头结点最新版本就在聚簇索引的记录里老版本通过回滚指针依次串联。ReadView做可见性判断时就是从版本链的头开始沿着指针向后查找找到第一个对当前事务可见的版本。2.3 ReadView决定“能看到哪个版本”的规则ReadView直译是“读视图”你可以把它理解成事务在某个时间点给数据库拍的一张“快照记录表”。它记录的是当前有哪些事务正在活跃未提交。ReadView内部包含四个关键字段字段含义m_ids生成ReadView时当前系统中所有活跃未提交事务的ID列表min_trx_idm_ids中的最小值即最早启动的活跃事务的IDmax_trx_id生成ReadView时系统已分配过的最大事务ID加1可以理解为“下一个将被分配的事务ID”creator_trx_id生成该ReadView的事务自身的ID有了这个视图判断一条记录的某个版本是否可见就变成了一套明确的规则。假设当前事务要判断DB_TRX_ID trx_id的版本是否可见如果trx_id creator_trx_id说明这个版本就是当前事务自己修改的肯定可见。如果trx_id min_trx_id说明这个版本在ReadView生成之前就已经提交了可见。如果trx_id max_trx_id说明这个版本是ReadView生成之后才开启的事务生成的不可见。如果min_trx_id trx_id max_trx_id说明这个版本可能来自活跃事务需要判断trx_id是否在m_ids列表中如果不在说明该事务已提交可见如果在说明事务还未提交不可见需要沿版本链继续往下找。这条规则建议反复读几遍理解了它MVCC的可见性判断就通了。很多文章只讲规则不解释为什么我简单说一下ReadView本质上是在事务执行期间划定了一条“安全线”线以下更早提交的事务可见线以上未来的事务不可见线中间的事务需要逐个确认是否已提交。这套机制让一致性读不需要加锁代价就是读取时需要沿着版本链做指针遍历。3. 两种隔离级别下ReadView的生成时机差异3.1 Read Committed每次读都生成新快照在Read Committed级别下事务内每次执行快照读时都会重新生成一个新的ReadView。这意味着什么如果事务A先做了一次查询然后事务B提交了一条修改事务A再做第二次查询第二次查询会生成新的ReadView这个新ReadView能看到事务B提交的数据。所以RC级别下同一个事务内两次查询结果可能不一样——不可重复读。从代码路径上说每次快照读都会调用相应的函数生成ReadView代价是版本链的遍历可能更频繁。但对于大多数业务系统RC级别已经是够用的而且能避免一些RR级别下的间隙锁问题很多团队因此首选RC。3.2 Repeatable Read只在第一次读时生成快照RR级别下事务内第一次执行快照读时生成ReadView之后整个事务期间都复用同一个ReadView。后续即使是新提交的事务修改了数据老ReadView依然看不到这就保证了可重复读。所以“可重复读”这个名字很直白同一个事务里对同一批数据反复读取结果永远一致。RR是MySQL默认的隔离级别很多业务系统直接用默认值也是因为这条规则的保证足够直观。3.3 两种生成时机的对比与选择隔离级别ReadView生成时机是否可能不可重复读是否可能幻读Read Committed每次快照读是是Repeatable Read首次快照读否部分解决后文细说实际项目中如果需要报表统计类的长事务RR更合适如果是高并发读写且对一致性要求不苛刻RC能减少锁竞争。需要根据业务场景去选不要无脑用默认值。4. 实操验证把MVCC从概念变成看得见的现象4.1 准备一个可复现的实验环境先用Docker起一个MySQL实例或者直接用本地的MySQL 8.x。建立一个测试库和表docker run --name mysql-mvcc -e MYSQL_ROOT_PASSWORD123456 -p 3306:3306 -d mysql:8.0CREATE DATABASE IF NOT EXISTS mvcc_demo DEFAULT CHARSET utf8mb4; USE mvcc_demo; CREATE TABLE account ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(32) NOT NULL, balance INT NOT NULL ) ENGINEInnoDB; INSERT INTO account(name, balance) VALUES (Alice, 500), (Bob, 300);这条account表就是我们的实验工具两行数据足够演示版本链、可见性判断这些核心机制。4.2 通过information_schema.innodb_trx观察事务状态MVCC看不见摸不着但InnoDB提供了一个系统表information_schema.innodb_trx可以实时查看当前正在运行的事务及其ID。这是实验里最实用的观察窗口。打开两个终端或两个MySQL会话终端ABEGIN; UPDATE account SET balance balance - 100 WHERE id 1;此时事务未提交查询系统表SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id FROM information_schema.innodb_trx;你能看到一个活跃事务记录trx_id就是它的ID比如2828。这个ID后面会用在可见性分析和版本链分析里。4.3 验证版本链动手制造多个版本继续在终端A执行修改让同一条记录产生多个版本UPDATE account SET balance balance - 50 WHERE id 1; UPDATE account SET balance balance - 20 WHERE id 1;每次UPDATE都会让DB_TRX_ID和DB_ROLL_PTR变化一次。但直接SELECT * FROM account看不到隐藏字段。有两个办法确认版本链的存在办法一用ibd文件解析工具比如ibd2sdi或商业工具能看到物理存储结构。这个偏底层适合感兴趣的同学。办法二在业务SQL层面通过开启另一个事务去观察不同时间点的快照差异间接验证。比如在终端BSET TRANSACTION ISOLATION LEVEL REPEATABLE READ; BEGIN; SELECT balance FROM account WHERE id 1; -- 第一次读假设得到500事务A未提交时然后回到终端A执行COMMIT;再回到终端B执行同样的查询会发现结果仍然是500。这就是RR级别下首次生成的ReadView被复用了事务A的提交不影响B的快照。这个实验每次做都很直观。4.4 实际查看ReadView的四个字段MySQL没有直接提供“查看ReadView”的命令。但在调试源码或使用某些运维工具时可以通过performance_schema下的相关表或者用pstack抓取线程信息间接看到。对普通开发者来说更推荐通过行为测试倒推ReadView的机制比如上面那个实验。如果你真的想“看见”ReadView可以安装mysql-debug版本的MySQL在storage/innobase/trx/trx0trx.cc文件里的trx_assign_read_view函数打个日志。这个方案适合源码爱好者不适合日常开发。我更推荐用行为实验去验证机制因为行为实验不依赖内部实现换版本、换环境都能复现。4.5 复现RC与RR的读取差异这个实验强烈建议亲手做一遍做完对MVCC的理解会上一个台阶。操作序列如下在终端A设置RC级别并开启事务SET TRANSACTION ISOLATION LEVEL READ COMMITTED; BEGIN;在终端B执行一条更新并提交UPDATE account SET balance 0 WHERE id 2; COMMIT;回到终端A第一次查询SELECT balance FROM account WHERE id 2;此时看到的是修改前的旧值300因为事务B还在活跃或尚未提交。再次切到终端B提交上面的更新后回到终端A再查一次RC级别下会看到新值0。因为RC每次快照读都会重新生成ReadView能够看到已经提交的事务。把第1步的隔离级别改成REPEATABLE READ重跑一遍第二次查询的结果还是300因为ReadView是第一次查询时生成的后续复用看不到后来提交的事务。这个对比实验能让你直观感受到隔离级别的语义差异本质上是ReadView生命周期差异的外在表现。5. 高频疑问与面试深挖点5.1SELECT ... FOR UPDATE是否读取了MVCC这个问题直接命中了很多人对MVCC边界的误解。结论是FOR UPDATE属于当前读它读取的是记录的最新版本不通过ReadView做版本可见性判断而是对命中的记录加排他锁。具体来说SELECT ... FOR UPDATE会从存储引擎读取最新的已提交数据并加锁阻塞其他事务的写操作。在RR级别下InnoDB还会使用间隙锁Gap Lock或临键锁Next-Key Lock来防止幻读锁的范围不止命中的行。所以如果你在一个事务里SELECT ... FOR UPDATE后其他事务对该行的普通SELECT不受影响快照读走MVCC但对该行的UPDATE会阻塞。实际工作中这是一处容易踩坑的地方。如果要在事务里做“先查再改”的流程FOR UPDATE能保证数据不被别人先改但代价是会引入锁竞争。高并发场景下需要评估锁的粒度是否可接受。5.2 RR级别下MVCC到底能不能防幻读关于幻读很多文章说法不一。补充几个结论MySQL默认的RR级别其实不能完全避免幻读。在RR级别下纯快照读普通SELECT确实不会出现幻读因为ReadView一旦生成后续查询都看同一个快照。但如果是当前读SELECT ... FOR UPDATE、UPDATE、DELETEInnoDB通过间隙锁来防止幻读锁是加在索引记录之间的空隙上的阻塞了其他事务在空隙中插入新记录。如果先快照读再当前读两者使用的一致性视图不同是可能出现“自己读到的数据和实际能修改的数据不一致”的情况的。一种经典场景事务A按条件查询没有数据快照读随后事务B插入了符合条件的数据并提交事务A再执行UPDATE当前读会修改到事务B插入的那条数据导致结果和快照读不一致。所以严格来说RR级别下MVCC只是解决了快照读的幻读问题没有完美解决“快照读当前读混合”场景下的幻读。5.3 长事务为什么会导致undo log膨胀从MVCC的原理可以推理出一个重要结论ReadView生成之后即使在它之后提交的事务其数据修改对当前事务也不可见。这意味着判断可见性时需要沿着版本链回溯到合适的版本。如果有个长事务迟迟不提交它持有的ReadView会一直“挡住”老版本InnoDB的purge线程不能清理那些老版本undo log就会不断累积导致undo表空间膨胀严重时还会让版本链变得很长拖慢查询。这个坑经常在报表类事务或误操作的长事务里出现。排查思路是定期查看information_schema.innodb_trx里的事务时长及时kill掉异常的长事务。5.4 面试常追问为什么插入操作要记录事务ID插入操作的数据对于其他并发事务不可见因为新插入的数据版本链上只有自己一个版本。但如果插入的事务未提交另一个事务的DELETE或UPDATE可能扫到这条记录怎么判断它可见这就依赖DB_TRX_ID如果DB_TRX_ID对应的时一个未提交的插入事务那么对该记录的任何访问都会走到锁等待或版本判断逻辑而不是直接可见。这也是隐藏字段存在的另一个意义。5.5 常见问题速查问题现象原因同事务两次查询结果不一致RC级别偶发每次查询生成新ReadViewRR级别下次查询看不到别的事务新提交数据符合预期ReadView复用长事务拖慢其他查询版本链膨胀老版本无法被purge清理FOR UPDATE导致写阻塞锁竞争当前读使用行锁/间隙锁快照读能读到未提交数据不会活跃事务版本不可见6. 一次完整的MVCC读取流程图解不用mermaid我用文字把一次快照读的完整路径串一遍。假设事务T1执行SELECT balance FROM account WHERE id 1T1首次查询生成ReadView记录当前活跃事务列表。InnoDB根据主键id1定位到聚簇索引中的记录拿到主键对应的最新版本数据。提取记录上的DB_TRX_ID比如是T2另一个事务的ID。用ReadView的规则判断T2是否可见如果T2在活跃列表中说明T2未提交这条数据不能读于是通过DB_ROLL_PTR指向的undo log找到上一个版本继续执行步骤3和4。直到找到一条DB_TRX_ID满足可见性规则的版本返回该版本的数据。整条路径不需要加任何锁这就是MVCC提供的无锁读取能力。理解这条路径你对InnoDB的读性能会有更深的体会快照读再多也不会被写操作阻塞这也是生产环境里普通查询能维持稳定RT的原因之一。7. 实操心得与避坑指南7.1 隔离级别的选择不要盲从默认MySQL默认是RR但在实际业务中很多大厂会选择RC。理由很简单RC级别的锁竞争更少不使用间隙锁而且很多业务根本不需要严格的可重复读语义。如果你的业务确实需要保证同一个事务内多次查询的结果一致再考虑RR。我的建议是先确认业务语义再去调整隔离级别不要因为“默认是RR”就不去思考。7.2 排查MVCC问题时的三个入口遇到奇怪的“读不到数据”“数据没更新”问题我一般会按顺序排查查information_schema.innodb_trx把活跃事务列表拉出来看看是不是有长时间未提交的事务。确认当前会话的隔离级别用SELECT transaction_isolation;查看不要凭印象。用EXPLAIN看执行计划确认是不是走了主键索引。如果查询走了二级索引MVCC判断可见性时需要回表处理逻辑会更复杂可能影响性能。7.3 避免被“常见说法”带偏网上很多文章把MVCC说成“乐观锁”其实不太准确。MVCC是InnoDB实现隔离级别的底层机制它不等于乐观锁。乐观锁通常指应用层通过版本号控制并发更新两者虽然都涉及版本概念但所处的层级和作用机制完全不同。面试时如果能把这个区别讲清楚会比单纯背概念加分不少。7.4 深入源码前的最后一公里如果你想把MVCC彻底吃透可以去看InnoDB源码里的几个关键函数trx_assign_read_view负责生成ReadViewlock_clust_rec_read_check_and_lock负责加锁时的一致性判断row_sel_build_prev_row_for_mysql负责读取时沿版本链查找。这几个函数把概念和实现串起来之后你对MVCC的理解会从“会背”变成“会讲”。我个人在实际排查中发现很多MVCC相关的“幽灵问题”归根结底都是因为对ReadView的生命周期理解不透。比如一个事务在A库查询正常在另一个连接里看不到更新多数情况就是因为两个连接处在不同的事务隔离级别或不同的事务生命周期里。弄懂ReadView、版本链、当前读和快照读的关系这些问题基本都能自己定位。最后建议你在自己的测试库里把第4节的实验完整跑一遍尤其是RC和RR的对比实验跑完比我在这里写一万个字都管用。