事务与并发(ACID / 隔离级别 / 锁 / MVCC) · 锁机制
什么是死锁?InnoDB 如何检测死锁?面试常考的死锁示例?
What is a deadlock? How does InnoDB detect deadlocks? Common deadlock examples in interviews?
答案语言
考察要点
这道题考察你对数据库并发控制核心问题的理解。面试官想看你是否懂:
- 死锁的本质:即多个事务因循环等待对方持有的资源而无限期阻塞的现象。
InnoDB的内部机制:包括其主动死锁检测的 wait-for graph (等待图) 算法,以及被动的innodb_lock_wait_timeout机制。- 实战分析能力:能否通过具体的 SQL 序列,分析出死锁的成因,并给出解决方案。
核心概念
1. 什么是死锁?
死锁是指两个或多个事务在同一资源上相互占用,并请求锁定对方占用的资源,从而导致恶性循环的现象。当多个事务处于这种状态时,若无外力干预,它们将永远无法继续执行。
死锁的产生必须同时满足以下四个必要条件:
- 互斥条件 (Mutual Exclusion):一个资源每次只能被一个事务使用。
- 请求与保持条件 (Hold and Wait):一个事务因请求资源而阻塞时,对已获得的资源保持不放。
- 不可剥夺条件 (No Preemption):事务已获得的资源,在未使用完之前,不能强行剥夺。
- 循环等待条件 (Circular Wait):若干事务之间形成一种头尾相接的循环等待资源关系。
2. InnoDB 死锁检测机制
InnoDB 采用两种策略来处理死锁问题:
a. 主动检测:等待图 (wait-for graph)
这是 InnoDB 主要的死锁检测方式。
- 原理:
InnoDB内部维护一个以事务为节点、以锁等待关系为边的有向图。 - 图的构建:
- 图中的节点代表事务。
- 如果事务 T1 正在等待事务 T2 持有的锁,则图中会有一条从 T1 指向 T2 的边。
- 死锁检测:当一个事务请求锁并需要等待时,
InnoDB会检查这个等待是否会在图中形成一个环路。例如:T1 -> T2 -> T1。 - 处理方式:一旦检测到环路,
InnoDB就认为发生了死锁。它会选择一个“代价”最小的事务作为“牺牲品”进行回滚,从而打破环路,让其他事务继续执行。选择“牺牲品”的依据通常是事务产生的 undo log 的大小(即回滚成本)。
ASCII 图示:
text
1 waits for lock held by2 T1 ----------------------------> T23 ^ |4 | |5 | waits for lock held by | waits for lock held by6 | v7 T4 <---------------------------- T3
在上图中,如果 T2 也开始等待 T4 持有的锁,就会形成 T2 -> T4 的边,构成 T2 -> T4 -> T1 -> T2 的环路,从而触发死锁。
b. 被动处理:锁等待超时 (innodb_lock_wait_timeout)
这是一个备用策略,当主动检测由于某些原因(如过于复杂)未能立即发现死锁时生效。
- 原理:为事务的锁等待设置一个时间上限,默认为 50 秒。
- 触发:如果一个事务等待一个锁超过了这个时间,它会自动放弃并回滚,并抛出错误
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction。 - 与死锁检测的区别:超时不一定是死锁。它也可能是因为某个事务执行时间过长,长时间持有锁,导致其他事务单纯地等待超时。而 wait-for graph 检测到的是确确实实的循环等待。
3. 面试常考的死锁示例
最经典的死锁场景是:两个事务以相反的顺序更新多行数据。
数据准备:
sql
1CREATE TABLE `user_account` (2 `id` INT PRIMARY KEY,3 `balance` DECIMAL(10, 2)4) ENGINE=InnoDB;56INSERT INTO `user_account` VALUES (1, 100), (2, 200);
死锁复现序列:
| 时间点 | 事务 A (Session A) | 事务 B (Session B) | 锁状态分析 |
|---|---|---|---|
| T1 | BEGIN; | 事务 A 启动 | |
| T2 | BEGIN; | 事务 B 启动 | |
| T3 | UPDATE user_account SET balance = balance - 10 WHERE id = 1; | 事务 A 成功获取 id=1 的行X锁 | |
| T4 | UPDATE user_account SET balance = balance + 10 WHERE id = 2; | 事务 B 成功获取 id=2 的行X锁 | |
| T5 | UPDATE user_account SET balance = balance + 10 WHERE id = 2; | 事务 A 尝试获取 id=2 的行X锁,但该锁被事务 B 持有,事务 A 进入等待状态。 | |
| T6 | UPDATE user_account SET balance = balance - 10 WHERE id = 1; | 事务 B 尝试获取 id=1 的行X锁,但该锁被事务 A 持有,事务 B 进入等待状态。 |
此时,死锁形成:
- 事务 A 持有
id=1的锁,等待id=2的锁。 - 事务 B 持有
id=2的锁,等待id=1的锁。 InnoDB的 wait-for graph 检测到环路,会选择回滚事务 A 或 B,并向客户端返回死锁错误ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction。
常见低分回答
- 混淆死锁和锁等待超时:回答“我的查询跑了很久然后报错了,就是死锁了”。这是错误的,锁等待超时不等于死锁,它仅仅是等待时间过长。
- 对死锁成因解释不清:只说“两个事务互相锁住了”,但无法说出“循环等待”这一核心,也无法列举四个必要条件。
- 认为死锁是数据库的 Bug:将死锁归咎于 MySQL 本身,而不是分析应用层的并发逻辑问题。死锁绝大多数情况是应用代码逻辑不当造成的。
- 对
InnoDB如何解决死锁机制模糊:只知道有超时机制,不知道有更重要的 wait-for graph 主动检测机制,更不清楚InnoDB会选择“代价最小”的事务进行回滚。 - 认为只有
UPDATE会导致死锁:忽略了SELECT ... FOR UPDATE,DELETE,INSERT(尤其是在有唯一键冲突时,会产生间隙锁或行锁)等同样会加锁并可能导致死锁的语句。
工程实践与优化
-
诊断死锁:
- 在生产环境中,定位死锁的首要工具是
SHOW ENGINE INNODB STATUS。 - 查看其输出中的
LATEST DETECTED DEADLOCK部分,这里详细记录了死锁发生的时间、涉及的事务、它们正在执行的 SQL 语句、持有的锁和等待的锁。这是分析死锁根源的最直接证据。 - 对于频繁发生的死锁,可以开启
innodb_print_all_deadlocks参数,将所有死锁信息记录到 MySQL 的 error log 中,便于事后分析。
- 在生产环境中,定位死锁的首要工具是
-
避免和减少死锁的策略:
- 约定加锁顺序:确保不同事务总是以相同的顺序获取锁。例如,在转账业务中,规定必须先锁定 ID 较小的账户,再锁定 ID 较大的账户。这样就破坏了“循环等待”条件。
- 减小事务粒度,缩短持有锁的时间:将长事务拆分为多个短事务,尽快
COMMIT或ROLLBACK。不要在事务中执行耗时长的操作(如 RPC 调用、复杂计算)。 - 使用更低级别的隔离级别:如果业务允许,将隔离级别从
REPEATABLE READ降为READ COMMITTED,可以减少间隙锁(Gap Lock)的使用,从而降低死锁概率。 - 使用合理的索引:确保
WHERE条件中的列都有合适的索引。如果UPDATE或DELETE语句没有走索引,会导致全表扫描,锁定大量行甚至锁表,极大地增加死锁风险。 - 使用乐观锁:对于并发写入不那么激烈的场景,可以引入
version字段,通过乐观锁机制代替悲观锁(如SELECT ... FOR UPDATE),在应用层面处理冲突。 - 做好重试机制:由于死锁在复杂高并发系统中难以完全避免,应用层必须有捕获死锁异常并进行重试的逻辑。通常重试 2-3 次即可成功。
常见追问与延伸题目
-
追问:刚刚你提到的死锁例子,在代码层面如何避免?
- 回答要点:核心是保证加锁顺序。比如在转账
transfer(from_id, to_id, amount)函数中,可以这样实现:通过这种方式,无论调用pseudo-code1// 保证始终先锁 id 小的,再锁 id 大的2if (from_id < to_id) {3 lock_row(from_id);4 lock_row(to_id);5} else {6 lock_row(to_id);7 lock_row(from_id);8}9// ... 执行转账操作 ...10// ... 释放锁并提交 ...transfer(1, 2, ...)还是transfer(2, 1, ...),内部加锁的顺序都是先锁id=1再锁id=2,从而破坏了循环等待条件,避免了死锁。
- 回答要点:核心是保证加锁顺序。比如在转账
-
追问:
INSERT语句也会导致死锁吗?请举例说明。- 回答要点:会的,尤其是在有唯一索引的情况下,
INSERT可能会因为重复键检查而产生锁。一个经典的例子是“插入意向锁(InsertIntention Lock)”冲突。 - 示例:假设
id是唯一索引。- 事务 A 执行
INSERT INTO t VALUES (10);但由于某种原因(如触发器执行慢)尚未提交。它会持有id=10的行锁。 - 事务 B 执行
INSERT INTO t VALUES (10);,它会检查id=10是否存在,发现事务 A 已经持有该锁,于是事务 B 等待。 - 如果此时事务 A 又执行
INSERT INTO t VALUES (11);,而事务 B 之前也插入过id=11并持有锁,就会形成死锁。
- 事务 A 执行
- 更常见的是,两个事务都想插入一个不存在于表中,但在同一个索引间隙(Gap)内的值。例如,表中已有数据 5 和 15,事务 A
INSERT 10,事务 BINSERT 12。它们都会在 (5, 15) 这个间隙上请求插入意向锁,并可能互相等待对方持有的间隙锁,从而导致死锁。
- 回答要点:会的,尤其是在有唯一索引的情况下,
-
追问:什么是活锁(Livelock)?它和死锁有什么区别?
- 回答要点:
- 死锁 (Deadlock):进程/事务处于完全阻塞状态,占用 CPU 资源为 0,都在等待对方释放资源,系统无任何进展。
- 活锁 (Livelock):进程/事务处于运行状态,没有阻塞,CPU 资源是 100% 占用的,但它们在不断地改变自身状态以响应对方的动作,导致整个系统没有有效进展。
- 类比:
- 死锁就像两辆车在单行道的桥中间相遇,谁也不肯退,都卡住了。
- 活锁就像两个人狭路相逢,你往左让,我也往左让;你赶紧往右让,我也赶紧往右让,来来回回,谁也过不去,但双方都在不停地“尝试”让路。
- 在数据库中,活锁不常见,但可能出现在一些基于重试的乐观锁或无锁算法中,如果冲突解决策略设计不当,可能导致事务不断地重试、失败、再重试,消耗大量资源却无法完成。
- 回答要点: