1. 一个反直觉的起点:行级锁锁的从来不是“行”
先抛个结论:InnoDB的行级锁,名字叫“行锁”,但底层的锁对象并不是挂在某一行数据上的。你把一张表的数据页在磁盘上打开,里面是无穷无尽的记录,InnoDB不可能为每一行预先分配一把锁。真正的实现是:锁是挂在索引记录上的,而且如果表上没有索引,那InnoDB会拿隐藏的聚簇索引来顶替。
这句话怎么理解?你可以这样想:一张InnoDB表,本质上是一个B+树。数据行全部存在聚簇索引的叶子节点里。你执行UPDATE user SET name = 'x' WHERE id = 10,InnoDB不会去遍历全表找到id=10这一行,然后往这行数据上挂一把锁。它的做法是:从聚簇索引B+树的根节点出发,逐层往下,定位到叶子节点里id=10那条索引记录,然后在内存中的一个锁结构里记录“事务A锁住了这个索引记录”。
所以更准确的说法是:InnoDB的行级锁,是索引记录锁。没有索引的情况下,InnoDB退化为锁全表,也是因为只能扫描聚簇索引的全部叶子节点记录来加锁。
这个认知是所有后续讨论的基础。很多人背了一堆“Record Lock、Gap Lock、Next-Key Lock”的概念,但不知道这些锁到底加在哪个对象上,遇到死锁分析就懵。记住一句话:有索引,锁索引记录;没索引,锁聚簇索引的每一条记录的区间。 下面逐个拆。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 加锁的底层设施:锁结构、锁内存和等待关系
要说清楚“怎么加的”,得先知道“加在哪”。MySQL 8.0里,你可以直接通过performance_schema.data_locks这张表看到事务加锁的实时情况。我建议你动手试一下,这比看任何文章都直观。
sql复制-- 会话A
BEGIN;
SELECT * FROM user WHERE id = 10 FOR UPDATE;
-- 不提交
-- 会话B(新开一个连接)
mysql> SELECT * FROM performance_schema.data_locks\G
*************************** 1. row ***************************
ENGINE: INNODB
ENGINE_LOCK_ID: 7522757045272:1127:4:7:7522757056360
ENGINE_TRANSACTION_ID: 421670687929632
THREAD_ID: 56
EVENT_ID: 15
OBJECT_NAME: user
PARTITION_NAME: NULL
SUBPARTITION_NAME: NULL
INDEX_NAME: PRIMARY
OBJECT_INSTANCE_BEGIN: 7522757056360
LOCK_TYPE: RECORD
LOCK_MODE: X,REC_NOT_GAP
LOCK_STATUS: GRANTED
DATA_SOURCE: 8.0.31
LOCK_DATA: 10
注意INDEX_NAME是PRIMARY,LOCK_MODE是X,REC_NOT_GAP,LOCK_DATA是10。意思是事务在PRIMARY索引的id=10这条记录上持有了一把排他的记录锁,这不是间隙锁(REC_NOT_GAP明确告诉你:我只锁这行,不锁它前面的间隙)。
如果加的是间隙锁,LOCK_MODE会显示成X,GAP。如果是临键锁(Next-Key Lock),会显示成X或者X,REC_NOT_GAP之外的组合,实际是X加上隐含的gap成分。8.0的data_locks表把锁的类型、模式、锁在哪条索引记录上全部摊开给你看,这比早期版本用SHOW ENGINE INNODB STATUS去猜要清晰得多。
在源码层面,每一个行锁对应的是一个lock_t结构体,它维护了:
- 锁所属的事务ID
- 锁的模式(S/X)
- 锁的类型(Record/Gap/Next-Key)
- 锁对应的索引空间ID和页号
- 页内的记录堆地址(heap_no)
重点说下heap_no。这不是主键值,是记录在页内的一个逻辑序号。InnoDB在做行锁的时候,本质上是在锁一个页内的“槽位”。每页里还有一个infimum和一个supremum两条伪记录,分别代表页内最小记录和最大记录。间隙锁的上界有时候就是锁在supremum上的——这个后面讲间隙锁的时候会再提到,很多死锁案例跟它有关。
锁结构的内存管理也很有意思。InnoDB有一个全局的锁系统lock_sys,里面维护了一个hash表,key是(space_id, page_no),value是这一页上的锁位图。之所以这么设计,是为了快速判断“两条记录在同一个页里,是不是被同一个事务锁的”,避免为每条记录都创建一个独立的锁结构。当一个事务同时锁住同一个页里的5条记录时,InnoDB会尽量用一张锁位图搞定,而不是建5个锁对象。这也是行锁的加锁成本比很多人想象中低的原因。
锁的等待关系也同样通过performance_schema.data_lock_waits查看。这张表记录了谁在等谁。配合sys.innodb_lock_waits视图,可以直接看到阻塞链条。排查死锁的第一反应应该是查这两张表,而不是去翻错误日志碰运气。
3. 不同SQL具体怎么加锁:SELECT、UPDATE、DELETE、INSERT各有各的玩
同一个事务里,不同语句加的锁完全不同。我把最常见的几类语句拆开说,每个都给你一个可以直接验证的实验。
3.1 快照读(普通SELECT)——不加锁
普通的SELECT语句走的是MVCC(多版本并发控制),它读的是undo log里构建的历史版本,不申请任何行锁。这就是为什么线上可以长时间跑一个大查询而不阻塞其他事务的更新——它根本不抢锁。
但这里有一个高频误解:快照读真的完全不加锁吗?答案是:在默认的REPEATABLE READ隔离级别下,快照读不加锁;但如果你把隔离级别切成READ COMMITTED,快照读每次语句执行前都会生成新的快照,它依然不加锁。唯一例外是如果你在SELECT后面跟了FOR UPDATE或LOCK IN SHARE MODE,那就是当前读,必须加锁。很多人在面试里被问“普通的SELECT加锁吗?”标准答案是:不加行锁,但InnoDB可能会获取MDL(元数据锁)来保护表结构,这是另一个层面的事。
3.2 当前读(SELECT ... FOR UPDATE / LOCK IN SHARE MODE)——显式加锁
FOR UPDATE加的是X锁(排他锁),其他事务既不能改也不能读这个版本(快照读除外)。LOCK IN SHARE MODE加的是S锁(共享锁),其他事务还能继续加S锁,但X锁会被挡在外面。
如果是通过唯一索引等值查询,且命中的记录存在,InnoDB只加一个记录锁(Record Lock),不会扩散。这一点上面实验已经验证过了。但请不要高兴得太早——如果id=10这条记录不存在,那加的就不是记录锁了,而是间隙锁。间隙锁的意思是:别的事务不允许往这个空位插入数据,但是记录锁本身不存在,其他事务依然可以读。
3.3 UPDATE和DELETE——读已提交和可重复读下的差异
UPDATE user SET name='x' WHERE id=10在执行时,InnoDB会先找到id=10的记录,对它加X锁,然后才去更新。这个“先锁后改”的顺序很重要:如果在加锁阶段发现这一行已经被其他事务的X锁占住了,当前事务就会进入锁等待状态,而不是原地自旋。
DELETE的加锁和UPDATE类似,先锁记录,再标记删除。但8.0里的DELETE还多了一个细节:如果是大范围删除,可能会用到DELETE BUFFER之类的机制,这里不展开。
重点说说范围查询。假设SELECT * FROM user WHERE id BETWEEN 10 AND 20 FOR UPDATE,InnoDB会扫描所有满足条件的索引记录,并对它们逐条加锁。在REPEATABLE READ下,加的是Next-Key Lock(临键锁),不仅锁住命中的记录,还锁住它们之间的间隙,防止幻读。扫描的起点是id=10之前的那个间隙,终点是id=20之后的那个间隙。也就是说,范围查询的锁范围,永远比你查询的条件大一圈。
这一点在生产环境经常搞出事情。比如一个订单表里执行:
sql复制UPDATE orders SET status = 1 WHERE order_no > '20240101000000' AND order_no < '20240102000000'
你以为只锁了这批订单,实际上InnoDB把这条范围的索引区间整个锁住了。如果另一个事务恰好要往这个区间里插入一条新订单,会被阻塞得死死的。用户端感知就是“插入订单一直在转圈”。
3.4 INSERT——隐式锁的巧妙设计
INSERT是唯一一个不会显式申请记录锁的DML吗?也不是。准确地说,INSERT在初始阶段不会创建锁结构,它使用一种叫隐式锁(implicit lock)的机制:新插入的记录上,DB_TRX_ID字段记着插入它的那个事务ID。如果有别的事务想来修改这条记录,它需要检查这条记录的DB_TRX_ID对应的事务是否还活着,如果还活着,就把隐式锁转换成显式锁(也就是创建锁结构),然后自己进入等待。
用大白话说:刚插入的记录并不真的“锁住”,但每个想动它的人都知道“这是谁的地盘”。这种做法省掉了很多创建锁结构的内存开销。批量插入时尤其明显——一次性插入1万条,如果每条都创建显式锁结构,锁内存会爆掉。隐式锁只在被争用的时候才转为显式锁,这是性能上的巨大优化。
4. 索引与锁范围:走错索引等于锁全表
现在进入最关键的部分——索引对行锁的影响。很多人只知道“有索引就锁行,没索引就锁表”,这个说法对,但不精确。更真实的规则是:锁的粒度取决于你最终在哪些索引记录上落了锁。
4.1 唯一索引等值命中——最理想的情况
如果id是主键或者唯一索引,等值查询精确命中一行,InnoDB只需要在对应的唯一索引上加一个记录锁。如果走的是二级唯一索引,还需要回表给聚簇索引上的对应记录加锁。为什么要回表加锁?因为同一行数据在聚簇索引上只有一份,如果另一个事务通过主键条件去更新这行数据,它锁的是聚簇索引记录。如果你只锁了二级索引记录而不锁聚簇索引记录,那两边事务各锁各的索引,同一行数据就可能被两个事务同时修改,数据一致性就崩了。
所以InnoDB的规则是:所有修改最终都要在聚簇索引记录上落锁。如果你通过二级索引更新,那二级索引扫到的记录加锁后,必须回表对聚簇索引记录加锁。二级索引加的是X锁,聚簇索引加的也是X锁,两把锁分属不同索引空间。
4.2 普通索引等值命中——临键锁登场
普通索引不唯一,所以即使你执行的是等值查询,InnoDB也无法确定后面还有没有相同值的记录。比如:
sql复制SELECT * FROM user WHERE age = 20 FOR UPDATE
age是普通索引,值为20的记录有三条。InnoDB会扫描整个索引,找到所有age=20的记录,然后:
- 对每条age=20的记录加记录锁
- 对第一条age=20记录之前的间隙加间隙锁
- 对最后一条age=20记录之后的间隙加间隙锁
实测效果就是:你只想锁那三条age=20的行,结果整个“20这个值所跨越的索引区间”全被锁住了。想插入age=19或age=21的记录,可能都会被阻塞,取决于间隙的边界。
4.3 没有索引——从行锁退化为全表锁
如果查询条件没有索引可用,InnoDB只能走全表扫描。走全表扫描的时候,它会依次访问聚簇索引的每条记录,并且对每条记录都加锁。你可能觉得它是在“扫描”,但在锁的语义上,它就是给全表每一条记录都上了锁。
不过这里有个容易被忽略的点:全表扫描加锁不代表所有记录都被锁死。如果表里有一万条记录,它的确会尝试给这一万条记录加锁,但其中有些记录在加锁时发现已经被别的事务锁住了,那扫描就会停下来,进入锁等待。所以更准确地说,全表扫描加锁是“逐条加锁,遇到冲突就等待”。
还有一个老版本的行为值得一说:在MySQL 5.6及更早版本,UPDATE没有走索引全表扫描时,是逐条加锁逐条更新,一旦遇到锁冲突会抛出死锁错误或直接锁等待超时。5.7开始引入了UPDATE的半一致性读优化——在某些情况下,如果被扫描的记录已经被别的事务锁住,可以先读取该记录的当前已提交版本,判断是否符合WHERE条件,如果不符合就直接跳过加锁。这个优化只适用于READ COMMITTED隔离级别。
4.4 ICP与MRR带来的锁差异
MySQL 5.6引入的索引条件下推(ICP)和5.6/8.0引入的MRR(Multi-Range Read),在优化器层面改变了索引扫描的方式,也间接影响了加锁顺序。8.0默认开启MRR,它会把一批二级索引的范围查询先排序,再回表取聚簇索引记录。这个过程改变了加锁的先后顺序——从原来的“一级索引扫描顺序”变成“聚簇索引主键顺序”。
这个变化看起来无关痛痒,但如果你恰好有两条范围查询分别走两个不同的二级索引,锁顺序不同,死锁的概率会上升。8.0的锁顺序调整让死锁更难复现,但绝不代表没有。遇到死锁先别急着说是MRR的锅,先看SHOW ENGINE INNODB STATUS里打印的锁等待链。
5. 间隙锁与临键锁:范围锁的边界到底怎么算
间隙锁是InnoDB在REPEATABLE READ隔离级别下防止幻读的核心手段,但也是最多人踩坑的地方。我详细说一下它的计算规则。
5.1 什么是间隙
索引B+树叶子节点上的记录是排好序的。相邻两条记录之间,天然存在一个“空隙”,这个空隙是为了后续插入新记录预留的物理空间,但逻辑上它在索引序中对应一个“开区间”。间隙锁锁的不是具体的记录,而是这个开区间——别的事务不允许往这个区间里插入任何记录。
间隙锁和间隙锁之间不冲突,只有插入意向锁(Insert Intention Lock)会被间隙锁阻塞。也就是说,两个事务可以同时持有同一个间隙的间隙锁,但它们都不能往这个间隙里插数据。这有点反直觉,但确实如此——间隙锁本来就是为了防止别人插入,而不是为了互相排斥。
5.2 Next-Key Lock = Record Lock + Gap Lock
临键锁是“记录锁 + 前一间隙锁”的组合。为什么需要这个组合?举个例子:事务A执行SELECT * FROM t WHERE id BETWEEN 10 AND 20 FOR UPDATE,锁住了id=10和id=20两条记录以及它们之间的间隙。如果没有间隙锁,另一个事务就可以在10和20之间插入一条id=15的记录。那事务A执行同样的查询时,就会多出一行——这就是幻读。有了临键锁,间隙被锁住,新记录插不进来,幻读被杜绝。
计算临键锁的区间,可以从一个例子体会:假设表里id分别是1、5、9、15,当前查询条件是WHERE id > 3 FOR UPDATE,实际扫描时会从id=5开始,加临键锁覆盖(1,5]这个区间,然后依次锁住(5,9]、(9,15]以及(15, +∞)。注意是左开右闭区间——每条记录那一侧是闭区间(记录本身被锁),前面那侧是开区间(间隙被锁)。
5.3 gap锁锁到supremum:一个典型的死锁温床
当查询条件的上限超过表中最大记录时,InnoDB会把间隙锁加到页内伪记录supremum上。比如:
sql复制SELECT * FROM t WHERE id > 100 FOR UPDATE
表里最大的id是99。那这个查询会把从99往后的整个间隙锁住,一直延伸到supremum。任何想插入id>100记录的请求都会被阻塞。从data_locks里看,LOCK_DATA会显示supremum pseudo-record。
这个场景的死锁高发点在于:多个事务同时执行INSERT INTO t (id) VALUES (101)时,它们会先请求插入意向锁,然后发现间隙被锁,进入等待。如果事务A持有间隙锁,事务B在等待,事务C在等待,三个事务一起穿插,就可能形成循环等待。我在实际排查中见过一个日活几十万的系统,就是因为一个定时任务更新全表索引键范围,导致用户新插入的数据全都堵住,最后整个表的写操作雪崩。排查到最后,锁等待的源头就是一条UPDATE ... WHERE create_time > '2024-01-01'触发的超大范围临键锁。
6. 死锁的完整复盘:从现象到根因
聊完底层的加锁机制,用一个真实案例把整个排查思路串起来。
6.1 现象
某个订单系统的日志里频繁出现:
code复制ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
两个事务同时操作,一个总在提交时失败。一开始以为是偶发,后来发现每天都有上百次。
6.2 排查链路
第一步,找到死锁日志。MySQL默认会把最近一次死锁信息写到SHOW ENGINE INNODB STATUS里。关键字段是LATEST DETECTED DEADLOCK,里面有事务A和事务B各自的锁等待信息,包括持有锁的索引、锁类型、等待的锁地址。
我当时看到的关键信息是:事务A持有一个二级索引上的Next-Key Lock,等待一个聚簇索引记录锁;事务B持有一个聚簇索引记录锁,等待同一个二级索引上的Next-Key Lock。锁等待方向刚好相反,形成了环。
第二步,回看业务代码。发现事务A的执行顺序是:
sql复制UPDATE order_item SET ... WHERE order_id = ?; -- 先走订单明细二级索引
UPDATE order_main SET ... WHERE order_id = ?; -- 再更新主表
事务B的执行顺序是反的:
sql复制UPDATE order_main SET ... WHERE order_id = ?; -- 先更新主表
UPDATE order_item SET ... WHERE order_id = ?; -- 再更新明细
两个事务互相持有对方需要的锁,死锁就产生了。
第三步,看索引设计。order_item上的索引是idx_order_id (order_id),order_main的主键是order_id。事务A先在order_item的二级索引上加锁,回表加聚簇索引锁;事务B先在order_main主键加锁。两边都拿到了自己需要的第一把锁,然后去拿对方手里的第二把锁,直接循环等待。
6.3 修复方案
最直接的方案是让所有事务按相同顺序获取锁。业务上把事务A的语句顺序调整成和事务B一致:先更新主表,再更新明细。这个改动一行代码的事,但需要业务确认逻辑上没有副作用。
另一个思路是把订单明细表的二级索引从普通索引改成唯一索引,这样InnoDB在加锁时可以直接确定唯一值范围,减少Next-Key Lock的区间。不过这个改动需要评估数据是否真的唯一,业务上如果同一个订单不可能有重复明细,那这个方案是有效的。
还有一个兜底手段:缩短事务时间。锁等待时间越长,死锁概率越大。把事务里无关的网络调用、慢查询移出去,让事务尽快提交释放锁。虽然不能根除死锁,但能显著降低概率。
MySQL遇到死锁会自动回滚其中一个事务(通常是被检测为死锁牺牲者的那个),应用层只需要捕获1213错误并重试即可。所以死锁本身不是数据库bug,而是事务并发设计的信号——按固定的锁顺序访问资源才是根治之道。
7. 锁监控与优化建议:搞清楚你的表到底锁在哪
最后给一些可以直接落地的监控建议。授人以鱼不如授人以渔,授人以锁不如授人以查锁。
我的习惯是接到锁问题告警后,按这个顺序排查:
- 查
performance_schema.data_lock_waits,看谁在等谁,锁定阻塞源头。 - 查
sys.innodb_lock_waits,这个视图把事务ID、耗时、SQL语句都整合好了,适合快速定位热点行。 - 查
SHOW ENGINE INNODB STATUS,看死锁日志里的加锁顺序,反推业务代码的访问路径。 - 分析索引,
EXPLAIN一下相关SQL,看是不是走了全表扫描或者索引选择不理想。
几个常见的加锁过大场景和应对思路:
| 场景 | 根因 | 应对 |
|---|---|---|
| UPDATE无索引条件 | 全表扫描逐条加锁 | 给WHERE条件加索引,让走索引 |
| 范围查询影响整个区间 | Next-Key Lock范围过大 | 缩小查询范围,或用LIMIT约束扫描量 |
| 事务太长导致锁持有时间过长 | 锁在提交时才释放 | 拆分事务,减少无用操作 |
| 多个事务按不同顺序更新多行 | 死锁 | 统一加锁顺序 |
| 热点行高并发更新 | 行锁争用 | 考虑队列化、合并更新、分片 |
关于LIMIT对锁的影响多说一句:UPDATE ... WHERE status = 0 LIMIT 100,InnoDB会扫描并锁定前100条满足条件的记录后停止,不会再往下扫。这把锁的范围从全表缩到了100条。但这会导致这批记录被锁住后,其他事务更新同一批记录会等待——所以要结合业务评估,不能为了减少锁范围就盲目加LIMIT。
另外一个容易被忽略的点:autocommit。很多排查了半天锁问题,最后发现是某个连接把autocommit关掉后忘了提交,事务一直开着,持有一堆锁不释放。不管是写脚本还是维护连接池,autocommit=1是默认推荐配置,长事务是行锁的隐形杀手。
再有一个关于锁等待超时的参数:innodb_lock_wait_timeout默认50秒。如果你的业务对延迟敏感,可以调小一点,让锁等待快速失败并重试;如果是批处理场景,可以调大一点,容忍更长的等待。但请记住调大超时只是治标,找到锁的根因才是治本。调大超时会让用户看到更长的卡顿,生产环境一定要想清楚再动。
行级锁的正确加锁姿势,一句话总结:它锁的是索引记录和索引区间,不是抽象的“行”;它的范围由索引选择、隔离级别和查询条件共同决定;它的核心矛盾不是锁本身,而是事务访问资源的顺序。 把这三个层面的机制吃透,面试时谁再问行级锁怎么加的,你可以从B+树叶子节点的伪记录讲到data_locks里的LOCK_MODE,再顺手给他一个死锁复盘——这比背十遍八股文管用多了。
