事务和锁这两个词在数据库领域算得上老面孔了。但说实话看了很多文章、也面试过不少人我发现大家对“数据库事务与InnoDB锁有什么关联”这个问题的理解大多停留在一个很模糊的状态知道事务要隔离、锁能解决冲突可真要问“锁是什么时候加的、什么时候释放的、隔离级别变了锁会怎么变、死锁到底是怎么死的”能说清楚的人并不多。我最早也被这个问题绕晕过。那时候排查一个线上卡顿业务说“我只是执行了一条UPDATE怎么后面所有写操作都堵住了”我盯着SHOW ENGINE INNODB STATUS里的锁信息看了半天才慢慢把事务和锁这两件事在脑子里连成一条线。今天想把这条线彻底捋清楚从原理到实操一次讲透希望能帮到正在被锁困扰的兄弟们。1. 事务的四大特性里锁到底扛了多少活1.1 别把MVCC和锁当成同一个东西先说一个最常见的误解。很多人以为MySQL的InnoDB用了MVCC多版本并发控制所以读操作不阻塞写操作、写操作也不阻塞读操作那“锁”是不是就没那么重要了这是错的。MVCC解决的是“读”和“写”之间的并发问题它是通过undo log里的旧版本数据让一个事务在读取时哪怕别的行正在被修改也能看到一个一致性快照从而避免了加锁。但注意这仅限于快照读——也就是普通的SELECT语句。真正的写操作比如UPDATE、DELETE以及显式加锁的读操作SELECT ... FOR UPDATE / SELECT ... FOR SHARE走的都是当前读。当前读必须读到记录的最新版本不能去读旧快照否则就会出现“两个事务同时改同一行后提交的覆盖先提交的”这种丢失更新问题。这时候锁就出场了。打个比方MVCC像图书馆里同时允许很多人翻看同一本书的不同版本复印件大家谁都不妨碍谁而锁就像这本书只有一本真迹一旦有人在上面做批注其他人就得等着。MVCC负责让“读读”和“读写”尽量并行锁负责让“写写”真正串行。1.2 ACID里谁依赖锁谁不依赖锁把事务的四个特性拆开看原子性Atomicity主要靠undo log。事务执行过程中如果出错可以用undo log回滚到事务开始前的状态。锁在这里参与不多。一致性Consistency这是一条贯穿性约束不是某一个机制单独扛的。锁保证并发写入不互相覆盖约束条件不因并发被破坏间接服务于一致性。隔离性Isolation这是锁的主场。隔离级别定义了两个事务之间的可见程度InnoDB就是通过锁 MVCC的组合来实现不同隔离级别的。持久性Durability主要靠redo log。服务崩溃后能重放日志恢复数据和锁的关系不大。所以如果有人问你“事务和锁有什么关联”最直接的回答是事务要保证隔离性而隔离性在写写冲突这个维度上必须靠锁来实现。明白这一点之后后面所有细节就都能挂上钩了。2. 从一把行锁开始扒开InnoDB的锁类型和加锁时机2.1 最核心的S锁和X锁InnoDB的锁从兼容性上分两大类共享锁S锁也叫读锁。一个事务加了S锁之后其他事务还能继续加S锁但不能加X锁。S锁允许多个事务同时读取同一条记录。排他锁X锁也叫写锁。一个事务加了X锁之后其他事务无论是S锁还是X锁都加不上。X锁意味着“这条记录我准备写了谁也别碰”。它们的关系就是一张兼容矩阵锁类型S锁X锁S锁兼容冲突X锁冲突冲突总之锁的排斥性可以概括成一句话写写互斥、读写互斥、读读不互斥。这也是为什么同一行的并发更新性能上不去的原因——写这条路的并行度天生就低。除了S锁和X锁这种行级锁InnoDB还有两种表级意向锁意向共享锁IS和意向排他锁IX。意向锁的意思是“我这个事务打算对某些行加S锁/X锁先在表级别上做个标记”。它存在的意义是当一个事务想对整张表加锁比如ALTER TABLE就可以快速判断出是否有其他事务正在锁表里的某些行而不需要逐行扫描锁信息。2.2 记录锁之外还有间隙锁和临键锁行级锁还能继续细分按锁定的范围来看记录锁Record Lock锁住索引上的一条记录。注意InnoDB是索引组织表即使你UPDATE时WHERE条件用的是普通字段最终也是通过扫描索引找到对应的聚集索引记录后再加锁。所以没有索引的话加锁范围会非常难看。间隙锁Gap Lock锁住索引记录之间的区间或者第一条记录之前的间隙、最后一条记录之后的间隙。间隙锁是个左开右开的区间目的是防止其他事务在这个间隙里插入新记录从而避免幻读。间隙锁之间不冲突它只和“插入”冲突。临键锁Next-Key Lock可以理解为“记录锁 间隙锁”的组合锁锁住的是一个左开右闭的区间。比如索引上有记录5和10临键锁可能锁的是(5, 10]表示5到10之间的间隙、以及10这条记录本身都被锁住了。为什么会有这么多种锁因为可重复读RR隔离级别要求事务在多次查询时看到同样的行集合不只要锁住已有的记录还得防止别人插入新记录。这个“防插入”的需求就是间隙锁存在的根本理由。2.3 实操中你会遇到哪些加锁场景我用一个非常常见的例子说明电商库存扣减。假如有张表sku_stock里面有一行记录sku_id A100当前库存是5两个用户同时下单-- 事务1用户A BEGIN; UPDATE sku_stock SET stock stock - 1 WHERE sku_id A100; -- 事务2用户B BEGIN; UPDATE sku_stock SET stock stock - 1 WHERE sku_id A100;事务1先执行UPDATE时会对sku_id A100这条记录加X锁。事务2执行同样的UPDATE时发现这条记录的X锁已被持有只能进入等待状态。直到事务1执行COMMIT或ROLLBACK锁被释放事务2的UPDATE才能继续执行。这个过程中事务2的UPDATE语句会一直阻塞阻塞时间取决于事务1什么时候提交、以及事务1在加锁之后到底做了多少别的事情。如果事务1锁完这行之后又去调用外部接口、等了500毫秒才提交那事务2就得跟着等500毫秒。这就是事务和锁的第一个直观关联事务的存活时间直接决定锁的持有时间锁的持有时间又直接决定其他人的阻塞时间。再看一个带范围查询的例子。假设sku_stock表在stock字段上有二级索引现在执行UPDATE sku_stock SET stock stock - 1 WHERE stock BETWEEN 3 AND 8;在可重复读隔离级别下InnoDB不仅要给满足条件的索引记录加记录锁还会给这些记录之间的间隙加间隙锁防止其他事务插入stock值为4、6这些落在(3,8)区间里的新记录。这俩锁合起来就是临键锁。这个逻辑很多人会忽略你以为自己只是改了3到8之间的几条记录实际上你把整个3到8的区间都给堵死了别的插入操作全都会排队。3. 不同隔离级别下事务和锁的联动规则完全不同3.1 隔离级别的本质是对锁的使用策略InnoDB的四种隔离级别读未提交、读已提交、可重复读、串行化区别不仅仅在于“能不能读到别人未提交的数据”更关键是它们对锁的加锁范围、锁的释放时机、是否需要间隙锁有着完全不同的规定。拿读已提交RC和可重复读RR这两个最常用的隔离级别对比维度读已提交RC可重复读RR普通SELECT是否加锁不加锁走快照读不加锁走快照读UPDATE/DELETE加锁的范围只锁满足条件的记录记录锁记录 间隙临键锁锁释放时机语句执行完就释放配合半一致性读事务提交/回滚时才释放是否防幻读不防只能看到提交后的最新版本通过间隙锁防幻读死锁概率相对低相对高简单说RR隔离级别为了让同一个事务内的多次查询保持结果一致采用了激进的加锁策略——不仅锁记录还锁间隙而且锁一直持有到事务结束。RC隔离级别则更宽松它允许一个事务在两次SELECT之间看到其他事务已提交的新数据因此不需要间隙锁锁也能在语句执行完就释放。3.2 RC下的“半一致性读”到底是怎么回事这里有个很隐蔽的细节值得单独说说。在RC隔离级别下UPDATE语句并不是简单地对所有匹配行加锁。InnoDB的半一致性读机制会在执行UPDATE时先去尝试读取已经提交的旧版本数据判断这行到底满不满足WHERE条件。如果某一行已经被别的事务加了X锁InnoDB会读取它最近一个已提交版本看看这行是否真的需要更新。如果这一行不满足更新条件就不等了直接跳过只有确认需要更新的行才会真正进入锁定等待状态。举个例子执行UPDATE orders SET status PAID WHERE user_id 100;如果user_id 100的记录里有一行恰好被另一个事务锁住了而这一行的status本来就是PAID不需要更新RC下InnoDB可以先看一眼已提交版本发现没必要更新就会绕开这把锁继续往下走。这个机制带来的直接效果就是RC下UPDATE的阻塞面比RR小得多。所以在高并发互联网场景里很多团队宁可选RC来降低锁等待和死锁概率代价是允许极少数的重复统计误差。3.3 RR下为什么容易产生间隙锁死锁我们来看一个比较典型的问题场景。RR隔离级别下事务A执行。BEGIN; SELECT * FROM orders WHERE amount 100 FOR UPDATE;假设这条查询覆盖了一些索引记录和间隙事务A拿到了对应范围的临键锁。这时候事务B尝试往这个范围里插入一条amount 200的新订单INSERT INTO orders (amount, status) VALUES (200, INIT);INSERT操作发现无法插入因为目标间隙被事务A的间隙锁挡住了于是事务B开始等待。现在的关键是如果事务A接下来的操作偏偏又要去更新一条事务B已经锁定或者事务B的插入即将涉及的记录就可能形成循环等待。比如事务B虽然插入被阻塞但它可能已经在之前的语句里锁了另外一行而事务A恰好在等这一行。两边各持一把锁、互相等死锁就产生了。这就是事务和锁关联中最典型的负面产物死锁。想要把死锁问题解决好不能只盯着某个SQL看要把它放在事务的完整生命周期里去理解。4. 一次真实死锁的完整排查链路4.1 从报错到现场还原某天我们线上报了这个错ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction业务方第一反应是“咋回事我明明只改了一行”。我打开错误日志发现这条UPDATE后面跟的是账户余额变更再结合代码一分析很快就定位到了两个事务的加锁顺序。当时是大致这样的逻辑-- 事务1从账户A转钱到账户B UPDATE account SET balance balance - 100 WHERE id 1; UPDATE account SET balance balance 100 WHERE id 2; -- 事务2从账户B转钱到账户A UPDATE account SET balance balance - 50 WHERE id 2; UPDATE account SET balance balance 50 WHERE id 1;两个事务都走的是双账户更新先减后加。事务1先锁了id1再去锁id2事务2恰好先锁了id2再去等id1。两边都只拿到了自己需要的第一把锁然后都在等对方的第二把锁——典型的循环等待死锁条件凑齐了。4.2 怎么看懂INNODB STATUS里的死锁信息遇到死锁第一件事不是猜而是去数据库里拉现场信息SHOW ENGINE INNODB STATUS\G输出里找到LATEST DETECTED DEADLOCK段落重点看两个TRANSACTION块每个事务持有哪些锁holding或LOCK WAIT信息哪个事务在等哪条记录的哪类锁事务整体执行了什么SQLSQL Statement会显示出来比如输出里可能会出现*** (1) TRANSACTION: ... LOCK WAIT 3 lock struct(s) ... *** (1) HOLDS THE LOCK(S): ... *** (1) WAITING FOR THIS LOCK TO BE GRANTED: ...HOLDS THE LOCK表示这个事务手里握着哪些锁WAITING FOR说明它在等哪把锁。结合两边事务的HOLDS和WAITING基本就能把锁的等待环画出来了。信息拉出来后回看应用代码确认加锁顺序。这个案例里就是两个事务对id1和id2的加锁顺序恰好相反。死锁的核心成因不在于你用了多少锁而在于多个事务请求锁的顺序不一致形成了一个闭合环。4.3 修复方案和进一步思考修复方式其实很简单让所有事务都按同一个顺序去加锁比如永远先锁id小的账户、再锁id大的账户。这样两个事务都会先去锁id1第二个事务会等第一个事务释放id1的锁不会出现交叉等待。另一个常见修复思路是缩小事务体量——让事务里只保留必要的写操作外部调用、耗时的计算全部移到事务外。事务持续越短锁持有时间越短死锁概率自然就下来了。还有个更隐蔽的情况值得一提就算两个事务加锁顺序一致如果其中一个事务的WHERE条件没走索引实际加锁的记录范围和预期完全不一样也容易产生意外死锁。所以在排查死锁时除了看加锁顺序还要顺手检查一下执行计划确认SQL真的用对了索引。5. 索引和执行计划对锁范围的影响比想象中大得多5.1 不加索引的行锁实际是全表范围的锁这是InnoDB很容易被人误解的一点。很多人说InnoDB支持行锁就以为“我UPDATE一条记录只锁这一条”。但InnoDB的行锁是建立在索引基础上的——它锁的是索引记录不是直接锁“物理行”。如果UPDATE的WHERE条件没有用到任何索引InnoDB只能从聚集索引的第一条记录开始全表扫描把所有扫描过的记录都加上X锁直到确认不满足条件的才放掉。听起来是“扫描过程中会锁所有行”实际效果几乎等同于锁表。大量写请求都会在这里排队。来看一个典型场景UPDATE user_order SET status CLOSED WHERE user_name 张三;如果user_name字段上没有索引这条SQL执行时InnoDB会逐条扫描所有用户订单记录先加X锁再判断user_name是否为张三。不是张三的记录虽然最终会解锁但扫描期间依然让很多人卡在了锁等待上。同样是这条SQL如果在user_name上建了普通索引InnoDB就能利用二级索引快速定位到张三的订单加锁范围大幅缩小。所以一句经验之谈写操作的WHERE条件必须走索引这不是性能优化建议而是并发正确性问题。没有索引的UPDATE等于给整张表按下了串行化开关。5.2 联合索引的“最左前缀”也在影响加锁索引设计对加锁范围的影响不止在于“有没有索引”还在于“SQL用到了索引的哪一部分”。举个例子假设order表上有联合索引(user_id, status)。UPDATE order SET amount amount * 0.9 WHERE status PAID;这条SQL只有status条件不满足联合索引的最左前缀原则优化器很可能不走这个索引而是选择全表扫描或扫另一个可用索引。加锁范围同样不可控。但如果WHERE条件带上user_idUPDATE order SET amount amount * 0.9 WHERE user_id 100 AND status PAID;联合索引就能从左到右匹配InnoDB会精准定位到符合条件的那几条索引记录加锁范围大大缩小。这个细节提醒我们写过WHERE条件时不仅要看“有没有索引”还要看“优化器到底选没选上”。最直接的办法是写完SQL之后顺手看一下执行计划EXPLAIN UPDATE order SET amount amount * 0.9 WHERE status PAID;观察key列有没有用到预期索引、rows列大概扫描了多少行。如果rows的数字远大于实际匹配行数那加锁范围大概率也会比预期大很多。5.3 插入语句表面上是新记录其实也会碰锁很多人觉得INSERT是插入新记录跟锁没什么关系。实际不是。刚才提到过INSERT在RR隔离级别下如果插入位置落在某个事务持有的间隙锁范围内会被阻塞。此外InnoDB里还有一种插入意向锁Insert Intention Lock。它其实是间隙锁的一种强调的是“我打算在这个间隙里插一条记录先占个位”。多个事务同时往同一个间隙插入不同值的记录时插入意向锁之间是兼容的可以并发插入但如果这个间隙已经有一个事务加了间隙锁那所有插入意向锁都得等。自增列还有一个自增锁AUTO-INC Lock在批量插入时持有。如果业务里频繁执行大批量INSERT自增锁同样可能成为写并发瓶颈。在这个问题上最容易犯的错误是只分析SELECT或UPDATE的锁忽略了INSERT语句本身对锁的依赖结果死锁现场分析半天也找不到头绪。6. 实际调优中最值得收藏的几个锁经验6.1 锁信息应该怎么看遇到锁问题常用的查询手段不只是SHOW ENGINE INNODB STATUS。MySQL 8.0以后performance_schema里有一组专门看锁的表-- 查看当前持有锁的事务 SELECT * FROM performance_schema.data_locks; -- 查看锁等待关系 SELECT * FROM performance_schema.data_lock_waits;data_locks能看到锁定对象、锁类型、锁模式以及哪个事务持有data_lock_waits能看到哪个事务在等哪个事务。还有一个更基础的办法在information_schema里看正在运行的事务SELECT trx_id, trx_state, trx_started, trx_query FROM information_schema.innodb_trx;通过trx_started字段可以判断某个事务到底跑了多久。如果一个事务持续了很长时间还没提交那它的锁也一直没释放后面排队的请求会越攒越多。遇到生产环境卡顿我的习惯是先看innodb_trx找出长时间未提交的事务再看data_lock_waits确认谁在堵谁。这两步做完大部分锁问题的方向就清晰了。6.2 隔离级别要按场景去选而不是跟风很多默认配置把MySQL的隔离级别设成RR这是有历史原因的——它解决了binlog在复制场景下的主从一致性问题。但到了高并发互联网应用RC往往对吞吐更友好因为它减少了间隙锁、锁持有时间也短写并发能力明显更强。所以我的建议是如果你的业务允许出现很小的重复读偏差比如报表统计、状态更新优先考虑RC如果业务严格不允许幻读比如金额计算、审批流程判断才选RR。不过要注意一点RR下的间隙锁并不是完全没救。如果只是个别SQL需要防幻读可以保持整体RR把这些SQL单独用临键锁机制去处理如果大多数SQL都允许RC那直接全局改RC更省心。6.3 常见误区对应的调整方向把几个高频误区和对应解法整理成一张表常见误区实际情况调整方向事务提交后锁才释放只有RR下的当前读锁才放到事务结束RC下语句执行完就释放别盲目延长事务锁的释放时机由隔离级别决定间隙锁只有范围查询才有等值查询查不到记录时也会加间隙锁注意UPDATE/SELECT FOR UPDATE查不到记录时的锁行为小事务一定不死锁死锁只要加锁顺序相反就可能出现和事务大小无关固定所有事务的加锁顺序行锁永远只锁一行没有索引时实际会锁大面积记录写SQL前查执行计划确保走索引锁只在写写冲突时出现INSERT也可能因为间隙锁被阻塞分析插入场景时也要考虑锁等待6.4 最后分享一个实际的小技巧有一次我们做一个订单状态流转的批量更新SQL模板长这样UPDATE order SET status PAYING WHERE biz_type ONLINE AND status INIT;这个UPDATE要扫不少行RR隔离级别下扫描范围内的所有相关记录都会加临键锁导致用户端下单操作偶发卡顿。排查完锁等待现场之后我们做了三件事把隔离级别从RR切成RC消除了间隙锁在(biz_type, status)上建了联合索引让UPDATE扫描的范围缩小把批量更新拆小拆成一次只更新几百条减少单事务持有锁的时间。改完之后锁等待的告警基本绝迹了。这三条经验后来也成了我做数据库锁优化的标配动作先看隔离级别、再看索引、最后控制事务体量。优先级从高到低任何一个环节掉链子锁都可能成为业务并发路上那根最细的瓶颈。现在再回头想“数据库事务与InnoDB锁有什么关联”这个问题我的理解是事务给数据库提供了一致性和隔离性的承诺而锁就是兑现这个承诺的底层执行者。没有锁事务之间的并发写入就会互相踩踏没有事务锁的存在就失去了意义。搞懂了它们之间这层关系再去看锁等待、死锁、性能调优思路会清晰很多。