☰
MySQL锁机制详解:行锁、间隙锁、临键锁如何引发死锁与优化策略
2026/10/1 3:50:42 网站建设 项目流程

曾有段时间,我被线上一个"订单创建接口频繁超时"的告警折腾得头大。监控面板里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 UPDATE

InnoDB会从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评审机制,我建议至少在关键更新语句前面,加一行注释标明"预计加锁范围",出了问题也方便回溯。

等线上真的出现死锁,再按第六部分的链路去查,效率会高很多。毕竟锁机制这种东西,等到线上炸了再去翻文档,代价太大了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询