
这套标题看起来平淡但凡是真正被线上并发问题折磨过的人都知道这几个字背后藏着一整座冰山。MVCC和锁一个是InnoDB的读优化神器一个是写冲突的仲裁者两者既互相独立又必须精密配合才能撑起事务的隔离性与一致性。很多开发对它们的理解停留在“MVCC负责快照读锁负责写互斥”这种粗颗粒层面一旦遇到RR隔离级别下的幻读、死锁分析、长事务导致undo膨胀这类实际问题就会发懵。这篇文章我从原理到实战把两者的分工、衔接点、排查方法一次讲透适合正在用MySQL做业务开发、想深入理解事务底层机制或者准备数据库面试的读者。1. MVCC与锁一套并发控制机制的两层实现1.1 先搞懂两套机制各自的职责MVCC全称是Multi-Version Concurrency Control多版本并发控制。它的核心思路是数据行在更新时不是直接覆盖旧值而是通过undo log保留历史版本形成一个版本链。读操作根据事务的快照ReadView从版本链中挑出当前事务“可见”的版本这样读操作不需要等待写操作释放锁读与写天然解耦。锁机制则是另一套思路悲观地认为并发必然产生冲突所以在操作数据前先加锁让其他事务排队等待。InnoDB的锁分为行级锁、表级锁和更细粒度的记录锁、间隙锁、临键锁。锁解决的是“写与写之间如何不互相覆盖”MVCC解决的是“读与写之间如何不互相阻塞”。两套机制不是替代关系而是分层配合。MySQL的InnoDB引擎在事务执行过程中读操作分两类快照读Snapshot Read走MVCC不需要加锁当前读Current Read比如SELECT ... FOR UPDATE、UPDATE、DELETE必须走锁读取的是最新已提交版本并加上对应锁保护后续操作。1.2 为什么不能只靠锁也不能只靠MVCC我见过不少团队的代码里所有SELECT都习惯性加FOR UPDATE理由是“防止读到脏数据”。这等于完全抛弃MVCC把并发能力拉回串行化水平。举个实际例子一个商品库存表10个并发请求同时扣减库存。如果全部走SELECT ... FOR UPDATE每个事务必须等前一个事务提交后才能读到新值QPS直接被锁排队时间卡死。而如果全部走快照读业务上判断“库存大于0再扣减”就会基于旧版本做判断多个事务同时通过校验最后超卖。只靠MVCC也不行。MVCC解决不了两个事务同时修改同一行数据的覆盖问题。如果事务A和事务B都基于相同版本进行更新MySQL的机制是后提交的事务会等待或者直接报锁等待超时。这需要锁来完成版本更新的仲裁——也就是说最终写入的数据版本一定是串行提交的这个“串行”的保证来自行锁而不是MVCC。所以在InnoDB的设计里MVCC是“善意的快照”锁是“强制的一致性闸门”。读多写少的场景拖慢性能的是锁竞争写多的场景破坏数据的是并发覆盖。两者必须同时存在才能让数据库在性能和正确性之间取得平衡。1.3 读路径与写路径的完整配合流程为了直观理解配合过程我模拟一个订单扣库存的SQL序列事务T1执行START TRANSACTION; SELECT stock FROM inventory WHERE product_id 1; -- 快照读通过MVCC读取不加锁 -- 假设返回stock 10业务层判断stock 0 UPDATE inventory SET stock stock - 1 WHERE product_id 1; -- 当前读需要加X锁 COMMIT;事务T2同时执行START TRANSACTION; SELECT stock FROM inventory WHERE product_id 1; -- 快照读MVCC直接返回其可见版本 UPDATE inventory SET stock stock - 1 WHERE product_id 1; -- 尝试加X锁若T1还未提交则阻塞 COMMIT;这里的关键点在于T2的SELECT不一定看到T1未提交的修改所以T2可能基于旧值走业务判断但T2的UPDATE一定会被T1的行锁阻塞。这个场景揭示了一个真相——MVCC决定了你“看到什么”锁决定了你“能不能改”。业务逻辑的正确性必须同时依赖两层机制哪一层出现误判都会出问题。2. 版本链与ReadViewMVCC的内部构造2.1 undo log版本链是怎么串起来的MVCC的地基是undo log。在InnoDB中每行记录除了自身的数据字段还包含两个隐藏字段trx_id最近一次修改该行的事务ID和roll_pointer指向undo log中该行上一个版本的指针。每次对行的修改InnoDB都会先将旧值写入undo log然后更新行上的trx_id指向当前事务并让roll_pointer指向上一个undo log版本。这样多次修改之后就形成了一条从最新版本到最旧版本的链条。读操作拿着这条链按可见性规则一个个往回找就能找到自己“应该看到”的版本。举个例子假设行数据初始状态是(amount100, trx_id10)事务20执行UPDATE SET amount90事务30执行UPDATE SET amount80。完成后版本链结构是当前行版本amount80trx_id30roll_pointer → 版本2版本2amount90trx_id20roll_pointer → 版本1版本1amount100trx_id10roll_pointer → NULL如果事务25有一个快照读它根据ReadView规则会跳过trx_id大于自己快照创建时间的事务版本最终读到amount100的旧版本。这条链就是MVCC的“档案室”每个版本都保留了事务的归属信息可见性判断才有依据。2.2 ReadView的生成时机与可见性判断ReadView是MVCC判断可见性的核心数据结构它包含几个关键字段m_ids生成ReadView时当前未提交事务ID的集合min_trx_id未提交事务中的最小IDmax_trx_id下一个待分配的事务IDcreator_trx_id创建该ReadView的事务ID可见性判断的核心规则我习惯归纳成一个口诀版本事务ID小于min_trx_id的可见大于等于max_trx_id的不可见在m_ids集合范围内的不可见等于creator_trx_id的可见。这里要特别注意生成时机。RCRead Committed隔离级别下每次SELECT都会生成新的ReadView所以能读取到其他事务已提交的最新版本。RRRepeatable Read隔离级别下事务内第一次快照读会生成ReadView后续SELECT复用同一个快照因此事务内看到的数据是一致的。这就是“可重复读”这个名称的MVCC层面的由来——它靠的不是锁而是固定不变的快照。2.3 快照读与当前读的分叉口理解MVCC和锁的配合必须先分清楚SQL语句走的是哪条路径快照读路径普通SELECT走MVCC无锁不会阻塞读到的可能是历史版本当前读路径SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE、INSERT强制读取最新已提交版本并加锁保护这里有一个非常容易踩坑的场景事务内先做了一次普通SELECT获得一个快照然后执行UPDATE。UPDATE走的是当前读读取最新版本可能与之前SELECT看到的数据不一致。如果业务逻辑依赖“先查询再更新”的结果必须在SELECT加FOR UPDATE让查询和更新走同一条当前读路线。我自己在开发中多次遇到这类问题比如订单状态流转逻辑先查状态为“待支付”再更新为“已支付”如果不加FOR UPDATE多个请求同时进来时两个事务都能查到自己可见的“待支付”状态最终发出两张重复支付回调。这类bug的隐蔽性极高因为本地测试很难复现并发时序。3. InnoDB的锁家族图谱3.1 全局锁、表级锁与行级锁的分层协作MySQL的锁不是单层结构而是分层设计。最外层是全局锁通过FLUSH TABLES WITH READ LOCK实现会让整个实例变成只读一般用于备份场景。再往下是表级锁包括表锁LOCK TABLES和元数据锁MDL锁。MDL锁很容易被忽视却常常是线上故障的元凶——一个长事务持有MDL写锁后面的所有访问该表的请求都会排队最终拖垮连接池。真正解决并发互斥的是行级锁。InnoDB的行锁通过索引项实现意思是如果查询没有走索引InnoDB会退化为锁定所有扫描到的记录表面看是行锁实际效果接近表锁。这个知识点我会在排查类问题里反复提到因为线上很多“莫名其妙锁等待超时”的问题根因就是SQL没有命中索引导致行锁范围放大。3.2 Record Lock、Gap Lock与Next-Key Lock行级锁又细分为三类Record Lock是记录锁锁定索引上的某一条具体记录。例如WHERE id 10只锁id10的这一行。它是所有行锁的基础形态。Gap Lock是间隙锁锁住两个索引值之间的空隙防止其他事务在这个间隙内插入新记录。它专门解决“幻读”的插入问题。比如索引上有id10和id20两条记录间隙锁会锁住(10,20)这个开区间其他事务在id15处插入会被阻塞。Next-Key Lock是临键锁是记录锁与间隙锁的组合锁定的范围是左开右闭区间例如(10,20]。它同时锁住记录和其前面的间隙。RR隔离级别下InnoDB默认使用Next-Key Lock来防止幻读。RC隔离级别只使用Record Lock不启用Gap Lock因此RC天然存在幻读风险代价是更高的并发度。还有一个很容易混淆的点唯一索引上的等值查询Next-Key Lock会退化为Record Lock因为唯一索引确定只有一条记录不需要间隙保护。普通索引或没有索引的查询就无法退化。3.3 锁的兼容性矩阵行锁的兼容性只有两种关系共享锁S锁与排他锁X锁。S锁之间兼容多个事务可以同时持有同一行的S锁做读操作S锁与X锁互斥X锁与任何锁都互斥。实际业务中S锁场景很罕见大部分并发风险来自X锁的竞争。我也遇到过把SELECT升级为SELECT ... LOCK IN SHARE MODE来配合业务校验的情况但这类用法在高并发下风险极大因为S锁与S锁兼容会让多个事务同时通过校验并进入后续更新流程最终在UPDATE时才暴发死锁排查成本远高于直接用一个FOR UPDATE。4. 隔离级别下MVCC与锁的配合细节4.1 Read UncommittedMVCC形同虚设锁只管写RU隔离级别下事务能读到其他事务未提交的数据产生脏读。这个级别下ReadView几乎不起作用读操作直接读取最新版本没有可见性过滤。写操作仍然需要加锁否则两个事务同时UPDATE同一行照样会产生覆盖。实际生产环境基本不会使用这个级别但它有助于理解MVCC的价值边界——MVCC的“读不阻塞写”完全依赖可见性规则一旦规则被放宽快照就失去意义。4.2 Read Committed每次快照都是新的RC是大多数互联网公司默认的隔离级别。在这个级别下普通SELECT走MVCC每次查询生成新的ReadView因此能立即看到其他事务已提交的数据变更。UPDATE和DELETE依然走当前读并加Record Lock不启用Gap Lock。RC的问题在于不可重复读和幻读。不可重复读很好理解同一事务内前后两次SELECT可能得到不同结果。幻读则指事务内同一查询两次返回的记录集合不同包括新插入的记录。由于RC不锁间隙其他事务可以在两次查询之间插入满足条件的新记录从而出现“幻影行”。实际业务中如果没有复杂的账务对账需求RC的并发表现优于RR因为它把间隙锁的开销省掉了锁等待概率更低。但如果业务涉及“先查询记录集合再做集合级操作”的逻辑RC就可能出问题。4.3 Repeatable ReadMVCC负责一致性快照锁负责堵住幻读RR是MySQL InnoDB的默认隔离级别也是讨论MVCC和锁配合最典型的环境。在RR下事务内第一次快照读生成ReadView之后全部复用所以普通SELECT之间保证完全的可重复读。这一点靠MVCC就足够不需要任何锁。但仅仅有快照还不够。如果一个事务先执行了UPDATE或DELETE当前读再执行普通SELECTSELECT会基于当前读的最新版本生成新的ReadView而不是复用旧快照。这种现象在MySQL官方文档中被称为“半一致读”或“当前读后的快照更新”。业务上常见的错误是事务内先UPDATE自己的数据再SELECT要返回给前端的记录发现结果和自己预想的不一致根因就在于此。RR下幻读的防御由Next-Key Lock承担。如果事务A在RR级别下执行SELECT * FROM orders WHERE user_id 100 FOR UPDATE;InnoDB会为user_id100这条索引记录加Next-Key Lock。如果user_id上存在普通索引锁定的范围不只是该记录本身还包括该索引值之前的间隙。此时事务B尝试INSERT INTO orders (user_id) VALUES (100)或INSERT INTO orders (user_id) VALUES (99)都会因为落入间隙锁范围而阻塞直到事务A提交。这里可以再展开一个细节在RR级别下如果使用SELECT快照读进行条件查询并不会阻塞其他事务的INSERT。幻读这个词在不同语境下含义不同——如果有人跟你争论“RR到底能不能防幻读”要先确认他说的是快照读幻读还是当前读幻读。快照读在RR下天然杜绝幻读因为快照是固定的当前读则依赖Next-Key Lock来杜绝。许多面试官设的陷阱就在这里。4.4 Serializable所有读都变成当前读Serializable是最高隔离级别MVCC在这里基本失去作用。所有普通SELECT都会被隐式转换为SELECT ... LOCK IN SHARE MODE读操作也加锁读与读之间虽然兼容但读与写之间完全互斥。事务并发度急剧下降相当于把快照读全部改造成当前读。实际生产环境用Serializable的极少但它可以作为理解锁的极端参照当把读操作全部加锁后MVCC就没有存在空间了。这也反向印证了MVCC的设计目标——让读操作尽可能无锁只有在必须保证一致性的场景才引入锁来接棒。4.5 幻读在RR下的完整防御闭环为了加深理解我画一个业务上的完整场景。假设有个活动表activity活动ID通过最大ID加一生成事务ASTART TRANSACTION; SELECT MAX(id) FROM activity FOR UPDATE; -- 当前读加Next-Key Lock锁住最大记录及其间隙 INSERT INTO activity (id, name) VALUES (100, A); -- 插入成功 COMMIT;事务B并发执行START TRANSACTION; INSERT INTO activity (id, name) VALUES (99, B); -- 试图在间隙内插入被A的Next-Key Lock阻塞 COMMIT;如果没有Next-Key Lock事务B可以在A持有锁的间隙里插入新idA在事务内再次SELECT ... FOR UPDATE时会发现多了一条id99的记录这就是典型的幻读。Next-Key Lock让间隙成为临时禁区从写入侧堵死了幻读的可能。注意这个机制依赖索引——如果activity表没有定义任何索引InnoDB无法使用间隙锁会退化为整表锁。这个知识点经常出现在线上索引优化和面试中。5. 实战锁等待与死锁的观测方法5.1 用系统表定位锁等待根源线上遇到锁等待最直接的做法是查询information_schema.innodb_trx和performance_schema.data_locks、performance_schema.data_lock_waits三张表。我给出一个排查SQL组合实测下来能快速定位谁在等待谁持有锁-- 查看当前所有运行中的事务 SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx; -- 查看当前持有的锁和等待的锁 SELECT * FROM performance_schema.data_locks; SELECT * FROM performance_schema.data_lock_waits; -- 通过thread_id关联到具体连接 SELECT PROCESSLIST_ID, THREAD_ID, EVENT_NAME, OBJECT_NAME, INDEX_NAME FROM performance_schema.data_lock_waits w JOIN performance_schema.threads t ON w.THREAD_ID t.THREAD_ID;这套查询能直接回答三个问题哪个事务阻塞了别人它执行了什么SQL已经跑了多久。配合SHOW ENGINE INNODB STATUS中的LATEST DETECTED DEADLOCK段落基本能还原死锁的完整链路。我遇到过最典型的死锁场景是双向更新。事务A先UPDATE表1的某行再UPDATE表2的某行事务B恰好相反先UPDATE表2再UPDATE表1。两个事务互相持有对方需要的锁死锁检测机制会自动回滚其中一个。解决方式有两种一是让所有事务按固定顺序加锁比如先小id后大id或者先用户表后订单表二是缩小锁粒度减少锁持有时间避免锁的范围跨越多张表。5.2 从死锁日志还原崩溃现场死锁日志是MySQL给出的“事故现场记录”里面对应关系非常清晰。我截取一段典型输出说明怎么看------------------------ LATEST DETECTED DEADLOCK ------------------------ *** (1) TRANSACTION: TRANSACTION 18889, ACTIVE 5 sec mysql tables in use 1, locked 1 LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s) *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 12 page no 4 n bits 80 index idx_user_id of table test.orders trx id 18889 lock_mode X waiting *** (2) TRANSACTION: TRANSACTION 18890, ACTIVE 4 sec mysql tables in use 1, locked 1 2 lock struct(s), heap size 1136, 1 row lock(s) *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 12 page no 4 n bits 80 index idx_user_id of table test.orders trx id 18890 lock_mode X *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 12 page no 4 n bits 80 index idx_user_id of table test.orders trx id 18890 lock_mode X waiting排查重点是锁模式列lock_mode X表示排他锁waiting表示等待中。根据索引名idx_user_id和表名可以立即定位是哪个索引上的锁竞争。结合业务代码反推加锁顺序就能找到死锁产生的原因。5.3 大事务对MVCC版本链的隐性伤害锁问题只是并发控制的表面症状真正破坏性能的往往是长事务。在RR隔离级别下一个事务长时间不提交它的ReadView会一直存活MySQL内部的purge线程无法清理该事务创建时间点之前的undo版本。版本链越来越长后续所有快照读都要沿链回溯查询性能随事务时长线性下滑。更隐蔽的问题是长事务持有了大量行锁或间隙锁会让其他事务的写入陷入长等待。无论从锁角度还是MVCC角度长事务都是并发性能的头号杀手。我在团队里推过一个硬性规范事务内禁止出现外部API调用、文件读写、用户交互这类耗时操作业务校验尽量在事务开启前完成事务只保留必要的数据库写操作。同时监控trx_started字段抓出超过30秒的异常事务。6. 高频追问MVCC和锁的分工边界6.1 一句话版本快照读靠MVCC当前读靠锁回答“MVCC和锁的关系”最凝练的一句话是MVCC让读操作不需要等锁锁让写操作不会互相覆盖。两者配合的焦点在隔离级别上——RR级别用MVCC解决普通读的一致性用Next-Key Lock解决当前读的幻读。RC级别用MVCC保证读不阻塞写用Record Lock保证写互斥但放弃了对幻读的防御。进阶的追问是“如果只用MVCC不用锁会发生什么”。答案是完全依赖版本仲裁就会退化为乐观并发控制InnoDB本身的实现并不支持这种方式它必须通过锁来实现写入串行化。反之只用锁不用MVCC事务隔离级别必须全部退化为Serializable事实上回归到数据库发展早期的两阶段锁模型。6.2 与Redis分布式锁的边界区分顺着热搜词里“分布式锁”再往外扩一步。MySQL的锁是数据库实例内部的资源仲裁机制与Redis分布式锁属于两个层级。分布式锁解决的是“多个进程/服务实例之间如何互斥访问共享资源”比如定时任务只允许一个节点执行MySQL行锁解决的是“同一数据库事务之间如何避免写冲突”。常有人混淆二者的适用场景有人用Redis锁扣减MySQL库存认为锁能避免超卖但Redis锁只能保证同一时刻一个线程进入临界区库存扣减的原子性还是要由数据库事务的锁来保证反过来有人用MySQL分发表锁做跨服务互斥结果因为长事务拖垮了整个库。正确姿势是跨进程互斥选分布式锁进程内数据一致性选数据库锁两者解决不同问题不能互相替代。6.3 连贯追问undo log膨胀与清理机制MVCC和锁的配合还有一个隐含成本undo log不能无限膨胀。InnoDB通过purge线程清理不再需要的undo版本但清理条件受两个因素限制——事务是否已提交以及所有活跃ReadView是否早于该undo版本。第一个条件保证已经提交的事务可以清理第二个条件保证没有任何事务还需要读到该版本。只要存在一个长事务持有了较早的ReadView即使其他事务都已提交它之前的undo版本也会滞留磁盘。曾见过一个低频业务表因为每天凌晨的定时任务事务悬挂数小时导致该表膨胀到3GB普通查询因版本链回表变得极慢。遇到这种情况除了修代码还要考虑使用innodb_undo_tablespaces合理分布undo表空间并监控undo表空间增长趋势。6.4 业务设计层面的取舍经验在真实的业务设计里MVCC和锁的取舍往往取决于读写的比例和一致性要求。读多写少的场景优先依赖MVCC的快照读能力尽量减少LOCK IN SHARE MODE和FOR UPDATE的使用写多读少且有严格一致性的场景确保当前读加锁的范围足够小尽量命中主键或唯一索引避免锁定范围扩散到整个索引区间。我最后再分享一个实际的优化案例。当时一个订单列表接口SQL是SELECT * FROM orders WHERE user_id ? ORDER BY id DESC LIMIT 20高峰期出现大量锁等待超时。排查发现orders表在user_id上没有索引RR隔离级别下更新操作锁定大量行与订单创建的INSERT发生间隙锁冲突。解决方案很简单给user_id加上普通索引减少扫描范围锁粒度从近似表锁降级为单个用户订单范围的边界锁。上线后锁等待直接消失接口P95延迟从800毫秒降到80毫秒。这就是MVCC和锁机制从原理层面对业务产生真实价值的典型案例。