曾有段时间,我被线上一个"订单创建接口频繁超时"的告警折腾得头大。监控面板里InnoDB的死锁次数每分钟冲到几十次,业务方第一反应都是"是不是缓存穿透了""是不是接口代码有重复提交",结果查到最后,定位到一个让我印象极深的根因:一条普通的UPDATE语句,和另一条INSERT语句,在RR(可重复读)隔离级别下因为间隙锁互相等待,把整张订单表的高并发写入堵成了串行。从那天起我就意识到,MySQL的行锁、间隙锁、临键锁这三兄弟,如果不把它们的运作机制彻底搞明白,排查死锁基本就是盲人摸象。
这篇文章我把这套锁机制拆开讲透。先说明白每种锁解决什么问题、在什么场景下生效,再配合具体的加锁规则和死锁排查链路,最后给出能直接落地的优化建议。不管你是刚接触MySQL的开发者,还是已经被线上死锁折磨过几次的运维,这篇都值得花十分钟认真读完。
1. 行锁、间隙锁、临键锁:三种锁到底在解决什么问题
很多刚接触MySQL锁机制的人,上来就背概念:行锁锁一行,间隙锁锁间隙,临键锁是行锁加间隙锁。背是背下来了,但一遇到实际问题还是懵。原因很简单——你不清楚这三种锁各自为了解决什么痛点而被设计出来。
1.1 先从"并发下数据失真"这件事说起
数据库并发访问最怕两件事:数据错乱和读到不一致的数据。同一个字段,事务A改成1,事务B改成2,如果不加控制,最终结果取决于谁后提交,这就是典型的更新丢失。而事务C在读取的过程中,事务D插入了一条新记录,C再查一次发现多了一行,这就是幻读。
InnoDB的锁机制,本质上就是在用不同粒度的锁,去堵住这些并发漏洞。行锁堵的是"同一行记录的读写冲突",间隙锁堵的是"区间内插入新记录产生的幻读",临键锁则是把两者合并,做到"既锁住已有记录、又锁住可能插入的记录"。
1.2 三种锁的定位差异与关系
先给一个总览表,后文会逐个展开。
| 锁类型 | 英文名 | 锁定的对象 | 解决的核心问题 | 是否依赖索引 | | 行锁 | Record Lock | 单条索引记录 | 同一行记录的并发修改冲突 | 是 | | 间隙锁 | Gap Lock | 两条索引记录之间的间隙 | 防止在间隙中插入新记录导致的幻读 | 是 | | 临键锁 | Next-Key Lock | 一条记录及其前面的间隙 | 行锁与间隙锁的组合,根治幻读 | 是 |
看到"是否依赖索引"这一列了吗?这是理解InnoDB锁机制的关键。InnoDB的锁不是加在"表的一行"上,而是加在"索引记录"上。就算你的表没有显式定义主键,InnoDB也会在内部生成一个聚簇索引(通常是主键索引,没有主键就找第一个非空的唯一索引,再没有就生成隐藏的ROW_ID)。所以行锁、间隙锁、临键锁,本质上都是加在索引记录上的。
1.3 当前读与快照读:锁只对"当前读"生效
在深入锁类型之前,必须先分清两种读取方式:
- 快照读:普通的SELECT语句,走MVCC(多版本并发控制),通过undo log版本链读取历史快照,不需要加锁。这也是为什么RR隔离级别下,普通SELECT不会阻塞INSERT。
- 当前读:SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE,这些操作读的是最新版本,并且必须加锁。
间隙锁、临键锁这些机制,只对当前读生效。如果全走快照读,MVCC就能解决幻读问题;但一旦有当前读,就需要临键锁去锁定区间,防止其他事务往这个区间里插入数据。理解了这个前提,你再看后文的各种加锁场景,思路会清晰很多。
2. 行锁(Record Lock):锁定索引记录的最小单元
行锁是InnoDB锁体系里最基础、最容易理解的一种。它锁定的是一条具体的索引记录。很多人以为行锁是加在"表的一行"上,这是个隐蔽的误区——实际上它是加在索引记录上,这句话后面会有大用。
2.1 共享锁与排他锁的读读/读写互斥关系
InnoDB的行锁分为两种模式:
- 共享锁(S锁):允许持有者读取该记录,也允许其他事务获取同一条记录的S锁。
- 排他锁(X锁):允许持有者对记录进行修改或删除,在同一时刻内,不允许其他事务再获取该记录的S锁或X锁。
这两种锁的兼容矩阵是:
| 锁模式 | S锁 | X锁 | | S锁 | 兼容 | 互斥 | | X锁 | 互斥 | 互斥 |
通俗点说:S锁之间友好相处(可以并发读同一条记录),S锁和X锁互不相让(读的时候不允许改),X锁之间也互不相让(改的时候不允许再改)。
2.2 行锁加锁的典型SQL与实操测试
动手验证一下。建一张最基础的用户表:
CREATE TABLE `t_user` ( `id` int NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL, `age` int DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; INSERT INTO t_user(id, name, age) VALUES (1, '张三', 10), (3, '李四', 20), (5, '王五', 30);开启两个会话模拟并发:
-- 会话A START TRANSACTION; SELECT * FROM t_user WHERE id = 1 FOR UPDATE; -- 此时id=1这条记录被加了X锁 -- 会话B START TRANSACTION; SELECT * FROM t_user WHERE id = 1 FOR UPDATE; -- 这里会一直阻塞,直到会话A提交或回滚如果会话B执行的是SELECT * FROM t_user WHERE id = 3 FOR UPDATE,则完全不会阻塞,因为锁定的索引记录不同。这就是行锁的粒度优势——把锁冲突控制在最小范围,允许不同行上的并发读写。
2.3 主键、唯一索引与普通索引上的行锁差异
行锁落在不同的索引上,效果是有区别的:
- 主键索引:锁直接落在聚簇索引记录上,定位最精确。
- 唯一索引(非主键):InnoDB会先锁唯一索引记录,再通过回表锁定聚簇索引记录。
- 普通索引:同样会锁普通索引记录和聚簇索引记录,但普通索引允许重复值,加锁的记录条数不止一条。
这里埋一个重要的伏笔:只要加锁路径走了索引,即使你Update的是十行,只要索引范围小,锁的范围也小;但如果一条UPDATE的WHERE条件无法使用索引,InnoDB会扫描聚簇索引的所有记录,相当于对全表每条记录都加X锁——表面是行锁,实际表现和表锁差不多。这个坑我在第五部分详细说。
3. 间隙锁(Gap Lock):锁住"不存在"的区间
行锁能管住已有的记录,却管不住"还没有插入的记录"。假设事务A执行SELECT * FROM t_user WHERE age = 20 FOR UPDATE,锁住了age=20那条记录,事务B往这个区间插入一条age=15的新记录,是否受影响?如果只有行锁,完全不受影响,因为新记录是"不存在"的,行锁锁不到它。结果就是:事务A第二次执行同样的查询,发现结果集里多了一条记录,幻读出现了。
3.1 间隙锁的区间判定:以数据为界的开区间
间隙锁锁定的不是某条记录,而是两条索引记录之间的"空白区间"。仍然以上面的t_user表为例,假设按age字段建了索引,age列上有10、20、30三条记录,那么age上的间隙锁可以锁这些区间:
- (-∞, 10)
- (10, 20)
- (20, 30)
- (30, +∞)
注意这些区间都是开区间,两端是已存在的索引记录值。如果事务A锁住了间隙(10,20),事务B想要往这个间隙里插入age=15的新记录,会被阻塞,直到事务A提交或回滚。
3.2 间隙锁为什么要存在:幻读问题与插入冲突
间隙锁存在的全部意义,就是把"间隙"这种不存在的区域也纳入锁定范围,从而阻止其他事务在间隙中插入新记录。这样,事务A在RR隔离级别下反复执行同一个当前读查询,结果集就不会多出新行——幻读被从机制上掐死了。
我来模拟一个典型的间隙锁场景:
-- 会话A START TRANSACTION; SELECT * FROM t_user WHERE age = 20 FOR UPDATE; -- age上建立了普通索引idx_age -- 这条语句不仅会锁住age=20对应的聚簇索引记录, -- 还会锁住间隙(10, 20),防止其他事务插入age在10到20之间的记录 -- 实际加锁区间为(10, 20],也就是临键锁,这点第四部分细说 -- 会话B START TRANSACTION; INSERT INTO t_user(name, age) VALUES ('赵六', 15); -- 阻塞!因为age=15落在间隙(10, 20)中这个例子中,会话B的插入操作会被间隙锁挡住。很多人第一次遇到这种情况都会很困惑:明明插入的是另一条新记录,为什么会被锁住?就是因为你锁住了它应该落进去的"间隙"。
3.3 间隙锁的副作用与死锁温床
间隙锁是解决幻读的功臣,但也带来了两个不容忽视的副作用:
副作用一是锁范围变大,并发度下降。行锁只堵一条记录的读写,间隙锁则堵住整个区间的插入操作,相当于把一个范围内的插入全部串行化。
副作用二是间隙锁本身是死锁的温床。因为间隙锁与间隙锁之间是兼容的,两个事务可以同时锁住同一个间隙,但谁都别想往这个间隙里插入新记录——一旦互相等对方的间隙释放,死锁就产生了。后面第六部分我会给一个真实的死锁拆解案例。
还有一点容易被忽略:间隙锁只在RR及以上隔离级别生效。如果切到READ COMMITTED隔离级别,间隙锁会被禁用,只保留行锁。这也是很多高并发团队宁可牺牲一点隔离性,也要把隔离级别降到RC的原因之一。
4. 临键锁(Next-Key Lock):行锁与间隙锁的组合体
临键锁可以说是InnoDB锁机制里最精妙的设计。它把"行锁"和"间隙锁"合并成一个原子动作,锁定范围用的是左开右闭区间。
4.1 左开右闭的区间定义与示例
一条临键锁的区间可以表示为(a, b],其中a是上一条索引记录的值,b是当前索引记录的值。它同时做了两件事:锁住了(a,b)之间的间隙,还锁住了b这条记录本身。继续看t_user表,age列上有10、20、30三条记录,那么age上的临键锁区间为:
- (∞, 10]
- (10, 20]
- (20, 30]
- (30, +∞]
看到区别了吗?和间隙锁相比,每个区间多了一个右端点,也就是多锁住了一条记录。这意味着:临键锁既能防止其他事务往间隙里插入新记录,又能防止其他事务修改/删除右端点这条记录。
4.2 范围查询与等值查询中的临键锁加锁过程
为什么InnoDB选择临键锁作为默认加锁单位?我拿一个范围查询来演示:
-- 会话A START TRANSACTION; SELECT * FROM t_user WHERE age >= 20 FOR UPDATE;这条语句在age索引上扫描时,会依次加这些临键锁:
- (10, 20]
- (20, 30]
- (30, +∞]
它覆盖了age从20开始的全部记录及其前面的间隙、以及最后一段无上界的间隙。会话B如果试图INSERT INTO t_user(name, age) VALUES ('周七', 25),会被阻塞,因为25落在(20,30]这个区间里。
等值查询的情况则微妙得多。在唯一索引上做等值查询且命中时,临键锁会退化为纯行锁,因为唯一索引不会出现两条相同的记录,幻读的威胁不存在,没必要锁间隙。这个"退化"规则是整个加锁机制里最实用的知识点,第五部分展开。
4.3 为什么RR隔离级别默认使用临键锁
因为临键锁是"最安全"的加锁方式。它同时解决了两类问题:已有记录的并发修改冲突(行锁的职责)和新记录插入产生的幻读(间隙锁的职责)。把这两件事放在同一个锁动作里完成,事务在执行当前读时,心里非常踏实:锁过的区间内,既不能改旧数据,也别想插新数据。
代价同样是并发度下降。一条等值查询,如果走的是普通索引,可能连锁住一大段区间,线上如果频繁执行这类SQL,插入操作的等待会非常明显。所以临键锁绝不是"越多越好"——它是一个基于正确性和性能之间的平衡设计,我们在实际优化中,经常通过精确利用"退化"规则来减少锁范围。
5. 加锁规则的黄金法则与实际坑
这部分是我在实际排查中反复验证过的核心规则。理解了它,你基本能手动推演一条SQL加了哪些锁,死锁分析也不再是玄学。
5.1 等值查询命中与未命中的加锁差异
等值查询,是加锁规则中最容易踩坑的地方。我把几种情况整理成一张表:
| 查询条件 | 索引类型 | 命中情况 | 实际加锁 | | 等值查询 | 唯一索引 | 命中 | 退化为行锁,只锁目标记录 | | 等值查询 | 唯一索引 | 未命中 | 退化为间隙锁,锁住目标值所在间隙 | | 等值查询 | 普通索引 | 命中 | 临键锁 + 下一记录的间隙锁退化 | | 等值查询 | 普通索引 | 未命中 | 间隙锁,锁住目标值所在间隙 |
看第二行:唯一索引等值查询未命中,这是新手最容易忽略的情况。举个例子,表里主键id有1、5、9三条记录,事务A执行SELECT * FROM t_user WHERE id = 7 FOR UPDATE。id=7不存在,但这条SQL不会因为没查到数据就万事大吉,它会在(5,9)这个间隙上加间隙锁。这时会话B试图插入id=8,会被阻塞。
为什么会这样?因为InnoDB无法预知id=8是否会在当前事务后续步骤中被需要。为了保持RR隔离级别的一致性,它宁可把整个间隙锁住,也不让任何新记录趁虚而入。
5.2 非唯一索引等值查询的回表加锁坑
非唯一索引等值查询命中时,加的锁远比想象中多。还是那张t_user表,age列有普通索引,现有记录id=1/age=10、id=3/age=20、id=5/age=30、id=7/age=40:
-- 会话A START TRANSACTION; SELECT * FROM t_user WHERE age = 20 FOR UPDATE;这条SQL的加锁路径分成两段:
- 在idx_age普通索引上,加临键锁(10,20],并额外对(20,30)范围加间隙锁(因为要向右遍历到第一个不满足条件的值30)。
- 通过回表,对聚簇索引中的id=3记录加行锁。
也就是说,age=20这个等值查询,实际上把[10,30)这个范围内的"插入动作"和记录"id=3"都锁住了。很多并发插入的卡顿,就死在这个"看似一条记录的查询,却锁了半个索引区间"的设计上。
5.3 范围查询"访问到不满足条件的第一条记录"意味着什么
范围查询加锁的终点,往往比你想的更远。执行:
SELECT * FROM t_user WHERE id >= 3 FOR UPDATEInnoDB会从id=3开始,一路加临键锁,直到碰到第一个不满足条件的记录才停下来。假设表里id最大是7,那会一直锁到(7, +∞)这个上界间隙,意味着整个表的新插入都被堵死。
这就是为什么"范围查询加锁范围大"不只是一句忠告,而是有明确技术依据的:它要保证事务期间这个查询的范围不再产生新数据,必须锁定到索引边界。生产环境里,但凡UPDATE或DELETE的WHERE条件是范围表达式,都要格外小心。我见过太多因为一个UPDATE ... WHERE create_time < '2025-01-01'把整个业务表写操作堵半天的案例,create_time上虽然有索引,但范围查询的临键锁直接锁到了正无穷。
5.4 无索引条件下的行锁"伪表锁"
最后补一个最隐蔽的坑:如果WHERE条件没法使用索引,InnoDB会退化为扫描全表的聚簇索引记录,逐条加临键锁。虽然锁的模式还是行级别的,但因为所有记录都被锁住了,新插入的记录即便落在空位也无法落下去(上界间隙也被锁了),实际效果和表锁没有区别。
判断标准很简单:用EXPLAIN看执行计划,type显示ALL或者extra里出现Using where但没走索引,就说明这条SQL在锁层面已经失控了。优化方向是给WHERE条件涉及的列建立合适的索引,而不是指望数据库自己"聪明地"绕过某些记录。
6. 死锁排查:从现象到根因的完整链路
理论说得再多,不如一个真实的死锁案例来得直观。我拿最经典的双间隙锁死锁做完整拆解。
6.1 经典死锁场景拆解:两个事务互相等待间隙锁
还是t_user表,表中只有id=1和id=5两条记录。
-- 事务A -- 事务B START TRANSACTION; START TRANSACTION; UPDATE t_user SET name='A' WHERE id = 3; -- 加锁:间隙锁(1,5) UPDATE t_user SET name='B' WHERE id = 4; -- 加锁:间隙锁(1,5) INSERT INTO t_user(id,name) VALUES (2,'C'); -- 需要向间隙(1,5)插入id=2, -- 等待事务B释放间隙锁 INSERT INTO t_user(id,name) VALUES (3,'D'); -- 需要向间隙(1,5)插入id=3, -- 等待事务A释放间隙锁 -- DEADLOCK! InnoDB检测到死锁, -- 回滚其中一个事务注意,两个UPDATE都没有命中任何记录,所以都只加了间隙锁(1,5),而间隙锁之间是兼容的,两个事务相安无事。但当双方都想往这个间隙里插入新记录时,插入动作需要获取插入意向锁,插入意向锁与已有的间隙锁互斥,于是双方互相等待,死锁形成。
InnoDB的死锁检测机制(innodb_deadlock_detect默认开启)会自动回滚代价较小的事务,但业务端会收到明确报错。如果你的代码没做重试补偿,这条请求就失败了。
6.2 SHOW ENGINE INNODB STATUS输出的解读要点
遇到死锁,第一个动作不是看代码,而是拉InnoDB的状态输出:
SHOW ENGINE INNODB STATUS\G重点看LATEST DETECTED DEADLOCK这一段。它包含:
- 两个事务各自的SQL语句;
- 各自持有的锁(显示的锁描述类似
space id ... page no ... n_bits ... index idx_age); - 各自等待的锁;
- 最终被回滚的是哪个事务(
WE ROLL BACK TRANSACTION (1)这种提示)。
如果你用的是MySQL 8.0,除了这个输出,还可以查performance_schema下的锁等待表:
SELECT * FROM performance_schema.data_locks; SELECT * FROM performance_schema.data_lock_waits;这两张表能实时看到当前所有锁的持有者(ENGINE_TRANSACTION_ID)和等待关系(REQUESTING_ENGINE_TRANSACTION_ID),排查锁等待比看死锁日志更快。
6.3 降低锁冲突的落地优化建议
从锁机制的角度,我梳理了几条切实有效的优化方向:
| 优化措施 | 原理 | 适用场景 | | 尽可能走唯一索引等值查询 | 让临键锁退化为行锁,最小化锁范围 | UPDATE/DELETE的WHERE条件优化 | | 范围查询拆分为等值查询或缩小范围 | 减少扫描的索引记录数,减少临键锁区间 | 报表统计、批量处理 | | 事务保持短小,尽快提交 | 缩短锁持有时间,降低死锁窗口 | 所有在线业务 | | 降低隔离级别到READ COMMITTED | 禁用间隙锁和临键锁,只保留行锁 | 可容忍不可重复读的子系统 | | 避免无索引条件的大范围更新 | 防止行锁退化为"伪表锁" | 定期批量更新任务 |
这里特别说一下"事务短小"。很多死锁不是必然发生,而是窗口期太长——两个事务各自持有一把锁,然后在较长的一段时间里才去请求对方的锁,这期间任何一点交叉都会引爆死锁。把事务里无关的查询挪出去、减少事务中的网络往返、及时提交,死锁概率会大幅下降。
6.4 关于死锁重试与业务补偿的实操建议
锁机制能做到的是"尽可能减少死锁",但完全避免死锁是不现实的。所以我在实际项目中,都会要求业务层对死锁错误码(MySQL的1213,对应ER_LOCK_DEADLOCK)做显式重试。一般来说,死锁自动回滚后,把当前事务的整个操作重跑一次就能成功,因为死锁是瞬时的资源竞争问题,不会持续存在。
重试要注意两点:一是要区分死锁(1213)和锁等待超时(1205),后者通常意味着某条SQL运行时间过长,盲目重试只会让系统更慢;二是重试次数要有限制,常见做法是3次以内,每次间隔几十毫秒到几百毫秒的随机退避,避免多个客户端同时重试再次撞车。
7. 把锁机制用到日常SQL审核与设计中去
光会排查还不够。我更想说的是,锁知识最大的价值在"事前预防"——在SQL上线之前,就基本能判断它会加什么范围的锁,会不会影响线上的写入。这是我们团队SQL评审的基本功。
我总结了一个懒人判断法:
- 首先看WHERE条件是否走索引;
- 然后看走的是唯一索引还是普通索引;
- 再看是等值查询还是范围查询;
- 最后结合当前的隔离级别判断是否会产生间隙锁。
这三步走下来,一条SQL的锁范围基本能画个八九不离十。每次提交SQL变更前,花一分钟过一遍这个过程,能避免大量的线上事故。如果你所在团队没有SQL评审机制,我建议至少在关键更新语句前面,加一行注释标明"预计加锁范围",出了问题也方便回溯。
等线上真的出现死锁,再按第六部分的链路去查,效率会高很多。毕竟锁机制这种东西,等到线上炸了再去翻文档,代价太大了。