
1. 一个死锁事故引发的提问锁、隔离级别和MVCC到底有多纠缠处理过不少线上问题之后我对一件事特别有感触很多人能背出事务隔离级别的概念能说出MVCC的全称也知道锁有共享锁排他锁之分但真遇到问题的时候这三个东西在他们脑子里是割裂的。上个月我排查一个入库任务经常出现死锁的caseSQL很简单就是个UPDATE order SET status 2 WHERE user_id 123结果时不时抛出Deadlock found when trying to get lock。看代码逻辑先查订单再改状态再插入流水一套事务下来再正常不过。但就是这种“正常”代码在高并发下撞车了。打开SHOW ENGINE INNODB STATUS锁定的事务是两条不同订单高并发优先在改同一条用户维度汇总记录。第一反应当然是加索引可这条SQL已经走了索引。那问题出在哪全在事务隔离级别和MVCC给我们制造的“快照认知”和真实加锁行为不一致上。这就是我想写这个系列的原因。MySQL这碗饭你和它和平共处的前提是真正理解锁机制、事务隔离、MVCC这三兄弟是怎么配合演一出“并发安全”的戏的。这一篇先讲透核心框架InnoDB 的锁到底在什么粒度上锁、事务隔离级别怎么定义“读什么版本”、MVCC 如何用多版本串起一条时间线。把这些底层逻辑理清死锁、幻读、长事务这些现象就都有了根排查时就不会靠瞎试。2. 从并发问题到隔离级别所谓的“脏读、不可重复读、幻读”到底在纠结什么2.1 没有控制时的三个混乱现场老规矩先摆问题。事务并发执行没有管控的话会出现三种典型乱象。我用实际操作来演示比干讲概念直观得多。先准备一张简单的表存储引擎必须是InnoDB字段没什么讲究CREATE TABLE t_user_order ( id int NOT NULL AUTO_INCREMENT, user_id int NOT NULL, order_amount decimal(10,2) DEFAULT 0, status tinyint DEFAULT 0, PRIMARY KEY (id), KEY idx_user_id (user_id) ) ENGINEInnoDB;脏读事务A改了数据但还没提交事务B把这个未提交的值读出来了然后A发生回滚B后面再用这个数据就属于拿着已经不存在的“脏值”做决策。这里关键是读到了“别的未提交事务的中间状态”。不可重复读事务A先查了一次user_id123的订单金额得到100之后再查一次因为事务B在这期间提交了金额修改结果A第二次查出200。同一事务内同一条SQL读到不同结果。关键在于“当前读”后面细说被别的事务已提交改动影响读的不是稳定快照。幻读事务A按条件status1查出了5条记录。事务B插入了一条新的status1记录并提交。事务A再用相同条件查询发现多了一条像是幻觉一样。看起来和不可重复读像但本质上不可重复读针对一条记录的更新/删除幻读针对“结果集的条数变化”或者新记录对范围造成的影响。这三种问题逼出了SQL标准里的四个隔离级别隔离级别脏读不可重复读幻读Read Uncommitted读未提交可能可能可能Read Committed读已提交不可能可能可能Repeatable Read可重复读不可能不可能InnoDB已基本解决Serializable串行化不可能不可能不可能MySQL默认隔离级别是Repeatable Read就是RR。也就是说InnoDB在RR下不仅解决了不可重复读还通过间隙锁和MVCC把幻读带掉了这比SQL标准要求的还高。这篇先不过多展开锁先把隔离级别的“读”语义说清楚。2.2 快照读与当前读同一句SQL怎么读出两个世界理解隔离级别落在InnoDB实际实现上必须分清两个读法。这不是概念咬文嚼字这是后面所有加锁现象的分水岭。快照读Snapshot Read就是不带FOR UPDATE、LOCK IN SHARE MODE的普通SELECT。它读的是事务开始时的某个历史快照不加锁所以在InnoDB层这叫非锁定读。当前读Current ReadSELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE、INSERT都需要读当前已提交的最新数据还要对涉及记录加锁。在RR隔离级别下普通SELECT通过MVCC读快照所以对同一条数据反复读都能拿到一致结果天然避免了不可重复读。而UPDATE这种当前读必须拿最新版本因为你要改它不能改着改着拿个旧版本。所以就出现了一个反直觉现象在一个RR事务里你先普通SELECT看到金额100然后另一个事务提交改成200你再普通SELECT还是100可如果你直接执行UPDATE ... SET amount amount 50它把最新值200取出来改了结果变250。很多人在这个地方踩坑其实就把快照读和当前读混在一起了。读已提交RC级别更简单粗暴每次执行快照读都生成一个新的快照所以能看到别人已提交的改动因此才会不可重复读。它也没有间隙锁所以幻读在RC下依然存在。隔离级别决定的是“当前读和快照读分别以什么规则找数据”而MVCC就是为快照读提供那条“历史版本链”的机制。所以下面必须把MVCC底层那几件套讲明白。3. MVCC不复杂隐藏列、undo log和ReadView三个零件就拼起来了3.1 InnoDB每一行数据自带的三个隐藏标记MVCC的全称是多版本并发控制但很多人一上来就被“多版本”唬住了。实际你只需要理解一件事InnoDB在磁盘上存的数据行并不是只存你定义的字段它还在每一行里偷偷放了额外信息。三个关键隐藏列DB_TRX_ID最近一次对该行记录执行修改插入或更新的事务ID。注意是“最近一次”。DB_ROLL_PTR回滚指针指向该行的上一个版本在undo log里的位置。DB_ROW_ID隐式自增ID如果表没有显式主键InnoDB会用这个作为聚簇索引。每次你UPDATE一行InnoDB不是原地把旧值擦掉而是把旧值拷贝进undo log当事务提交后这些旧值也不是立即清掉而是等没有事务需要它们时再由purge线程清理。然后新值写入数据页同时把DB_TRX_ID改成当前事务ID把回滚指针指向刚才写入undo log的旧版本记录。所以“多版本”的含义是一行记录在逻辑上有很多历史版本这些版本以undo log串成一条链物理上最新值在聚簇索引里老版本在undo log里。读数据的时想读哪个版本就沿回滚链找。这个设计同时给了InnoDB两个能力事务回滚时不需要“反向执行SQL”直接把回滚指针指回旧版本就行恢复快而且不污染其他事务。快照读拿旧版本值时不需要等正在修改该行的事务提交读操作不阻塞写操作写操作也不阻塞快照读这就是InnoDB并发读性能好的核心来源。3.2 ReadView一套生成“可见版本”的判断规则光有版本链不够还得有个规则说清“我这个事务能看到哪个版本”。规则就是ReadView。所谓ReadView可以粗浅理解成事务在进行快照读的那一刻给整个事务系统拍了一张“活跃事务清单”的照片记录几个核心信息m_active_ids生成ReadView时还活跃未提交的事务ID集合。m_low_limit_id生成ReadView时刻尚未分配的最小事务ID。比这个更大的事务ID都是在这个ReadView生成之后才启动的不可见。m_up_limit_id活跃事务集合里最小的那个事务ID。m_creator_trx_id创建当前ReadView的事务自己的ID。判断一行数据版本是否可见规则就是拿数据行的DB_TRX_ID去对ReadView里的几个值比较条件可见性DB_TRX_IDm_up_limit_id可见。这个版本是当前ReadView生成前就提交的老版本。DB_TRX_IDm_low_limit_id不可见。这个版本是在ReadView之后才产生的最新改动。m_up_limit_idDB_TRX_IDm_low_limit_id分两种情况如果DB_TRX_ID在m_active_ids里说明修改者还活跃不可见如果不在说明已经提交了可见。如果读到的版本不可见就沿着回滚指针往旧版本找一直找到可见的为止。这里就是RC和RR在MVCC层面的分界点RC级别事务内每一次快照读都生成一个新的ReadView。所以同一个事务早晚读到不同的已提交新值不可重复读就这么来的。RR级别只在第一次快照读时生成ReadView后面整个事务都用这同一个ReadView。后续哪怕别的版本提交了你依然只能看到自己事务开始时那个世界的样子所以RR天然规避了不可重复读。但请注意这个“固定ReadView”只对快照读有效。当前读拿到的是什么版本不是靠ReadView而是靠锁去拿最新已提交版本。这就到了锁的舞台。3.3 一个简单例子把MVCC走一遍我还用t_user_order表举例。假设初始事务ID递增当前最大事务ID是100有一条订单id1, user_id123, amount100。事务Atrx_id102启动执行普通SELECT生成了一个ReadView此时活跃事务是[102]。事务Btrx_id103启动执行UPDATE t_user_order SET amount200 WHERE id1未提交。B把旧值100拷贝进undo log新值200写入聚簇索引DB_TRX_ID103回滚指针指向旧版本。事务A再执行普通SELECT拿着快照读的ReadView看到id1的数据行的DB_TRX_ID103103比ReadView的最小活跃事务ID102大而且在ReadView生成的时刻103还没分配出来因此不可见于是沿回滚链找到旧版本旧版本DB_TRX_ID100小于m_up_limit_id可见返回100。事务B提交。事务A第三次普通SELECT还是同一个ReadView结果还是100。如果事务A这时执行SELECT ... FOR UPDATE想看最新值那就是当前读InnoDB会定位到最新物理版本并加锁拿到200。这就是MVCC同一行数据上快照和当前读两条路径的完整逻辑链路。4. InnoDB锁的分类体系从全局锁到行内锁到底谁挡谁的路4.1 按粒度分层全局锁、表级锁、行级锁MVCC解决了读的并发但写和写之间、当前读和写之间要靠锁。InnoDB的锁从粗粒度往细粒度排可以分为三层。全局锁锁整个实例命令是FLUSH TABLES WITH READ LOCK简称FTWRL。这个一加上全库只读通常只在全库备份场景会用。它的特点是重量级线上业务不能用它来锁表做数据修复会把写入全堵死。表级锁Metadata LockMDL和LOCK TABLE ... READ/WRITE。MDL是MySQL自己维护的防的是DML和DDL并发操作同一张表导致的结构不一致。大家平时执行DDL发现一直等待大概率就是卡在MDL上。注意MDL在8.0中是一把“隐性锁”不用你手动加它存在的意义是保证“读表的元数据时元数据不变化”。手动LOCK TABLE这种方式在InnoDB里已经被当成一种低效手段基本不推荐。行级锁这才是InnoDB真正精细化的锁也是本文重点。4.2 按锁模式分S锁与X锁以及它们的兼容关系行级锁按模式核心就两种共享锁S Lock读锁允许其他事务再加S锁但不允许加X锁。排他锁X Lock写锁其他事务不能加任何锁包括S锁和X锁。没有X锁和S锁的世界会乱套你读数据不加锁别人就能同时改当前读就无法保证稳定。所以当前读分两类SELECT ... LOCK IN SHARE MODE加S锁SELECT ... FOR UPDATE加X锁UPDATE/DELETE/INSERT加X锁。锁兼容矩阵用一张表说明当前事务持有锁当前事务持有锁新请求的锁新请求的锁共享锁(S)排他锁(X)共享锁(S)兼容不兼容排他锁(X)不兼容不兼容写写冲突、读写冲突当前读都靠这个矩阵拦住唯有快照读不参与矩阵判断因为快照读通过undo log拿旧版本不走锁。4.3 按记录范围分Record Lock、Gap Lock、Next-Key Lock行锁又按锁住的范围细分为三种。Record Lock记录锁锁住索引记录本身。注意一个经常被忽略的点InnoDB行锁锁的是索引记录不是“数据行”。如果没有索引约束InnoDB会走隐藏的聚簇索引来锁。因此“锁加在索引上”这句话要刻在脑子里。加了唯一索引或主键时锁定单条记录用Record Lock就够。Gap Lock间隙锁锁住两条索引记录之间的区间范围目的是禁止其他事务在这个区间插入新数据。还有两种情况会锁住整个“开区间”一种是范围查询命中的间隙在正无穷方向另一种是查询条件没有任何索引那可能锁整个索引区间代价极大。Next-Key Lock临键锁这是Record Lock Gap Lock的组合体锁住的是一段“左开右闭”的区间同时锁住记录本身以及该记录前面的间隙。比如索引值有10、20临键锁可能锁住(10,20]这个区间。它是InnoDB在RR隔离级别下解决幻读的核心武器锁定了范围区间别人在这个区间里插不进新记录自然不会有新纪录冒出来。4.4 插入意向锁插入前的“礼貌协商”既然提到间隙锁就躲不开插入意向锁Insert Intention Lock。它本质上是一种特殊的间隙锁表示一个事务想要往某个间隙里插入数据。它和间隙锁的关系是多个插入意向锁之间互相兼容因为它们各自要插入的位置不一定冲突但插入意向锁和间隙锁冲突一旦别的事务持有这个间隙的Gap Lock插入意向锁就要等待。这个细节很多人忽略但死锁排查时经常撞见事务A锁住了(10,20]的间隙事务B想往id15处插入形成了插入意向锁排队等待。如果事务A同时又需要等事务B持有的一把锁死锁就瞬间成立。5. RR隔离级别下的加锁心智模型普通SELECT、当前读和写操作锁什么5.1 三种场景的锁行为对照把所有概念合起来实际在RR隔离级别下一条SQL会怎么加锁我总结成三种场景。第一种普通SELECT。属于快照读不加任何锁通过MVCC版本机制完成一致性读。第二种SELECT ... FOR UPDATE、UPDATE、DELETE。这里对命中索引条件的记录加X锁同时对涉及范围加Gap Lock或Next-Key Lock。具体加哪种看条件WHERE id 1主键命中唯一记录只加Record Lock因为不存在范围问题。WHERE user_id 123非唯一索引命中多条或者可能有新值插入InnoDB会对命中的索引记录加临键锁同时对本组记录间隙加Gap Lock。WHERE amount 100按非索引字段过滤会锁整个索引范围。这也是为什么批量更新没走索引会锁全表不是“InnoDB专门锁全表”而是它没办法通过索引界定记录只能退而求其次把所有可能的索引区间都锁上。第三种INSERT。如果要插入的位置已被别人持锁会先生成插入意向锁并等待一旦拿到机会就插入记录并对新记录加X锁。如果唯一键冲突则会在冲突的唯一索引上加S锁然后去检查是否有死锁风险。5.2 二次刷新“索引顺序决定锁顺序”的教训死锁最常见诱因不是锁用错了而是多个事务获取锁的顺序不一致。举个我真实遇到的场景事务A做两件事UPDATE t_user_order ... WHERE id10然后UPDATE t_user_order ... WHERE id20。事务B则反过来先更新id20再更新id10。两个事务几乎同时启动A持有id10的X锁等着id20B持有id20的X锁等着id10互相等待不释放死锁出现。在RR级别下这个问题更刺眼因为不仅锁记录还可能锁间隙加锁范围更大冲突概率更高。解决思路非常土但有效所有事务内对多个资源的加锁顺序必须全局统一要么都按主键升序操作要么把多个更新塞进同一把“业务锁”保护。真遇到死锁别慌先看SHOW ENGINE INNODB STATUS的LATEST DETECTED DEADLOCK段InnoDB会把两个事务各自持有的锁和等待的锁打出来能定位到是哪两条SQL的资源顺序冲突。5.3 间隙锁不是白拿的它对“插入性能”的隐形伤害这句话听起来像废话间隙锁会把相邻范围卡住插入被迫排队。很多人以为只有在频繁范围删除/更新时才有问题实际上即使你只是对一张id自增的表执行UPDATE ... WHERE id 100在RR级别下InnoDB会把100到正无穷之间的间隙全锁住其他试图插入id101、102、103的请求全部排队等待这个更新事务提交。如果这个事务又很长比如更新中间还调了接口、查了库存、甚至SELECT ... FOR UPDATE了别的表那插入侧耗时肉眼可见起飞。这就是线上常见的“某个表频繁insert但突然insert全部卡住”的典型原因之一。排查方法很简单去information_schema.innodb_trx看长事务检查是否存在间隙锁等待。所以在业务场景确定可以接受的情况下很多人会把线上隔离级别从RR调成RC。RC下InnoDB只存在Record Lock不再有Gap Lock和Next-Key Lock插入并发立刻上来但代价是幻读可能性增加同一事务多次查询结果条数可能变。取舍逻辑就是这样没有银弹。6. 实际排查与调优把底层原理落到具体问题里6.1 三个命令快速看清当前锁状态方法一SHOW ENGINE INNODB STATUS。这个命令能看到最近一次死锁的详细信息包括事务持有锁的模式、等待锁的SQL、索引信息。但要提醒它只记录最近一次死锁如果死锁频繁最好打开innodb_print_all_deadlocks1这样所有死锁都会写进错误日志排查记录不丢。方法二查询performance_schema.data_locks和data_lock_waits两张表。前者能看到当前每把锁的类型、模式、锁在哪个索引哪个记录上后者能看到等待关系。这是比老的information_schema.innodb_locks更建议的手段尤其锁比较多的时候一条SQL就能把等待链拉出来SELECT requesting_trx_id, blocking_trx_id, REQUESTING_ENGINE_TRANSACTION_ID, BLOCKING_ENGINE_TRANSACTION_ID FROM performance_schema.data_lock_waits;方法三查询sys.innodb_lock_waits视图这个视图把等待信息直接连成了可读的行能看到谁阻塞谁SELECT * FROM sys.innodb_lock_waits\G6.2 一个“索引导致的锁范围扩大”案例有次做性能测试一张表只有主键业务SQL是UPDATE t_order SET status 2 WHERE order_no SO20250101;order_no没有索引。这个SQL执行时InnoDB的搜索路径是全表扫描聚簇索引把所有读到过的记录都加上X锁直到确认没有满足条件才释放。并发高了以后这把X锁把表上大部分插入都堵住。加了一个普通索引ALTER TABLE t_order ADD INDEX idx_order_no(order_no);问题立刻消失。锁粒度从“全表扫描的聚簇索引范围”收敛到“索引命中的少数记录”。这个改动没人动业务代码线上一分钟完成。如果你没理解“锁是加在索引上”这个底层机制遇到这类问题很容易误判成“事务太长”或“锁等待配置太小”方向全错。6.3 隔离级别选择建议RR不是默认就是最优把本篇所有知识点串起来选择时我给团队默认原则是没有强一致要求的主流程优先考虑RC来降低间隙锁竞争有报表、统计类多次查询必须确保结果稳定的留在RR能用代码控制并发写顺序的不要依赖串行化级别。实际运维中有一个常见操作误区为了“提升性能”把隔离级别改成READ-UNCOMMITTED。这等于放弃事务核心保证脏读问题会直接暴露在业务结果里。真出现读性能问题优先考虑的是减少大事务、优化索引、让快照读走MVCC而不是降级隔离级别。最后补一句排查时很容易漏的长查询也会持有MVCC的版本链阻止undo log被purge导致undo膨胀间接拖慢所有写操作。你以为在调整锁其实元凶是“一个几小时的SELECT报表”拽着版本链不放手。所以看锁的时候顺手看下information_schema.innodb_trx里的trx_started找那些跑了很久的事务顺手把它停了可能比调任何参数都管用。这一篇把锁和隔离级别、MVCC的关系链路理通了下篇可以接着细讲死锁日志每一段到底在说什么以及生产环境怎么用这些知识去设计更稳的并发更新逻辑。