diff --git a/notes/计算机操作系统 - 死锁.md b/notes/计算机操作系统 - 死锁.md
index a70bf8f9..cfcf22dc 100644
--- a/notes/计算机操作系统 - 死锁.md
+++ b/notes/计算机操作系统 - 死锁.md
@@ -21,14 +21,21 @@
* [1. Java 线程死锁](#1-java-线程死锁)
* [2. MySQL 死锁](#2-mysql-死锁)
* [附录:实测](#附录实测)
+ * [A1 Java 线程死锁](#a1-java-线程死锁)
+ * [A2 MySQL 死锁](#a2-mysql-死锁)
+ * [A3 先查后插的间隙锁死锁](#a3-先查后插的间隙锁死锁)
* [参考资料](#参考资料)
## 必要条件
+死锁指的是一组进程中的每个进程都在等待只有组内其它进程才能释放的资源,结果谁都无法继续执行,也不会自己结束。例如下图中 T1 持有 alice 等待 bob,T2 持有 bob 等待 alice。
+
+只有以下四个条件同时成立时才会发生死锁:
+
- 互斥:每个资源要么已经分配给了一个进程,要么就是可用的;
- 占有和等待:已经得到了某个资源的进程可以再请求新的资源;
- 不可抢占:已经分配给一个进程的资源不能强制性地被抢占,它只能被占有它的进程显式地释放;
@@ -110,6 +117,8 @@
已经占有资源的进程请求新资源得不到满足时,必须释放已经占有的资源,之后需要时再重新申请。
+分布式锁设置过期时间也是破坏这个条件:持有锁的进程崩溃后不会释放锁,到期后锁自动失效,相当于被强制收回。代价是业务还没执行完锁就可能过期,被另一个客户端拿到,因此还需要续期,见 [分布式锁](分布式.md#redis-的-set-nx-指令)。
+
### 4. 破坏环路等待
给资源统一编号,进程只能按编号顺序来请求资源。
@@ -194,7 +203,7 @@ synchronized (first) {
}
```
-两个线程各反向转账 10 万次,原来的写法每次运行都会卡住,按 id 顺序加锁后都能完成,两个账户的余额仍为 1000(JDK 21 实测,见[附录:实测](#附录实测))。使用 ReentrantLock 时还可以用 tryLock 设置超时,拿不到锁就释放已持有的锁再重试,破坏的是占有和等待条件。
+两个线程各反向转账 10 万次,原来的写法每次运行都会卡住,按 id 顺序加锁后都能完成,两个账户的余额仍为 1000(JDK 21 实测,见[附录 A1](#a1-java-线程死锁))。使用 ReentrantLock 时还可以用 tryLock 设置超时,拿不到锁就释放已持有的锁再重试,破坏的是占有和等待条件。
### 2. MySQL 死锁
@@ -213,7 +222,26 @@ UPDATE account SET balance = balance + 50 WHERE id = 1;
-- ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
```
-InnoDB 默认开启死锁检测,发现环后立即回滚其中一个事务(倾向于回滚修改行数少的事务),另一个事务继续执行。上例中事务 2 被回滚,事务 1 提交后余额为 900 和 1100(MySQL 8.0 实测,见[附录:实测](#附录实测))。执行 SHOW ENGINE INNODB STATUS,在 LATEST DETECTED DEADLOCK 一节中可以看到两个事务各自持有和等待的锁。被回滚的事务需要由应用重试。
+InnoDB 默认开启死锁检测,发现环后立即回滚其中一个事务(倾向于回滚修改行数少的事务),另一个事务继续执行。上例中事务 2 被回滚,事务 1 提交后余额为 900 和 1100(MySQL 8.0 实测,见[附录 A2](#a2-mysql-死锁))。执行 SHOW ENGINE INNODB STATUS,在 LATEST DETECTED DEADLOCK 一节中可以看到两个事务各自持有和等待的锁。被回滚的事务需要由应用重试。
+
+按相同顺序加锁并不能避免所有死锁。可重复读隔离级别下,常见的“先查有没有,没有再插入”也会死锁,例如两个事务分别为订单 1024 和 1025 写支付记录,表中已有 1000 和 2000 两行,两个事务先后执行 SELECT,再先后执行 INSERT:
+
+```sql
+-- 事务 1
+BEGIN;
+SELECT * FROM payment WHERE order_id = 1024 FOR UPDATE; -- 没有这一行,锁住间隙 (1000, 2000)
+INSERT INTO payment VALUES (1024, 100); -- 等待事务 2 的间隙锁
+
+-- 事务 2
+BEGIN;
+SELECT * FROM payment WHERE order_id = 1025 FOR UPDATE; -- 同一个间隙,间隙锁之间互不冲突
+INSERT INTO payment VALUES (1025, 50);
+-- ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
+```
+
+两个事务都拿到了同一个间隙上的间隙锁,插入前要申请的插入意向锁却和对方的间隙锁冲突,于是互相等待(MySQL 8.0 实测,见[附录 A3](#a3-先查后插的间隙锁死锁))。去掉 SELECT ... FOR UPDATE,直接 INSERT 并依靠主键或唯一索引判重即可,重复时会返回 1062 错误;改用提交读隔离级别时不加这种间隙锁,同样的语句不会死锁,但查询也就挡不住并发插入,判重同样要靠唯一索引。间隙锁的加锁范围见 [Next-Key Locks](数据库系统原理.md#六next-key-locks)。
+
+排查数据库死锁时,SHOW ENGINE INNODB STATUS 只保留最近一次死锁,打开 innodb_print_all_deadlocks 后每次死锁都会写入错误日志。锁等待还没有形成死锁时,可以查询 performance_schema.data_locks 和 data_lock_waits,看到每个事务持有和等待的锁,例如上例中事务 1 的插入意向锁处于 WAITING 状态。
减少数据库死锁的方法:
@@ -223,7 +251,9 @@ InnoDB 默认开启死锁检测,发现环后立即回滚其中一个事务(
## 附录:实测
-Java 线程死锁:JDK 21,两个线程分别执行 10 万次 transfer(a, b, 1) 和 transfer(b, a, 1),设为守护线程,各等待 3 秒后检查是否结束,各运行 3 次:
+### A1 Java 线程死锁
+
+JDK 21,两个线程分别执行 10 万次 transfer(a, b, 1) 和 transfer(b, a, 1),设为守护线程,各等待 3 秒后检查是否结束,各运行 3 次:
```text
原写法:stuck
@@ -234,7 +264,9 @@ Java 线程死锁:JDK 21,两个线程分别执行 10 万次 transfer(a, b, 1
按 id 顺序加锁:1000 1000
```
-MySQL 死锁:MySQL 8.0.44,account 表中 id 为 1、2 的两行余额都是 1000,用两个连接按正文的顺序执行,最后执行 SHOW ENGINE INNODB STATUS(节选):
+### A2 MySQL 死锁
+
+MySQL 8.0.44,account 表中 id 为 1、2 的两行余额都是 1000,用两个连接按正文的顺序执行,最后执行 SHOW ENGINE INNODB STATUS(节选):
```text
事务 1 的第二条 UPDATE: OK
@@ -252,7 +284,58 @@ UPDATE account SET balance = balance + 50 WHERE id = 1
*** WE ROLL BACK TRANSACTION (2)
```
+### A3 先查后插的间隙锁死锁
+
+MySQL 8.0.44,payment 表的主键为 order_id,已有 1000、2000 两行,用两个连接按正文的顺序执行,在事务 1 的 INSERT 等待时查询 performance_schema.data_locks 的事务 ID、锁类型、模式、状态和锁住的记录,最后执行 SHOW ENGINE INNODB STATUS(节选):
+
+```text
+== REPEATABLE READ
+事务 1 SELECT 1024 FOR UPDATE: ()
+事务 2 SELECT 1025 FOR UPDATE: ()
+事务 1 INSERT 1024 等待中时的锁:
+ (1150774, 'TABLE', 'IX', 'GRANTED', None)
+ (1150774, 'RECORD', 'X,GAP', 'GRANTED', '2000')
+ (1150774, 'RECORD', 'X,GAP,INSERT_INTENTION', 'WAITING', '2000')
+ (1150775, 'TABLE', 'IX', 'GRANTED', None)
+ (1150775, 'RECORD', 'X,GAP', 'GRANTED', '2000')
+事务 1 INSERT 1024: OK
+事务 2 INSERT 1025: OperationalError(1213, 'Deadlock found when trying to get lock; try restarting transaction')
+提交后: ((1000, 10), (1024, 100), (2000, 20))
+LATEST DETECTED DEADLOCK
+*** (1) TRANSACTION:
+INSERT INTO payment VALUES (1024, 100)
+*** (1) HOLDS THE LOCK(S):
+RECORD LOCKS space id 166 page no 4 n bits 72 index PRIMARY of table `demo`.`payment` trx id 1150774 lock_mode X locks gap before rec
+*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
+RECORD LOCKS space id 166 page no 4 n bits 72 index PRIMARY of table `demo`.`payment` trx id 1150774 lock_mode X locks gap before rec insert intention ...
+*** (2) TRANSACTION:
+INSERT INTO payment VALUES (1025, 50)
+*** WE ROLL BACK TRANSACTION (2)
+```
+
+间隙锁记在间隙右边的记录 2000 上(gap before rec)。同样的语句在提交读隔离级别下执行,两个 SELECT ... FOR UPDATE 只加了表级的意向锁,两条 INSERT 都成功:
+
+```text
+== READ COMMITTED
+事务 1 INSERT 1024 等待中时的锁:
+ (1150797, 'TABLE', 'IX', 'GRANTED', None)
+ (1150798, 'TABLE', 'IX', 'GRANTED', None)
+事务 1 INSERT 1024: OK
+事务 2 INSERT 1025: OK
+提交后: ((1000, 10), (1024, 100), (1025, 50), (2000, 20))
+```
+
+直接插入重复的主键:
+
+```text
+ERROR 1062 (23000) at line 1: Duplicate entry '1024' for key 'payment.PRIMARY'
+```
+
+执行 SET GLOBAL innodb_print_all_deadlocks = ON 后重跑可重复读的例子,错误日志中同样记录了两个事务持有的间隙锁和等待的插入意向锁(lock_mode X locks gap before rec insert intention waiting)。
+
## 参考资料
- Tanenbaum A S, Bos H. Modern Operating Systems [M]. Pearson, 2014.
- [Deadlocks in InnoDB | MySQL 8.0 Reference Manual](https://dev.mysql.com/doc/refman/8.0/en/innodb-deadlocks.html)
+- [InnoDB Locking | MySQL 8.0 Reference Manual](https://dev.mysql.com/doc/refman/8.0/en/innodb-locking.html)
+- [The data_locks Table | MySQL 8.0 Reference Manual](https://dev.mysql.com/doc/refman/8.0/en/performance-schema-data-locks-table.html)