InnoDB 有哪些锁?死锁是怎么产生的,怎么排查?
一句话回答
按模式分为共享锁(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 的是插入意向锁。
死锁是怎么产生的
两个事务以相反的顺序锁同样的两行:
-- 事务 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 错误,另一个事务继续执行。
怎么排查和避免
排查:
- 执行
SHOW ENGINE INNODB STATUS\G,看LATEST DETECTED DEADLOCK部分:两个事务各自在执行的 SQL、持有的锁(HOLDS THE LOCK(S))、在等的锁(WAITING FOR THIS LOCK TO BE GRANTED),以及回滚了哪个事务 - 这里只保留最近一次死锁,开启
innodb_print_all_deadlocks可以把每次死锁都记到错误日志里 - 根据锁在哪个索引上、是什么类型,回到业务代码找出加锁顺序冲突的地方
避免:
- 固定加锁顺序:比如转账时总是先锁 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 轮
这道题你掌握了吗?
选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。
学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。