InnoDB 有哪些锁?死锁是怎么产生的,怎么排查?

深入原理场景题约 7 分钟读完

一句话回答

按模式分为共享锁(S)和排他锁(X);按粒度分为表级的意向锁,以及行级的记录锁、间隙锁和临键锁。行锁加在索引上,查询没走索引时会锁住扫描到的所有记录。死锁是两个事务各自持有对方需要的锁、互相等待,InnoDB 检测到后会回滚其中一个,用 SHOW ENGINE INNODB STATUS 可以查看最近一次死锁。避免死锁要固定加锁顺序、缩短事务、建好索引。

详细解析

锁的种类

锁 粒度 说明
共享锁 S 行或表 SELECT ... FOR SHARE 加行级 S 锁,多个事务可以同时持有,但会阻止别人加 X 锁
排他锁 X 行或表 UPDATE、DELETE、SELECT ... FOR UPDATE 加行级 X 锁,和别的事务的 S 锁、X 锁都冲突
意向锁 IS / IX 表 加行级 S 锁或 X 锁之前,先在表上加对应的意向锁
记录锁 行 锁住一条索引记录
间隙锁 行 锁住两条索引记录之间的间隙,阻止别人往里插入
临键锁 行 记录锁 + 记录前面的间隙,左开右闭,如 (10, 20]
插入意向锁 行 INSERT 之前加的一种特殊间隙锁,会被别人的间隙锁挡住

意向锁为什么存在:有人要加表锁(如 LOCK TABLES ... WRITE)时,需要确认表里没有任何行被锁住。没有意向锁就得逐行检查;有了意向锁,看一眼表上有没有 IX 就知道了。意向锁之间互相兼容,只会和表级的 S、X 锁冲突(IS 只和表级 X 锁冲突),不影响行锁的并发。

间隙锁主要在可重复读和串行化下使用,目的是防止幻读;读已提交下只在检查外键和唯一键冲突时才会用到。间隙锁之间不冲突,两个事务可以锁同一个间隙,它们阻止的只是插入。

锁加在索引上

可重复读下大致的加锁规则(细节在不同版本中有差异,以实际观察为准):

  • 唯一索引等值查询,记录存在:只加记录锁
  • 唯一索引等值查询,记录不存在:在它所在的间隙加间隙锁
  • 普通索引等值查询:给匹配的记录加临键锁,还会锁住最后一条匹配记录之后的间隙
  • 范围查询:给扫描到的范围加临键锁
  • 通过二级索引加锁时,对应的主键索引记录通常也会被锁住

最需要警惕的是没走索引:UPDATE user SET status = 0 WHERE name = 'Tom',如果 name 上没有索引,就要扫描整个聚簇索引,扫过的每一条记录和间隙都会被锁住,效果接近锁表。读已提交下,不满足条件的行判断完就会释放锁,影响小一些,但扫描本身的开销还在。

MySQL 8.0 可以查 performance_schema.data_locks 看当前持有的锁:LOCK_MODE 为 X 是临键锁,X,REC_NOT_GAP 是记录锁,X,GAP 是间隙锁,带 INSERT_INTENTION 的是插入意向锁。

死锁是怎么产生的

两个事务以相反的顺序锁同样的两行:

SQL
-- 事务 A
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 1;  -- 锁住 id = 1

-- 事务 B
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 2;  -- 锁住 id = 2

-- 事务 A
UPDATE account SET balance = balance + 100 WHERE id = 2;  -- 等待 B 释放 id = 2

-- 事务 B
UPDATE account SET balance = balance + 100 WHERE id = 1;  -- 等待 A,形成环
-- ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

InnoDB 默认开启死锁检测(innodb_deadlock_detect):检查事务之间的锁等待关系有没有形成环,发现死锁就立即回滚其中一个事务,一般选修改行数较少、回滚代价小的那个。被回滚的事务收到 1213 错误,另一个事务继续执行。

怎么排查和避免

排查:

  1. 执行 SHOW ENGINE INNODB STATUS\G,看 LATEST DETECTED DEADLOCK 部分:两个事务各自在执行的 SQL、持有的锁(HOLDS THE LOCK(S))、在等的锁(WAITING FOR THIS LOCK TO BE GRANTED),以及回滚了哪个事务
  2. 这里只保留最近一次死锁,开启 innodb_print_all_deadlocks 可以把每次死锁都记到错误日志里
  3. 根据锁在哪个索引上、是什么类型,回到业务代码找出加锁顺序冲突的地方

避免:

  • 固定加锁顺序:比如转账时总是先锁 ID 小的账户
  • 事务尽量短:不要在事务里调用外部接口、等待用户操作,锁持有得越久越容易冲突
  • 建好索引:让更新语句走索引,少锁无关的行
  • 考虑读已提交:基本没有间隙锁,由间隙锁引起的死锁会少很多
  • 应用层重试:死锁无法完全杜绝,捕获 1213 后重试整个事务

面试官可能追问

先查询记录是否存在、不存在再插入,为什么会死锁?

可重复读下,两个事务同时执行 SELECT * FROM t WHERE id = 5 FOR UPDATE,id = 5 不存在,两个事务都在同一个间隙上拿到了间隙锁,间隙锁之间不冲突。接着 A 插入 id = 5,插入意向锁被 B 的间隙锁挡住;B 也插入 id = 5,又被 A 的间隙锁挡住,形成死锁。可以改用 INSERT ... ON DUPLICATE KEY UPDATE,或者直接插入,靠唯一索引报错来判断记录是否已存在。

死锁和锁等待超时有什么区别?

死锁是互相等待,永远等不到,InnoDB 检测到后立即回滚一个事务,报 1213。锁等待超时是单纯等得太久:某个事务一直持有锁不释放,等待者超过 innodb_lock_wait_timeout(默认 50 秒)后报 1205,默认只撤销当前语句。死锁检测本身也有开销,并发极高时有的系统会关闭它,这时死锁只能靠锁等待超时来解除。

乐观锁和悲观锁怎么选?

悲观锁是先加锁再操作,比如 SELECT ... FOR UPDATE,适合冲突多、必须串行处理的场景。乐观锁不加锁,更新时检查数据有没有被别人改过:UPDATE goods SET stock = stock - 1, version = version + 1 WHERE id = ? AND version = ?,影响行数为 0 就说明发生了冲突,重试或报错。冲突少时乐观锁吞吐更高,冲突多时大量重试反而浪费。

易错点

  • 行锁锁的是索引记录,没走索引的更新会锁住大量记录
  • 间隙锁之间不冲突,冲突发生在间隙锁和插入意向锁之间
  • 普通 SELECT 是快照读,不加行锁(串行化级别除外)
  • 死锁时被回滚的是整个事务,重试要从头执行整个事务,而不是只重试最后一条语句

AI 模拟面试官

用自己的话回答,AI 对照参考答案打分、指出遗漏,再追问,最多 3 轮

登录后就可以和 AI 面试官对练,面试记录也会保存下来。登录

这道题你掌握了吗?

选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。

学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。