MySQL锁机制与MVCC深入解析:从并发控制到线上排障
做后端开发这些年被MySQL并发问题坑过太多次。明明一条简单的UPDATE在业务量上来之后就偶尔报锁等待超时明明隔离级别设成了可重复读却还是能读到别的事务提交的数据。这些问题绕不开两个底层机制锁机制和MVCC。今天这篇就把它们从头到尾拆一遍不讲八股文更多是我在实际排查和调优中的理解。读完你不仅能把“锁的分类”“MVCC原理”这类面试题答明白还能拿着这些知识去处理真实的线上问题适合后端开发、DBA、以及准备技术面试的工程师。先给出一个总览MySQL里保证并发安全靠两条腿走路一条是锁机制负责让多个事务在读写同一份数据时“排队”另一条是MVCC多版本并发控制负责让读操作不去堵写操作写操作也不堵读操作。你可能听过“MVCC能提升并发性能”但很多人并不知道它到底是怎么实现的。所以下面先从最基础的锁说起再深入到MVCC的版本链和ReadView最后给一些我实际用过的排障命令和避坑经验。1. 并发控制的第一道闸门MySQL 锁机制全景拆解1.1 为什么数据库需要锁从并发异常说起先想一个最简单的场景两个人同时在某电商平台下单都去扣同一件商品的库存如果数据库不做任何限制两个人的“扣减库存”操作会交叉执行最后库存数很可能变成负数。这就是并发更新导致的丢失更新。除了丢失更新没有锁还会带来脏读、不可重复读、幻读等一系列问题。脏读指的是一个事务读到了另一个事务还没提交的数据。举个例子事务A把用户余额改成1000但还没提交事务B就在这时读余额读到1000。如果事务A最终回滚那事务B读到的1000就是凭空捏造的脏数据。不可重复读更隐蔽同一个事务里第一次执行SELECT和第二次执行SELECT读到同一条记录的值不一样。原因是另一个事务在这期间修改并提交了这条记录。幻读则是另一个事务插入了新记录导致同一范围查询的“结果集数量”发生变化。最经典的场景事务A先查出所有商品库存小于10的商品事务B突然插入一条库存为5的新商品并提交事务A再查一次发现多了一行“幻影”记录。锁机制要解决的就是这些东西把对同一份数据的访问变成互斥的、可排序的让每个事务都感觉“别人没在动我的数据”。MySQL的InnoDB引擎在这件事上做得相当细致它没有用一把大锁锁住全表而是把锁的粒度拆成了多个层次这也是InnoDB在高并发下远比MyISAM能打的原因。1.2 锁的粒度与分类从全局锁到行锁从粒度大小来说InnoDB下的锁可以分成四类全局锁、表级锁、行级锁、元数据锁。用一个表格看得很清楚锁类型范围典型场景说明全局锁整个实例全库备份FLUSH TABLES WITH READ LOCK会让所有表变成只读表级锁单张表表结构变更、MyISAM存储粒度大并发能力差InnoDB建议少用元数据锁表结构DDL与DML并发自动加防止改表时有人正在读写行级锁索引记录/间隙高并发更新InnoDB核心能力锁粒度最小全局锁最容易理解执行FLUSH TABLES WITH READ LOCK之后整个库只能读不能写。你可能会问现在备份工具一般都用一致性快照还要这种锁吗其实在早期或者某些需要“物理备份”的场景全局锁仍然存在但它会堵住所有写操作线上用起来风险极大我见过有人手抖执行了整个业务瞬间卡死最后只能赶紧释放。表级锁里有两种很容易混淆表锁和元数据锁MDL。表锁是显式的LOCK TABLES ... READ/WRITE现在InnoDB下基本不会主动用。MDL则是MySQL 5.5引入的“隐形锁”你执行ALTER TABLE改表结构时MySQL会自动对表加MDL写锁如果这时候有长事务正拿着这条数据的行锁做更新DDL会一直等MDL锁直到那个长事务结束。很多工单系统里“改表结构超时”本质就是被MDL锁住了。行级锁才是InnoDB的看家本领但它不是直接锁“一行物理记录”而是锁“索引记录”。这一点极其关键后面我会单独拿出一节来讲。先记结论InnoDB所有行锁都是通过索引实现的如果更新条件没有走索引InnoDB会扫描全表然后给每一条扫描到的记录加锁这等于“用行锁实现了表锁”并发性能瞬间崩掉。2. 行锁还是间隙锁InnoDB 锁的底层实现细节2.1 记录锁、间隙锁、临键锁的差异InnoDB的行锁并非只有一种根据锁定的范围和目的分成了三种记录锁Record Lock、间隙锁Gap Lock、临键锁Next-Key Lock。记录锁最简单就是只锁住某一条索引记录。比如SELECT * FROM goods WHERE id 10 FOR UPDATE如果id是主键就只锁住id10这行别的记录还能正常读写。间隙锁锁的是一个“开区间”范围不锁任何具体记录而是锁住记录之间的空隙。它的目的是防止其他事务在这个空隙里插入新数据。在默认的REPEATABLE READ可重复读隔离级别下MySQL为了防幻读会主动加间隙锁。举个例子现在goods表里id有1、5、10三条记录事务A执行SELECT * FROM goods WHERE id 5 FOR UPDATEInnoDB除了锁id10的记录外还会在(5,10)和(10,正无穷)这两个区间上加间隙锁。这样事务B想插入id6或者id100的新记录都会被挡住。临键锁是记录锁和间隙锁的组合它锁的是一个“左开右闭”区间比如(5,10]这种。它既锁住id10这个记录又锁住(5,10)这个间隙是InnoDB在可重复读下的默认行锁策略。记住一个口诀默认RR隔离级别下InnoDB对索引记录加的都是临键锁只会在某些条件下退化成记录锁或间隙锁。为什么要设计得这么复杂单纯为了一个“幻读”问题。MVCC的快照读能解决一部分幻读但更新操作要靠锁来保证范围插入不被并发破坏于是间隙锁和临键锁就出现了。代价是并发度变低特别是更新一个大范围条件时锁住的间隙会造成大量阻塞这也就是为什么很多团队会把隔离级别调到READ COMMITTED来换取更高并发只是需要自己在业务层面处理幻读。2.2 索引与锁的关系为什么锁的是索引而不是行第一次看InnoDB源码或者执行计划时你可能会困惑我明明只想更新一行怎么锁住了好几行原因就是InnoDB所有行锁都挂在索引上。如果表里没有合适的索引InnoDB会去主键索引加锁如果没有主键它会隐式生成一个ROWID当主键。实验最能说明问题。建一张表CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(32) NOT NULL, age INT NOT NULL, KEY idx_age (age) ) ENGINEInnoDB;现在在事务A里执行START TRANSACTION; UPDATE t_user SET name lisi WHERE age 25;如果age列上有索引InnoDB会走idx_age对所有age25的二级索引记录加锁同时通过回表对对应的主键记录加锁。如果age列没有索引那情况就完全不同了InnoDB必须扫描全表它不是只找到age25的行而是把扫描过程中经过的所有行都加上了锁。看起来像“锁住了整张表”实际是每一行都被记录锁/临键锁覆盖了其他事务想更新任意一行都会被阻塞。这个知识点直接决定了开发规范里的一条铁律UPDATE和DELETE语句的WHERE条件必须命中索引否则容易造成锁表。我在业务代码里见过太多事故一条UPDATE t_order SET status 2 WHERE merchant_id 123如果merchant_id没建索引线上流量一大所有对这张表的更新全部锁等待数据库的“并发更新”直接退化成“串行更新”。2.3 死锁的产生与处理死锁是锁机制绕不开的话题。死锁的官方定义两个或两个以上事务各自持有对方需要的锁并且互相等待谁都不让谁。一个典型死锁场景事务A先UPDATE t WHERE id 1再UPDATE t WHERE id 2事务B先UPDATE t WHERE id 2再UPDATE t WHERE id 1。A拿到id1的锁B拿到id2的锁然后A等id2B等id1双方僵持。MySQL不是干等着它有个后台机制叫死锁检测每执行一个事务操作都会检查当前是否形成了死锁等待环。一旦检测到死锁InnoDB会选一个“代价较小”的事务作为牺牲者直接回滚它并返回一个Deadlock found when trying to get lock的报错。很多人问死锁检测完全靠自动开发就不用管了吗不对。自动回滚只是兜底高并发下死锁频繁发生会带来两个问题一是被牺牲的事务做了白工应用层如果不重试业务就失败二是死锁检测在高并发场景下本身有性能开销。所以实践中更推荐“主动防死锁”常用手段有固定访问顺序多个事务操作多行数据时都按主键从小到大的顺序执行。缩小事务范围减少一个事务里处理的记录数量。避免大事务事务里不要混入远程调用、消息发送这类耗时操作。通过索引减少锁范围尽量让SQL精准命中唯一的记录或小的索引范围。我见过一个比较极端的死锁案例两张表做循环引用式的更新A表改B表B表改A表然后账务逻辑里又逆序操作一次死锁日志刷屏。最后把两个方向的更新统一成从主键小的表开始操作死锁直接消失。3. 多版本并发控制MVCC 的底层工作逻辑3.1 undo log 与版本链一行数据的历史版本MVCC这个词听起来高大上核心思路却很朴素不直接阻塞读操作而是让读操作去读取一个历史版本。数据库为每条记录维护多个历史版本读操作根据规则找到自己“应该看到”的版本写操作继续往新版本上面改读写各走各的路互不阻塞。那历史版本存在哪答案是undo log。InnoDB在主角表里会额外隐藏两个字段trx_id最近修改这行记录的事务ID和roll_pointer回滚指针指向undo log里的上一个版本。当一条记录被UPDATE时InnoDB并不是直接把旧值覆盖掉而是先把旧值写入undo log然后更新行记录把新的trx_id写为当前事务IDroll_pointer指向刚才写入的旧版本。多次更新以后一行记录就变成了一条版本链最新版本(trx_id110) - 旧版本(trx_id105) - 更旧版本(trx_id100) - ...每个版本都记录了当时的事务ID和完整数据。你可以把这条链想象成一个员工档案的“修改记录表”每次调整薪资不是把原来的数字划掉而是新增一条记录并指向上一版。这样审计或者回溯时随时能看到历史状态。需要注意的是undo log不是永远保留它会被purge线程清理。清理原则是“没有任何事务需要看到这个版本了”具体和隔离级别、事务活跃时间有关。所以不要指望用undo log做无限期的时间旅行。3.2 ReadView 的核心规则可见性判断有了版本链下一个问题成了事务读某一行时到底沿版本链读到第几个版本这就是ReadView要做的事。ReadView可以理解成“生成本次读操作时数据库里活跃事务的一份快照清单”。它在代码里主要包含几个关键字段m_ids生成ReadView时当前所有“未提交事务”的ID列表。min_trx_idm_ids里最小的那个事务ID。max_trx_id下一个即将分配的事务ID也就是当前最大事务ID 1。creator_trx_id创建这个ReadView的事务自身ID。判断一个版本是否可见规则可以简化成四句话如果版本的事务ID等于creator_trx_id说明是自己改的当然可见。如果版本的事务ID小于min_trx_id说明这个事务在ReadView生成前就已经提交可见。如果版本的事务ID大于等于max_trx_id说明这个事务是在ReadView生成之后才启动的不可见。如果版本的事务ID落在[min_trx_id, max_trx_id)之间需要判断它是否在m_ids列表里在列表里说明还没提交不可见不在列表里说明已经提交可见。如果当前版本不可见就顺着roll_pointer往下找上一个版本一直重复到可见版本或者版本链尽头。这个“循环找版本”的过程就是MVCC的核心动作。为了更容易记住可以类比成看一场电影ReadView是你被允许进入影厅的时刻电影院里已经坐着的人已提交事务你可以看到在你之后进场的人新开启事务你看不到中途进进出出的未提交事务你看不到。你要看的内容不对就往前面一排座位找有没有已经稳定落座的人。3.3 隔离级别下 MVCC 的使用差异MVCC不是在所有隔离级别下都一样工作的。MySQL的InnoDB在READ COMMITTED和REPEATABLE READ下使用MVCC实现方式有一个关键区别READ COMMITTED每次执行普通SELECT都会生成一个新的ReadView。这会导致同一个事务里第一次SELECT和第二次SELECT可能看到不同的已提交数据因为它每次都重新获取“当前活跃事务快照”。这就是不可重复读的成因。REPEATABLE READ事务第一次执行普通SELECT时生成ReadView之后整个事务期间都复用这份ReadView。由于ReadView不变后续所有普通SELECT都基于同一份快照所以在一个事务内重复读相同记录结果必然一致。这里能回答很多人的疑问为什么MySQL默认隔离级别是REPEATABLE READ正是因为MVCC让RR不用加锁就能保证快照读的一致性同时又比SERIALIZABLE并发度高很多。但是要注意MVCC只在“快照读”场景下生效也就是普通SELECT语句。如果是SELECT ... FOR UPDATE、UPDATE、DELETE这类“当前读”读的是记录最新版本并且要加锁这时候ReadView是不参与的判断可见性的就是锁机制。这也是为什么RR隔离级别下居然还可能出现“当前读看到的记录和快照读看到的不一样”这种怪象。3.4 当前读与快照读MVCC 不能解决的问题搞清楚“当前读”和“快照读”的区别比背十个概念都有用。快照读不加锁的普通SELECT走MVCC读历史版本。当前读加锁的SELECT、UPDATE、DELETE读最新版本。MVCC解决了快照读的隔离问题但解决不了当前读的写冲突问题。比如库存扣减这种业务你不能用一个快照版本里的库存数去扣必须读当前最新值再扣否则就会超卖。所以真正的关键更新操作一定走的是当前读也必然要用锁来串行化。在REPEATABLE READ隔离级别下InnoDB为了防幻读对当前读使用了临键锁。它锁住了匹配记录和记录之间的间隙让其他事务无法插入新的记录。这就是MVCC和锁机制的协作点快照读靠MVCC看到一致性视图当前读靠临键锁阻止幻读。有人问MVCC在READ COMMITTED下就不能防幻读吗确实不能因为RC每次读都生成新ReadView对快照读而言新插入并提交的行可能在下一次SELECT中被看到。对当前读而言RC隔离级别下InnoDB只用记录锁不用间隙锁其他事务可以插入新记录因此幻读依然可能发生。所以如果你的业务要求绝对无幻读又不想用SERIALIZABLE只能在REPEATABLE READ下保持默认行为或者自己在应用层加锁。4. 结合实战如何用锁和 MVCC 定位并发问题4.1 查看锁等待与阻塞信息纸上谈兵没用真到线上出了问题第一步是查看当前锁竞争情况。MySQL提供了一组information_schema下的表我经常用这三张innodb_trx当前所有未结束的事务。innodb_locks当前正在等待的锁以及被等待的锁8.0改名为performance_schema.data_locks等。innodb_lock_waits锁等待关系。比如执行这条SQL可以快速看到谁在等谁SELECT t1.trx_id AS waiting_trx_id, t1.trx_state AS waiting_trx_state, t2.trx_id AS blocking_trx_id, t2.trx_state AS blocking_trx_state, t2.trx_query AS blocking_query FROM information_schema.innodb_lock_waits w JOIN information_schema.innodb_trx t1 ON w.requesting_trx_id t1.trx_id JOIN information_schema.innodb_trx t2 ON w.blocking_trx_id t2.trx_id;trx_state为LOCK WAIT的就是正在等锁的事务blocking_trx_id就是堵住它的源头。同时可以查看两个事务各自的trx_started和trx_query判断哪个事务开了太久没提交。在MySQL 8.0里锁信息更多可以借助performance_schema.data_locks和data_lock_waits查询列更丰富能看到LOCK_TYPE、LOCK_MODE、LOCK_STATUS、INDEX_NAME等。如果遇到奇怪的锁问题优先用8.0的视图排查。实操时我一般会先看有没有LOCK WAIT的事务再看它的trx_started是否已经好几分钟如果事务里没有明显的业务操作十有八九是代码里忘了提交或事务过长。4.2 一个经典死锁案例的复盘举一个我实际处理过的库存扣减死锁。业务逻辑大致是订单创建后要同时更新订单表和库存表。两个事务的SQL顺序如下事务AUPDATE t_order SET status 1 WHERE order_id 1001; UPDATE t_stock SET stock stock - 1 WHERE goods_id 88;事务BUPDATE t_stock SET stock stock - 1 WHERE goods_id 88; UPDATE t_order SET status 1 WHERE order_id 1001;两个事务都拿到了各自第一行数据的锁然后都在等对方的第二把锁。死锁检测介入后其中一个事务被回滚应用层报错。这个案例的教训很直白多个事务操作多张表时锁的获取顺序必须一致。如果业务上无法统一SQL顺序那就至少保证在代码层面按固定规则排序。比如拿到一批order_id和goods_id后先按主键排序再执行更新死锁发生的概率会大大降低。另一个容易踩的坑是死锁不代表代码完全错了有时只是“偶发”和“高并发”下的必然结果。线上偶然出现一两次死锁如果没有造成大范围失败可以先观察日志把死锁SQL记录下来在代码里对死锁异常做重试。InnoDB每次死锁选牺牲者时会回滚整个事务所以应用层需要捕获这个异常把事务整体重试一次而不是只重试最后一条SQL。4.3 相关参数调优下面的参数是我在锁问题和MVCC问题排障时最常关注的参数默认值作用我的建议innodb_lock_wait_timeout50事务等待锁的最长秒数超时则报错业务要求高交互时调到10~20秒太长会导致请求堆积transaction_isolationREPEATABLE-READ事务隔离级别没特殊要求别乱改优先业务逻辑解决innodb_deadlock_detectON是否开启死锁检测极端高并发可关掉但风险极大不推荐autocommitON是否自动提交事务务必保持ON手动BEGIN事务后记得COMMITinnodb_lock_wait_timeout这个值很微妙。调太大DBA会收到一堆锁等待告警调太小正常业务稍微排队几秒就报超时。我在绝大多数项目里用20秒既不会让用户长时间卡住也能容忍短时间的锁排队。关于transaction_isolation我的态度是默认RR不要轻易改。有些人为了消除间隙锁带来的阻塞把隔离级别改成RC这确实能提升并发但也会让同一个事务内读到已提交的新数据。如果业务逻辑依赖“事务内结果集稳定”出了Bug反而难排查。真要提升并发优先从索引和事务范围入手而不是改隔离级别。5. 常见问题速查与避坑经验5.1 面试高频题锁机制和 MVCC 常见问题这里挑几个我面试时几乎必问的问题给一个相对完整的答案方向MVCC是解决脏读问题的吗不完全是。MVCC通过版本链和ReadView让读操作不会看到未提交事务的数据所以确实能避免脏读。但它更大的价值是在避免读写互相阻塞的前提下实现不同隔离级别的一致性视图。RR隔离级别下MVCC能完全解决幻读吗对快照读来说基本能解决因为整个事务复用同一个ReadView但当前读不能完全解决只能通过临键锁来阻止幻读。严格说RR下的MVCC加临键锁可以防止大部分幻读但某些特定边界场景下仍可能出现。这也是为什么理论课会把幻读定义成“RR下的一个痛点”。间隙锁在RC隔离级别下存在吗不存在。RC下InnoDB只加记录锁不加间隙锁和临键锁所以RC的并发度更高但可能幻读。如果你看到RC下依然有类似gap锁的信息大概率是外键约束或者复制场景触发的。为什么说“没有索引的更新会锁表”因为InnoDB行锁靠索引实现没有索引时只能全表扫描扫描到每条记录都会加锁导致所有更新都被阻塞结果等同于表锁。建索引后加锁范围可以精确到少量记录这是缓解锁竞争最直接的手段。5.2 实际开发中的六个“不要”这些“不要”是我在代码Review和线上事故里总结出来的每一条都有代价不要用大事务一个事务里塞几百上千条更新锁的持有时间会非常长很容易拖垮整个库。不要用不带索引的WHERE条件做更新它会锁全表。不要在长事务运行期间执行DDLMDL锁会堵住一大堆DML。不要在高并发下随意把隔离级别改成SERIALIZABLE那等于让所有读写串行性能会非常难看。不要在事务里做远程调用、发消息等IO操作锁不释放依赖网络超时会让锁时间成倍放大。不要以为唯一索引就一定会防止并发重复插入如果INSERT没有走唯一索引或者用了INSERT IGNORE依然可能在并发下产生重复数据。5.3 线上 UPDATE 并发问题修复实录最后分享一个我非常典型的修复过程。某次大促订单表出现大量Lock wait timeout exceeded。看innodb_trx发现一个事务更新了订单表里几百条记录持续了快五分钟不提交。顺着业务代码查下去发现是批量对账逻辑把“查询对账单”和“更新订单状态”写在了同一个事务里。查询对账数据本身要好几秒期间锁一直攥在手里后面所有用户的下单更新都在排队。修复方案分了三步第一把“查询对账数据”移出事务只让更新操作留在事务里第二将批量更新拆成每批处理50条的小事务降低单事务锁持有时间第三给每次更新加上LIMIT或者分批ID范围避免一次扫描全表。改动后Lock wait timeout立刻消失。这个案例也再次印证了一个观点锁机制和MVCC的很多问题根源往往不是数据库本身而是事务边界设计和SQL索引设计。我个人在实际操作中还有一个很深的体会真正排查锁问题不要一上来就翻参数先把“谁持有锁、谁在等锁、事务开了多久、执行了什么SQL”四个问题搞清楚。大多数并发问题看information_schema.innodb_trx就能定位到百分之七八十。等你把这些信息串起来再回头看MVCC的版本链和ReadView会发现很多抽象概念一下就通了。希望这篇能帮你少踩几个我踩过的坑。