本页内容受版权保护 · 已添加水印 · 禁止任何形式转载
事务与并发(ACID / 隔离级别 / 锁 / MVCC) · 锁机制

什么是死锁?InnoDB 如何检测死锁?面试常考的死锁示例?

What is a deadlock? How does InnoDB detect deadlocks? Common deadlock examples in interviews?

答案语言

考察要点

这道题考察你对数据库并发控制核心问题的理解。面试官想看你是否懂:

  1. 死锁的本质:即多个事务因循环等待对方持有的资源而无限期阻塞的现象。
  2. InnoDB 的内部机制:包括其主动死锁检测的 wait-for graph (等待图) 算法,以及被动的 innodb_lock_wait_timeout 机制。
  3. 实战分析能力:能否通过具体的 SQL 序列,分析出死锁的成因,并给出解决方案。

核心概念

1. 什么是死锁?

死锁是指两个或多个事务在同一资源上相互占用,并请求锁定对方占用的资源,从而导致恶性循环的现象。当多个事务处于这种状态时,若无外力干预,它们将永远无法继续执行。

死锁的产生必须同时满足以下四个必要条件:

  1. 互斥条件 (Mutual Exclusion):一个资源每次只能被一个事务使用。
  2. 请求与保持条件 (Hold and Wait):一个事务因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不可剥夺条件 (No Preemption):事务已获得的资源,在未使用完之前,不能强行剥夺。
  4. 循环等待条件 (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 by
2 T1 ----------------------------> T2
3 ^ |
4 | |
5 | waits for lock held by | waits for lock held by
6 | v
7 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;
5
6INSERT INTO `user_account` VALUES (1, 100), (2, 200);

死锁复现序列:

时间点事务 A (Session A)事务 B (Session B)锁状态分析
T1BEGIN;事务 A 启动
T2BEGIN;事务 B 启动
T3UPDATE user_account SET balance = balance - 10 WHERE id = 1;事务 A 成功获取 id=1 的行X锁
T4UPDATE user_account SET balance = balance + 10 WHERE id = 2;事务 B 成功获取 id=2 的行X锁
T5UPDATE user_account SET balance = balance + 10 WHERE id = 2;事务 A 尝试获取 id=2 的行X锁,但该锁被事务 B 持有,事务 A 进入等待状态。
T6UPDATE 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。

常见低分回答

  1. 混淆死锁和锁等待超时:回答“我的查询跑了很久然后报错了,就是死锁了”。这是错误的,锁等待超时不等于死锁,它仅仅是等待时间过长。
  2. 对死锁成因解释不清:只说“两个事务互相锁住了”,但无法说出“循环等待”这一核心,也无法列举四个必要条件。
  3. 认为死锁是数据库的 Bug:将死锁归咎于 MySQL 本身,而不是分析应用层的并发逻辑问题。死锁绝大多数情况是应用代码逻辑不当造成的。
  4. 对 InnoDB 如何解决死锁机制模糊:只知道有超时机制,不知道有更重要的 wait-for graph 主动检测机制,更不清楚 InnoDB 会选择“代价最小”的事务进行回滚。
  5. 认为只有 UPDATE 会导致死锁:忽略了 SELECT ... FOR UPDATE, DELETE, INSERT(尤其是在有唯一键冲突时,会产生间隙锁或行锁)等同样会加锁并可能导致死锁的语句。

工程实践与优化

  1. 诊断死锁:

    • 在生产环境中,定位死锁的首要工具是 SHOW ENGINE INNODB STATUS。
    • 查看其输出中的 LATEST DETECTED DEADLOCK 部分,这里详细记录了死锁发生的时间、涉及的事务、它们正在执行的 SQL 语句、持有的锁和等待的锁。这是分析死锁根源的最直接证据。
    • 对于频繁发生的死锁,可以开启 innodb_print_all_deadlocks 参数,将所有死锁信息记录到 MySQL 的 error log 中,便于事后分析。
  2. 避免和减少死锁的策略:

    • 约定加锁顺序:确保不同事务总是以相同的顺序获取锁。例如,在转账业务中,规定必须先锁定 ID 较小的账户,再锁定 ID 较大的账户。这样就破坏了“循环等待”条件。
    • 减小事务粒度,缩短持有锁的时间:将长事务拆分为多个短事务,尽快 COMMIT 或 ROLLBACK。不要在事务中执行耗时长的操作(如 RPC 调用、复杂计算)。
    • 使用更低级别的隔离级别:如果业务允许,将隔离级别从 REPEATABLE READ 降为 READ COMMITTED,可以减少间隙锁(Gap Lock)的使用,从而降低死锁概率。
    • 使用合理的索引:确保 WHERE 条件中的列都有合适的索引。如果 UPDATE 或 DELETE 语句没有走索引,会导致全表扫描,锁定大量行甚至锁表,极大地增加死锁风险。
    • 使用乐观锁:对于并发写入不那么激烈的场景,可以引入 version 字段,通过乐观锁机制代替悲观锁(如 SELECT ... FOR UPDATE),在应用层面处理冲突。
    • 做好重试机制:由于死锁在复杂高并发系统中难以完全避免,应用层必须有捕获死锁异常并进行重试的逻辑。通常重试 2-3 次即可成功。

常见追问与延伸题目

  1. 追问:刚刚你提到的死锁例子,在代码层面如何避免?

    • 回答要点:核心是保证加锁顺序。比如在转账 transfer(from_id, to_id, amount) 函数中,可以这样实现:
      pseudo-code
      1// 保证始终先锁 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,从而破坏了循环等待条件,避免了死锁。
  2. 追问:INSERT 语句也会导致死锁吗?请举例说明。

    • 回答要点:会的,尤其是在有唯一索引的情况下,INSERT 可能会因为重复键检查而产生锁。一个经典的例子是“插入意向锁(Insert Intention 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 并持有锁,就会形成死锁。
    • 更常见的是,两个事务都想插入一个不存在于表中,但在同一个索引间隙(Gap)内的值。例如,表中已有数据 5 和 15,事务 A INSERT 10,事务 B INSERT 12。它们都会在 (5, 15) 这个间隙上请求插入意向锁,并可能互相等待对方持有的间隙锁,从而导致死锁。
  3. 追问:什么是活锁(Livelock)?它和死锁有什么区别?

    • 回答要点:
      • 死锁 (Deadlock):进程/事务处于完全阻塞状态,占用 CPU 资源为 0,都在等待对方释放资源,系统无任何进展。
      • 活锁 (Livelock):进程/事务处于运行状态,没有阻塞,CPU 资源是 100% 占用的,但它们在不断地改变自身状态以响应对方的动作,导致整个系统没有有效进展。
      • 类比:
        • 死锁就像两辆车在单行道的桥中间相遇,谁也不肯退,都卡住了。
        • 活锁就像两个人狭路相逢,你往左让,我也往左让;你赶紧往右让,我也赶紧往右让,来来回回,谁也过不去,但双方都在不停地“尝试”让路。
      • 在数据库中,活锁不常见,但可能出现在一些基于重试的乐观锁或无锁算法中,如果冲突解决策略设计不当,可能导致事务不断地重试、失败、再重试,消耗大量资源却无法完成。