1. 为什么要搞懂事务隔离和MVCC先回答一个很多开发者问过我的问题隔离级别和MVCC到底值不值得花时间啃我的答案是值得而且越早啃越省事。MySQL的InnoDB引擎是默认存储引擎是不是单行读还是批量导入最终都会落在事务和并发控制这层。搞懂MVCC不仅仅是应付面试更多时候是线上出问题了比如死锁、脏读、可重复读失效你得能快速定位到是哪儿出了问题。没有这套底层认知你就只能靠重启试试加索引试试这种碰运气的办法。这篇我不会按教科书那套讲法讲而是从实际使用场景出发把事务隔离级别、ReadView机制、undo log版本链、当前读快照读这些概念串成一个完整链路。看完你至少能做到能说清楚RR级别下为什么不会幻读、RC和RR在底层有什么区别、什么时候读不到刚提交的数据、死锁日志该怎么看。2. 事务隔离级别的本质2.1 隔离级别到底隔离了什么事务的ACID大家应该都熟悉其中IIsolation就是隔离性。并发事务同时执行时隔离性决定了你的事务能看到多少别人的操作。太低会出乱子太高会让并发性能大幅下降这是个取舍问题。要理解隔离级别先得搞清楚它要解决的三类问题脏读读到别的事务还没提交的数据。对方如果回滚了你就等于读了一个假数据。不可重复读同一条数据事务内前后读了两次结果不一样其他事务修改并提交了。幻读同一个查询条件事务内前后查了两次结果集的行数不一样其他事务插入或删除了记录。MySQL的InnoDB提供了四种隔离级别隔离级别脏读不可重复读幻读READ UNCOMMITTED可能可能可能READ COMMITTED不可能可能可能REPEATABLE READ不可能不可能可能InnoDB实际解决SERIALIZABLE不可能不可能不可能2.2 REPEATABLE READ下的幻读到底是解决了还是没解决有个很容易踩坑的知识点MySQL官方文档说RR级别存在幻读可能但InnoDB在大多数场景下通过MVCC加间隙锁把幻读解决了。这个大多数场景很微妙。快照读普通的SELECT在RR下永远不会幻读因为MVCC通过ReadView保证了每次读到的都是事务开始时的快照。但如果你的场景里出现了当前读加锁的SELECT ... FOR UPDATE、UPDATE、DELETE幻读就可能钻空子。举个例子-- 事务A START TRANSACTION; SELECT * FROM user WHERE id 100 FOR UPDATE; -- 此时锁住了id 100的间隙 -- 事务B INSERT INTO user(id, name) VALUES(105, new); -- 阻塞直到事务A提交如果事务A的查询条件没有建立索引InnoDB的间隙锁锁的是整个索引范围还能防住。但如果条件没走索引间隙锁锁的是主键索引的所有间隙效果差异很大这也是为什么建议大范围更新前检查执行计划。我之前遇到过这样一个线上问题某表在RR级别下一个事务内先查了一次快照然后另一个事务插入了一条符合条件的新数据并提交第一个事务再执行当前读居然读到了这条新数据。表面上是幻读但严格来说是快照读和当前读在RR下读的不是一个版本链。所以排查问题的时候先确认你执行的是不是当前读。2.3 实际开发中隔离级别怎么选默认的REPEATABLE READRR对大部分业务是够用的因为InnoDB在MVCC上做的优化让RR的并发性能损失没那么大。但有一个场景我建议切换到READ COMMITTEDRC高并发下的热点行更新。原因很简单RR下的间隙锁范围更大锁冲突概率更高很容易出现死锁。RC下没有间隙锁只有行锁冲突面和等待时间都会降下来。很多大流量系统比如秒杀、库存扣减都主动切到RC再用分布式锁或者乐观锁去保证业务一致性。不过要注意RC有它的代价——不可重复读。典型场景就是统计类SQL事务内多次做SUM查询如果期间有别的事务提交了数据每次查的结果可能都不一样。业务方必须能接受这种不一致否则就得考虑对汇总表加锁或干脆用SERIALIZABLE。3. MVCC底层机制全拆解3.1 一条记录怎么变成多个版本MVCCMulti-Version Concurrency Control叫多版本并发控制。核心思想不是加锁去阻塞读和写而是每条记录维护多个历史版本让读写不互相等待。读读随便并发读写大概率也并发只有写写才需要真正的锁。InnoDB在聚簇索引主键索引的记录里隐藏了三个核心字段DB_TRX_ID最后一次修改这条记录的事务ID。DB_ROLL_PTR回滚指针指向undo log里上一个版本的位置。DB_ROW_ID没有主键时内部生成的自增行ID有主键则无该字段。你每修改一条记录InnoDB不会直接覆盖旧值而是先把旧值写进undo log然后把DB_ROLL_PTR指向这个旧版本最后更新DB_TRX_ID和新的数据值。这样一来同一条逻辑记录就在undo log里串成了一条版本链表。从一个实际更新来感受下这个过程-- 假设初始记录id1, nameA, trx_id100 -- 事务200执行UPDATE user SET nameB WHERE id1; -- 发生的事 1. 先把id1的旧值nameA, trx_id100copy到undo log 2. 更新聚簇索引记录nameBDB_TRX_ID200 3. DB_ROLL_PTR指向undo log中的旧版本此时如果要回滚InnoDB顺着DB_ROLL_PTR链路找到最老的版本直接恢复数据即可。不需要额外存一份变更前快照这就是undo log承担的责任。3.2 ReadView是快照读的透视镜每个事务开始快照读时会生成一个ReadView它决定了你在这个事务里能看到哪个版本的数据。ReadView的核心字段就四个m_ids生成ReadView时当前系统内活跃事务ID的列表。min_trx_idm_ids里最小的事务ID。max_trx_id生成ReadView时系统分配给下一个事务的ID注意这不是活跃事务的最大ID是未来事务的起点。creator_trx_id创建这个ReadView的事务自己的ID。判断一条记录对当前事务是否可见逻辑如下条件可见性结论DB_TRX_ID creator_trx_id可见自己改的当然能看见DB_TRX_ID min_trx_id可见修改记录的事务已提交DB_TRX_ID max_trx_id不可见记录是未来事务改的DB_TRX_ID在m_ids中不可见修改事务仍在活跃未提交DB_TRX_ID不在m_ids中可见修改事务已提交如果当前版本的记录不可见就顺着DB_ROLL_PTR往下找下一个版本继续用同样的规则判断直到找到可见版本。找不到那就返回空。用生活化的例子ReadView就像你在餐厅点菜时拿到的号码牌你进场时已经在场的客人活跃事务的账单状态你看不到你进场之后才来的客人他们点的菜端上来之前你也看不见只有你进场之前就已经结账离开的客人他们点什么你完全看得见。3.3 RC和RR的核心差异ReadView的创建时机这是面试也常考的也是理解MVCC的最关键差异。READ COMMITTED级别下每个快照读语句执行时都会生成一个新的ReadView。正因为每次快照都重新拍一张照片所以事务内两次相同的SELECT可能看到不同的结果——这就是不可重复读。REPEATABLE READ级别下事务内第一次执行快照读时生成ReadView之后整个事务内所有的快照读都复用这一个ReadView不再更新。于是你始终看到的是同一个版本——这就是可重复读。这里有一个值得注意的细节RR下如果事务一开始没有执行快照读而是在执行了当前读比如UPDATEInnoDB会为这个当前读生成一个ReadView后续的快照读也会沿用这个ReadView。我实习时遇到过一次问题就是因为先做了一次UPDATE才做SELECT结果跟预期完全不一样后来追踪发现就是这个原因。再说清楚一点快照读本身是不加锁的MVCC读当前读是加锁的读取。前者的可见性是靠ReadView决定的后者是直接读最新版本并且加锁。4. undo log版本链与回滚实操4.1 undo log的两种类型undo log其实分两种INSERT undo log插入操作产生的。因为插入的记录对别的事务不可见未提交前回滚时直接删除这条记录的聚簇索引即可。这种undo log在事务提交后就可以直接清理不需要参与MVCC版本链判断。UPDATE undo log更新和删除操作产生的。删除本质上也是一种更新打删除标记。这种undo log要保留到没有任何事务的ReadView还需要看到它时才能被purge线程清理。我一直提醒团队里新人大事务是undo log膨胀的第一元凶。一个事务里UPDATE几百万行事务迟迟不提交这些版本的undo log就全部不能清理。线上表现就是ibdata1文件疯狂涨甚至磁盘被打满。所以OLTP系统里大事务一定要拆批控制在几千行一个事务提交一次。4.2 长事务和死锁的联动长事务本身不只是日志膨胀的问题还会加剧死锁。因为MVCC下写操作的锁要一直持有到事务提交才能释放长事务意味着锁持有时间拉长两个事务互相等待的概率直线上升。排查长事务可以直接用SELECT * FROM information_schema.INNODB_TRX\G重点看这几列trx_id事务ID看是否过于偏大偏大说明是较新的事务。trx_started事务开始时间和当前时间差超过几秒就要警惕。trx_rows_locked锁了多少行数值异常大说明扫描了很多行。trx_query当前正在执行的SQL判断事务挂在哪条语句上。如果某个连接长时间不提交trx_query可能显示为空因为事务已经执行完但没提交。这种情况光靠这条SQL看不出业务逻辑需要去应用端查这个连接在干什么。4.3 手动模拟一个版本链直观感受MVCC想彻底理解版本链我建议你自己动手模拟一次-- 会话1 START TRANSACTION; UPDATE user SET nameB WHERE id1; -- 先不提交 -- 会话2 START TRANSACTION; UPDATE user SET nameC WHERE id1;此时如果会话2执行更新它会被阻塞因为会话1持有id1的行锁。但如果你把两句话的顺序反过来先会话2更新并提交再会话1更新并提交最后这条记录只有一个版本链前一个版本的undo log很快就会被清理。更直观的方式是-- 查询最近的事务ID分配情况 SELECT TRX_ID, TRX_STARTED FROM information_schema.INNODB_TRX;然后结合SHOW ENGINE INNODB STATUS的TRANSACTIONS段落可以看到每个事务的undo log条目数和锁等待。建议把两三个客户端开起来交叉执行上面这些SQL实际体验比对着一堆文档想象要深得多。5. 当前读与快照读什么时候用哪个5.1 两种读的边界很多人分不清当前读和快照读但这两者的语义完全不同。快照读普通SELECT不加锁读的是ReadView可见的版本。不阻塞任何写操作写操作也不阻塞它。当前读SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE、INSERT在特定条件下。读的是最新已提交的数据版本并且对记录加锁。举例说明-- 会话1 START TRANSACTION; SELECT * FROM user WHERE id1; -- 得到 nameB假设这是旧版本 -- 会话2 START TRANSACTION; UPDATE user SET nameD WHERE id1; COMMIT; -- 会话1再次执行快照读 SELECT * FROM user WHERE id1; -- RR下还是nameBRC下就变成nameD但如果会话1改成当前读SELECT * FROM user WHERE id1 FOR UPDATE; -- 无论RR还是RC都会读到nameD这就是为什么很多读不到刚提交数据的问题最后都发现是快照读和当前读混用了。5.2 UPDATE为什么需要先读最新版本当前读必须读最新版本是因为写操作要在最新数据上做修改。如果允许基于旧版本做修改那后提交的事务覆盖先提交事务的修改就会出现丢失更新问题。这就是MVCC加锁配合的真正意义MVCC管读的可见性锁管写的串行化。两者结合在保证多版本的前提下不让写操作损坏数据。5.3 实际业务中怎么分配读写模式大体原则报表类查询数据量大读多写少优先用快照读不加锁性能最好。订单扣减库存这类操作必须在最新数据上判断用当前读。先查后改的复合操作比如先查余额再扣款尽量把查和改合并成一条UPDATE条件语句减少竞态窗口。对于读后写的并发控制最简单有效的方式是版本号字段乐观锁每次更新时检查版本号是否匹配匹配才更新。UPDATE account SET balance balance - 100, version version 1 WHERE id 1 AND version 5;返回影响行数为1则更新成功为0则版本冲突重试即可。这种方式比间隙锁可控得多。6. 间隙锁与RR下幻读的真实防线6.1 什么是间隙锁间隙锁锁的不是具体某条记录而是两个索引记录之间的空隙。比如表里现在主键id有1和5两条记录那(1,5)这个区间就是一个间隙。事务在RR下对id3这条不存在的记录加锁时实际上锁的是(1,5)这个间隙任何尝试往这个间隙里插入数据的操作都会被阻塞。间隙锁和行锁配合构成了Next-Key Lock临键锁它锁的是记录本身前面的间隙。这样设计的目的就是防止幻读你第一次查id 1的记录时锁住了(1,正无穷)这个范围别人就没法在这个范围里插入新行。6.2 间隙锁的边界条件真的很多间隙锁并不是灵丹妙药它的生效条件很苛刻查询条件必须有索引否则InnoDB会锁全表的所有间隙性能灾难。如果查询条件是普通索引非唯一锁的范围会变成一个排他区间包含相邻的范围比单一条件锁的多。唯一索引的等值查询在找到目标记录后会把对应间隙锁升级为行锁没有幻读问题不需要间隙锁。我之前排查过一个死锁案例两条UPDATE语句一条走唯一索引一条走普通索引update了同一批数据但因为加锁顺序不同直接死锁。日志里可以清楚看到一条holding间隙锁另一条waiting行锁交叉等待。6.3 RR必用的场景和推荐做法如果你的业务确实存在先查询一个范围然后根据查询结果做插入的场景又不想出现幻读RR默认就会帮你把间隙锁加上。这种场景下没必要手动处理直接用事务加当前读即可。但如果是高并发系统我建议优先考虑RC乐观锁不要依赖间隙锁做并发控制。原因很实际间隙锁的并发度太低了锁范围大冲突概率高死锁率也高。很多阿里系的数据库中间件默认把隔离级别切到RC不是没有原因的。下面是一个排查死锁的快速SQL记录一下备用SHOW ENGINE INNODB STATUS\G -- 看LATEST DETECTED DEADLOCK段落 -- 重点关注 -- 1. 两个事务各自的SQL -- 2. 各自持有的锁和等待的锁 -- 3. lock_type和index name死锁日志里最容易忽略的是table handle和index name。如果发现锁的是二级索引而不是主键大概率是SQL没有走索引或者索引设计不合理加索引往往比改代码更有效。7. 常见问题排查实录7.1 问题1RR下读到了自己修改的旧数据现象事务A里先UPDATE了某条记录然后同事务里SELECT发现还是旧值。原因这个现象的根本原因往往不是MVCC而是UPDATE的WHERE条件没走索引锁全表但更新到了目标记录。然后SELECT用的快照读ReadView生成在UPDATE之前所以看到的是旧版本。解决确认UPDATE执行计划确保索引命中或者SELECT也改成当前读强制读取最新版本。7.2 问题2一条SQL扫描了太多行但锁却很少现象慢SQL日志里Rows_examined非常高但INNODB_TRX里trx_rows_locked很小。原因InnoDB对扫描过的每一行都会加锁但innodb行锁是不到提交不释放如果SQL走的是覆盖索引可能在索引层面扫描大量记录但实际加锁落地到主键上的行数少所以锁数量看着少但CPU和内存的消耗其实很大。解决优化索引尽量让SQL定位到精确的记录范围减少扫描行数。例如大范围查询改成基于主键的分批查询。7.3 问题3长事务导致undo膨胀现象磁盘空间被打满ibdata1或者undo文件异常增长。原因长事务持有旧版本的ReadView导致undo log版本链无法清理。MySQL的purge线程必须等到所有活跃事务的ReadView都看不到这些旧版本后才能清理。解决找到长事务并处理掉然后在应用层加事务超时控制。最直接的方式是SELECT trx_id, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_sec FROM information_schema.INNODB_TRX WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) 5;发现超时事务直接kill连接别犹豫。业务代码里也要加事务超时保护避免连接池里的连接长期挂着未提交事务。7.4 问题4RC下同一个事务内SUM结果不一致现象统计SQL在事务里跑了两遍数值对不上。原因RC下每个快照读都生成新的ReadView不可重复读在RC下是允许的甚至可能读到中间状态。解决如果统计结果必须一致事务改成RR级别或者把统计SQL放到一个事务的最开始保证读的是同一个ReadView再或者让统计SQL走当前读加锁但不推荐并发性能太差。8. 我的实操心得文章最后分享两个实际经验。第一排错MVCC问题先不要急着看代码先看binlog和慢查询日志搞清楚出问题的时候对应的SQL走了什么索引、事务执行了多长时间。极多事务隔离问题最终都落在索引没走对或者长事务上。第二在自己的学习环境里多开几个客户端实践是最好的老师。创建一个简单的user表插几行数据然后按上面说的步骤模拟脏读、不可重复读、幻读、死锁。每个现象都亲手复现一遍远比背十篇讲义有用。我面试别人时比较看重的是他能不能用自己的话解释清快照读和当前读的区别这比背出四个隔离级别的定义更能反映水平。希望这篇能帮你在MySQL并发这条路上少踩几个坑。如果你在复现的过程中遇到什么奇怪的现象也欢迎一起交流讨论。