
先还原一个我去年在群里被问烂的场景库存表只剩10件商品两个用户几乎同时下单。前端把两个请求打到后端后端两条UPDATE同时落到同一行库存上。结果呢后台显示库存还剩9件订单却生成了两单。并发控制失效了吗不是。是没有搞清楚数据库事务和InnoDB锁之间那层关系。我这些年面试后端提到事务几乎人人都能背出ACID但问到“隔离性在存储引擎层面到底怎么实现的”一半人卡壳。说白了事务里的隔离二字落到InnoDB就是锁。共享锁、排他锁、意向锁、间隙锁……这些名字你大概都听过但什么时候加哪种锁、为什么可重复读会锁住一段范围才是真正拉开水平的地方。这篇文章不需要你有源码基础我会从一个线上场景顺藤摸瓜把事务和锁的关系讲透然后再给一套我实际排查锁等待、死锁的流程。后端开发、DBA以及准备数据库面试的人都能从里面找到自己需要的东西。1. 先从一个并发事故说起事务为什么离不开锁1.1 丢失更新是怎么发生的先说库存超卖这件事。假设sku_stock表里有这么一行idsku_codestock1001A00110两个事务同时执行下面这条语句UPDATE sku_stock SET stock stock - 1 WHERE id 1001;很多人以为UPDATE是一条“原子操作”执行完就完事了。但在InnoDB内部这条语句至少拆成三步先读出stock的当前值在内存里减1再把新值写回去。两个事务并发执行时可能都读到了10各自算出9然后都写回9。于是库存从10变成9但扣减动作发生了两次。这种现象在数据库理论里叫丢失更新——后提交的事务覆盖了先提交事务的修改。我见过不少刚转行的同学遇到这种问题第一反应是上Redis分布式锁或者Java里加synchronized、ReentrantLock。这些方案能不能解决问题单机部署时勉强能但一旦服务多节点、请求经过消息队列异步落库锁的范围根本罩不住数据库那一层。数据库事务锁才是最后一道防线前面所有应用层的花活都是为了让数据库这层锁竞争小一点而不是替代它。1.2 锁是隔离性的底层饭碗再往原理上走一步。事务的ACID四个特性并不是四个独立的实现而是InnoDB各组件分工的结果原子性靠undo log操作失败时把数据回滚到修改前的状态。持久性靠redo log崩溃恢复时把已提交的修改重新落盘。隔离性靠锁加MVCC多版本并发控制控制事务之间能不能看到彼此的中间状态。一致性是前三者配合约束实现的最终目标。所以锁存在的直接目的就是实现事务的隔离性。两个事务如果同时改同一行必须有先来后到一个事务改了还没提交另一个事务必须被挡住。锁的本质是把并发操作中对同一资源的访问强制变成串行牺牲一点并发度换取正确性。这里也顺带澄清一个热词层面的混淆偏向锁、自旋锁、互斥锁这些是Java和操作系统里的概念管的是进程内多线程InnoDB的锁管的是多个数据库会话、多个连接之间的并发访问。它们层级不同但设计思想相通都是让临界区在同一时刻只被一个执行者持有。理解这一点你再去看SHOW ENGINE INNODB STATUS里那些锁信息就不会觉得是个完全陌生的世界。2. InnoDB锁体系全景从行级锁到间隙锁2.1 共享锁与排他锁锁分类的地基InnoDB的锁体系往下拆最先要记住的就是两类基本锁共享锁和排他锁简称S锁和X锁。共享锁的意思是“大家可以一起读但谁也不能改”。排他锁的意思是“我拿了这条锁别人连读都不行”。它们之间的兼容关系可以用一句话概括S锁和S锁兼容其他组合都不兼容。也就是锁类型S锁X锁S锁兼容冲突X锁冲突冲突用生活类比就是图书馆的阅览室S锁是“多人同时看同一本书”X锁是“有人把书借走了并且锁在柜子里别人连翻都不许翻”。什么时候加什么样的锁InnoDB的规则是INSERT、UPDATE、DELETE这类写操作自动加X锁普通SELECT默认不加锁走快照读。如果你想在读取时主动控制并发就得手动加锁这个我放到2.4节细说。很多线上死锁和锁等待本质上就是S锁和X锁的兼容矩阵发生了变化导致两个事务互相等对方释放。2.2 意向锁表锁与行锁之间的协调员意向锁是新手最容易忽略、面试却最爱问的一类锁。它分为意向共享锁和意向排他锁简称IS锁和IX锁。为什么InnoDB需要它因为MySQL里除了行级锁还有表级锁比如LOCK TABLES命令。假设事务A在sku_stock表的某一行加了X锁此时事务B想对整个sku_stock表加表级锁。如果不做任何标记B只能逐行检查表里有没有行锁成本高得离谱。意向锁就是用来解决这个问题的事务准备给某行加锁之前先给表加一个意向锁相当于在门口挂一块“表内有行被锁定”的牌子。于是表级锁的请求来了只需要看一眼有没有意向锁就能立刻判断能不能加表锁。IS锁和IX锁之间互相兼容因为它们只在表级别做标记真正的互斥发生在行级。这个设计实际运行起来很省事但如果业务里有人用LOCK TABLES配合LOCK IN SHARE MODE就会出现意向锁和表级锁冲突的诡异现象排查起来非常头疼后面我细说。2.3 记录锁、间隙锁与临键锁锁的是索引区间行级锁再往下细拆还有三个重要概念记录锁、间隙锁、临键锁。记录锁Record Lock锁住索引上的一条具体记录比如id 1001这一行。间隙锁Gap Lock锁住索引记录之间的区间防止其他事务在这个区间插入新记录但它不锁任何具体记录本身。临键锁Next-Key Lock记录锁加间隙锁的组合锁的是“记录的前一段左开右闭区间”。用一个公寓楼的类比来记记录锁是锁住某个房间钥匙只有一把间隙锁是锁住了走廊和楼与楼之间的空地不让人新盖房间临键锁则是“锁房间连门前那段走廊一起锁”。临键锁是InnoDB默认隔离级别下解决幻读的核心武器也是为什么有些查询明明没命中任何行却把一段范围卡得死死的。举一个我实际踩过的坑。执行这条查询SELECT * FROM order WHERE id BETWEEN 100 AND 200 FOR UPDATE;即使id 150这条记录根本不存在只要在可重复读隔离级别下InnoDB也会把(100, 200]这段范围内的间隙锁住。另一个事务想往中间插入一条id 150的新订单会被死死拦住直到当前事务提交或回滚。代码里没有报错但压测时就是感觉插入QPS上不去一查才发现是间隙锁在作祟。2.4 手动加锁的两种姿势FOR SHARE 与 FOR UPDATE普通SELECT不加锁这个前面说了。但业务里经常有“我必须基于当前最新值做判断”的场景比如扣库存前先确认库存够不够。这时就要手动加锁两种SQL姿势-- 共享锁S锁允许别人继续读但不允许别人改 SELECT * FROM sku_stock WHERE id 1001 FOR SHARE; -- 排他锁X锁当前事务独占这一行 SELECT * FROM sku_stock WHERE id 1001 FOR UPDATE;MySQL 8.0里FOR SHARE是标准写法老版本用的是LOCK IN SHARE MODE。我个人的使用习惯FOR SHARE适合“读多写少、只防别人改、不防别人读”的场景比如统计类报表FOR UPDATE适合“读完之后必须接着改”的场景比如库存扣减。8.0之后还可以加NOWAIT和SKIP LOCKED配合队列消费很顺手SELECT * FROM task_queue WHERE status 0 ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED;这条语法的意义是跳过已经被其他事务锁住的行直接拿一条没被处理的任务。用行锁天然实现了一个多消费者抢任务的队列比在应用层用分布式锁简单得多。3. 隔离级别与锁的映射关系默认RR下的锁行为真相3.1 四种隔离级别锁的“火力”逐步升级MySQL定义了四个事务隔离级别读未提交、读已提交、可重复读、可串行化。InnoDB默认是可重复读也就是REPEATABLE READ。从隔离性角度看级别从低到高锁的“火力”也逐步升级隔离级别脏读不可重复读幻读加锁特点读未提交可能可能可能写加X锁读不加锁读已提交不可能可能可能只锁命中记录无间隙锁可重复读不可能不可能当前读可能记录锁 间隙锁/临键锁可串行化不可能不可能不可能读也加锁退化接近表锁读未提交为什么脏读因为一个事务改了数据但还没提交另一个事务的普通SELECT就能读到未提交的修改。InnoDB实际上会在写的时候加X锁但读不加锁所以读到“半成品”数据。业务里几乎没人会用这个级别除非你对数据准确性毫不在意。读已提交也就是READ COMMITTED比读未提交多了一层保障普通SELECT只会读到已提交的快照所以不会脏读。但这个级别下锁只锁命中记录不锁间隙于是同一个事务里两次SELECT可能读到不同的结果这就是不可重复读。可重复读和可串行化的差别集中在幻读上这个稍后专门讲。3.2 快照读与当前读为什么普通select不加锁很多人看完锁的分类会蒙圈既然要隔离为什么普通SELECT不加锁因为InnoDB靠MVCC把“不加锁的读”也做得基本正确。MVCC的核心思想是每一行数据不只是最新值还有历史版本历史版本存在undo log里。普通SELECT走的是快照读它看到一个事务开始时点的快照不需要给任何行加锁而UPDATE、DELETE、SELECT ... FOR UPDATE这类语句走的是当前读必须读最新版本所以必须加锁。用一句话概括快照读用来提供一致性视图当前读用来保证写操作互斥。这两个机制并行才让InnoDB在默认隔离级别下既有较高并发又能避免大部分异常。理解了这一点就不会再问“为什么我查一条数据不加锁它也不会读到脏数据”这种问题了。3.3 RR下还会出现“幻读”吗一个容易翻车的实验可重复读理论上应该避免幻读但实际测试会发现情况没那么简单。我给你一个可以自己复现的实验事务A先跑一遍START TRANSACTION; SELECT COUNT(*) FROM order WHERE amount 100;假设结果返回3。然后事务B插入一条amount 200的新订单并提交。事务A再跑一遍同样的查询结果仍然返回3。从快照读的角度看事务A确实“看不到”新插入的行称不上幻读。但是如果事务A用的是当前读SELECT COUNT(*) FROM order WHERE amount 100 FOR UPDATE;这时它就会读到事务B刚插入的那条记录返回4。这就是可重复读隔离级别下仍然可能在当前读中出现的幻读现象。InnoDB用临键锁来尽力阻止这种事事务A执行当前读之后amount 100对应的索引区间都被加上了临键锁事务B的插入会被阻塞直到事务A提交。问题是事务B如果在事务A加锁之前就已经插入并提交事务A的当前读就会撞见它。所以严格说REPEATABLE READ能防住“事务执行期间其他人插入新数据”但防不住“插入发生在我第一次查询之前的一种时间窗”里的残余情况。这也是为什么事务要尽量短、加锁的语句要尽量靠后执行。3.4 很多团队为什么把线上从RR降到RC聊到隔离级别绕不开一个实战争议线上到底用RR还是RCMySQL官方默认是REPEATABLE READ但不少高并发团队会把线上调到READ COMMITTED。原因很直接RR下临键锁锁的范围大锁竞争和死锁的频次明显更高。比如一个热门商品行两个事务分别对stock做扣减RR下不仅锁住目标行还可能锁住相邻索引区间导致本来不相关的插入被无辜阻塞。RC下没有间隙锁只有记录锁并发度确实更高。但代价也很明显同一事务里两次读同一行可能结果不同也就是不可重复读而且主从复制时必须把binlog_format设为ROW否则会出现主从数据不一致的风险。我的建议是读多写少、一致性敏感的业务保持RR高并发写入、热点行竞争严重的业务考虑RC。调整方式很简单SET GLOBAL transaction_isolation READ-COMMITTED;不过改之前一定要压测别只看并发理论。我见过一个团队从RR切到RC后DELETE一个大表导致从库延迟飙高就是因为RC下每次只锁记录主库删得快从库应用不过来反而把业务拖垮了。4. 锁等待、死锁与排查实操4.1 死锁产生的必要条件与真实案例死锁是事务和锁结合得最紧密的“事故现场”。四个必要条件互斥、持有并等待、不可剥夺、循环等待。数据库里最常见的就是两个事务以相反顺序更新同一组行。事务A执行START TRANSACTION; UPDATE t SET a 1 WHERE id 1; UPDATE t SET a 2 WHERE id 2; COMMIT;事务B同时执行START TRANSACTION; UPDATE t SET a 3 WHERE id 2; UPDATE t SET a 4 WHERE id 1; COMMIT;两个事务各拿住一行锁又都在等对方释放另一行锁循环等待形成。InnoDB的检测机制会在瞬间识别这种状态主动回滚其中一个事务让它释放所有锁另一个事务继续执行。你会看到类似ERROR 1213 (40001): Deadlock found when trying to get lock的报错。解决死锁最朴素也最有效的办法是所有事务都按同一个顺序加锁。比如统一要求先更新id 1再更新id 2循环等待就不存在了。虽然数据库会自动回滚一个事务但它并不会帮你自动重试所以业务代码里还是得捕获死锁异常做有限的重复执行。4.2 锁等待/死锁现场我是怎么查的真到了线上等死锁还是不死锁不是固定剧本。我的排查流程比较固定分三步走直接复制就能用。第一步看当前有哪些事务还在跑SELECT * FROM information_schema.INNODB_TRX\G重点看trx_state是不是RUNNINGtrx_started是不是已经跑了几分钟。事务拖得越长持锁时间越久锁等待发生的概率成倍上升。很多“莫名其妙锁表”的问题最后查出来就是某个连接开了事务忘了COMMIT。第二步看等待关系SELECT * FROM information_schema.INNODB_LOCK_WAITS\G这张表会告诉我哪个事务在等哪个事务的锁请求的锁ID和持有的锁ID都有。顺着查下去基本就能还原出“谁堵了谁”。第三步看引擎内部状态SHOW ENGINE INNODB STATUS\G在LATEST DETECTED DEADLOCK段落里有最近一次死锁的两条SQL原文、锁定的记录、以及回滚了哪个事务。这是定位死锁根因的黄金信息源。MySQL 8.0之后系统库升级更方便了SELECT * FROM performance_schema.data_locks\G SELECT * FROM performance_schema.data_lock_waits\Gdata_locks能看到锁对象、锁类型、锁模式、被锁的索引和记录比老版本INFORMATION_SCHEMA的信息更直观。我实际排查时基本先看data_locks确认是X锁还是S锁、锁在哪一行再看data_lock_waits确认等待链最后拿SHOW ENGINE INNODB STATUS里的死锁日志定论。4.3 常见问题速查表最后把我在不同项目里反复撞到的几类问题整理成一张速查表排查时先对号入座现象常见原因排查路径UPDATE很慢大量会话堆积WHERE条件没走索引行锁扩成表锁执行EXPLAIN看type是不是ALL或index锁等待超时报ERROR 1205别的长事务持有锁不提交查INNODB_TRX定位长事务必要时KILL死锁报错ERROR 1213多个事务加锁顺序不一致看SHOW ENGINE INNODB STATUS死锁日志统一加锁顺序大量并发UPDATE同一行热点行竞争考虑异步合并、队列化或引入分布式锁在应用层排队DDL卡住无法执行元数据锁MDL等待检查是否有长时间未提交事务MDL等待和行锁等待是两套体系两条事务互相读对方数据后改写先S锁再升级X锁导致锁升级等待业务逻辑尽量避免“读后写”之间夹太多耗时操作每条背后都对应一个参数或一条命令的实操经验。比如innodb_lock_wait_timeout默认50秒意思是事务等待行锁超过50秒就放弃lock_wait_timeout管的是元数据锁默认一年改的时候要看清改的是哪一个。别把这两个参数弄混不然排查半天还定位错方向。另外一个容易被忽视的坑长事务本身就是一种锁。即使事务里只有一条SELECT ... FOR UPDATE只要不提交锁就不会释放。有些开发的代码里事务内嵌了外部HTTP调用一调就是几秒甚至几十秒那段时间里相关行全部锁死。我后来养成的习惯是事务里不做远程调用、不做耗时计算、不等待用户输入所有不涉及数据库的操作全部挪到事务外面去。我个人在实际操作中还有一个小技巧写任何一条UPDATE或DELETE之前先问自己三个问题。第一WHERE条件上的列有没有索引没有索引行锁就很容易退化成表锁线上直接锁表。第二当前事务是RC还是RRRR下锁的范围可能比我以为的大一次小更新可能把一段区间都冻住。第三有没有别的事务会以相反顺序动同一组行如果有先统一下加锁顺序。这三个问题养成肌肉记忆比背一堆锁的名字管用得多。数据库事务和InnoDB锁从来不是两张孤立的知识点它们就是一对连体婴儿事务的隔离性要靠锁来实现锁的生命周期又完全绑定在事务上。把这个心智模型立住再去调隔离级别、修死锁、回答分布式锁和数据库行锁的边界问题都会顺很多。