作为一名开发者或数据库管理员,你一定遇到过查询突然卡住,或者应用抛出 "Lock wait timeout exceeded" 错误的情况。这往往意味着我们的数据库遭遇了令人头疼的——死锁。在多用户并发环境下,死锁是高并发系统必须面对的棘手问题。今天,我们将深入剖析 DBMS 中死锁的来龙去脉,不仅搞懂它的原理,更要学会如何在生产环境中有效地预防和解决它。
什么是死锁?
简单来说,死锁就像是两个人在狭窄的路口相遇,A 坚持要 B 先退,B 也坚持要 A 先退,结果谁也动不了。在数据库世界中,当两个或多个事务相互持有对方所需的资源,并无限期地等待对方释放时,就发生了死锁。这会形成一个"依赖循环",导致所有参与的事务都无法继续执行。
可视化死锁场景
让我们通过一个经典的场景来直观理解。假设我们有两张核心表:INLINECODE3dae4e57(学生表)和 INLINECODEfbfe654e(成绩表)。此时,有两个事务正在并发执行:
- 事务 T1:首先锁定了 INLINECODEbb13efae 表中的记录(准备查询学生信息),紧接着它需要去更新 INLINECODE8ff734c8 表中的数据。
- 事务 T2:首先锁定了 INLINECODEacc87cae 表中的记录(准备计算成绩),随后它也需要去更新 INLINECODE997d4d24 表中的数据。
在这个时间交错点:
- T1 锁住 INLINECODE2c584bcb,眼巴巴盯着 INLINECODE6da89c0b(被 T2 占用)。
- T2 锁住 INLINECODE8acc11c7,急切盯着 INLINECODEecee6238(被 T1 占用)。
结果就是,T1 等待 T2,T2 等待 T1,两者陷入无限期的僵持状态。这就是典型的死锁。
死锁的四大必要条件
并不是所有的并发等待都会导致死锁。根据计算机科学的理论,只有当以下 四个条件同时满足 时,死锁才会发生。理解这些条件是我们解决问题的第一步:
- 互斥: 资源不能被共享。也就是说,同一时刻只能有一个事务持有特定的资源(如行锁或表锁)。如果大家都能同时读,自然就不会有这种问题。
- 持有并等待: 一个事务已经持有了至少一个资源,但同时又在等待获取其他事务所持有的额外资源。正是"拿着一个还要另一个"的行为导致了复杂局面。
- 不可抢占: 资源不能被强行从持有它的事务中夺走。只能由事务自己主动释放(提交或回滚)。这意味着系统不能简单地"切断"这种僵持。
- 循环等待: 存在一个闭环的等待链。T1 等 T2,T2 等 T3,… Tn 等 T1。这是死锁形成的直接表现形式。
为什么死锁是个大问题?
你可能会问,既然只是卡住了,能不能等一会儿自动恢复?遗憾的是,如果不加干预,死锁会导致严重的后果:
- 业务停滞: 涉及的事务会无限期地挂起,用户端会表现为请求超时或页面卡死。
- 吞吐量暴跌: 随着越来越多的请求被阻塞,数据库的活跃连接数会迅速耗尽,导致整个系统性能呈指数级下降。
- 资源浪费: 锁定的资源虽然未被使用,但也不能被其他事务利用,极大地降低了系统资源的利用率。
实战代码示例:复现死锁
光说不练假把式。让我们通过一段真实的 SQL 代码来模拟刚才描述的场景。你可以尝试在你的本地数据库环境中运行它。
场景设置
首先,我们需要创建两张简单的表并插入一些初始数据:
-- 创建学生表
CREATE TABLE Students (
id INT PRIMARY KEY,
name VARCHAR(100)
);
-- 创建成绩表
CREATE TABLE Grades (
student_id INT PRIMARY KEY,
score INT
);
-- 插入测试数据
INSERT INTO Students VALUES (1, ‘Alice‘);
INSERT INTO Grades VALUES (1, 90);
模拟死锁的 SQL 脚本
为了模拟死锁,我们需要打开两个不同的数据库连接窗口(会话 1 和 会话 2),并按照特定的顺序执行语句。
步骤 1:在会话 1 中开启事务并锁定第一张表
-- 会话 1
START TRANSACTION;
-- 我们对 Alice 的记录加一个排他锁(X锁)
-- FOR UPDATE 确保在事务结束前,其他事务无法修改或删除这一行
UPDATE Students SET name = ‘Alice_T1‘ WHERE id = 1;
-- 注意:此时不要提交事务,让锁保持持有状态!
步骤 2:在会话 2 中开启事务并锁定第二张表
-- 会话 2
START TRANSACTION;
-- 锁定成绩表中的记录
UPDATE Grades SET score = 95 WHERE student_id = 1;
-- 同样,不要提交事务。
步骤 3:回到会话 1,尝试访问会话 2 的资源
-- 会话 1 (继续)
-- 此时会话 1 已经锁定了 Students,现在它想修改 Grades
-- 但是 Grades 被会话 2 锁定了,所以会话 1 会在这里进入"阻塞状态",等待会话 2 释放资源。
UPDATE Grades SET score = 100 WHERE student_id = 1;
步骤 4:在会话 2 中,尝试访问会话 1 的资源(触发点)
-- 会话 2 (继续)
-- 此时,会话 2 想要修改 Students 表。
-- 但是 Students 被会话 1 锁定了,而会话 1 此时正在等待会话 2(步骤3)。
-- 循环等待形成!
UPDATE Students SET name = ‘Alice_T2‘ WHERE id = 1;
-- 在执行这条语句时,数据库(如 MySQL/InnoDB)会迅速检测到死锁。
-- 它会立即抛出错误:"Error 1213 - Deadlock found when trying to get lock; try restarting transaction"
-- 并且会主动回滚其中一个事务(通常是会话 2),以打破僵局。
通过这个实验,你可以清晰地看到死锁是如何因操作顺序的不一致而产生的。
如何处理死锁?
面对死锁,我们并不是无计可施。通常我们有三种主要的处理策略:预防、避免和检测。在大型数据库系统中,通常是结合使用这些方法。
1. 死锁预防:扼杀摇篮中的危机
预防的核心思想是"破坏"四大必要条件中的任何一个,最常见的就是破坏"循环等待"条件。如果我们能确保资源请求的顺序一致性,就能从根源上杜绝死锁。
最佳实践:统一访问顺序
我们可以约定:无论业务逻辑如何,所有事务都必须先锁定 INLINECODEab7d058f 表,再锁定 INLINECODEdba7623e 表。
-- 改进后的代码示例 (针对事务 T1)
START TRANSACTION;
-- 步骤 A: 总是先访问 Students
UPDATE Students SET name = ‘Updated‘ WHERE id = 1;
-- 步骤 B: 然后访问 Grades
UPDATE Grades SET score = 100 WHERE student_id = 1;
COMMIT;
如果所有事务(T1 和 T2)都严格遵守这个顺序,就不可能出现"T1 等 T2,T2 等 T1"的情况。顶多会出现排队(串行化),而不会死锁。
其他预防建议:
- 缩短事务持有锁的时间: 不要在事务中进行网络调用(如调用第三方 API),这会让锁持有时间过长。
- 使用较低的隔离级别: 在允许的情况下,使用
READ COMMITTED可以减少间隙锁的范围,从而降低死锁概率。
2. 死锁检测:等待图算法
对于非常复杂的系统,强制规定访问顺序可能过于困难。现代数据库管理系统(DBMS)通常内置了死锁检测机制。
工作原理:等待图
数据库维护着一个有向图,用来描述事务之间的依赖关系:
- 节点: 代表当前活跃的事务。
- 边: 代表等待关系(例如:T1 -> T2 表示 T1 正在等待 T2 持有的资源)。
数据库的后台线程会周期性地扫描这个图。如果算法在图中发现了环,就说明发生了死锁。
- 解决: 一旦检测到环,DBMS 会选择环中的一个"牺牲者"(通常是代价最小的事务,比如修改行数最少的),将其回滚,从而打破循环,让其他事务继续执行。
3. 深入死锁预防机制
在数据库理论中,针对大型数据库环境,我们有两种经典的预防方案,它们基于时间戳来决定事务的"辈分",以此强制打破循环。这听起来很理论,但在理解高并发锁机制时非常有帮助。
#### 方案 A:等待-死亡 —— 非抢占式
这个方案的核心原则是:老事务优先,新事务靠边。
- 规则: 如果一个较旧的事务(时间戳较小)请求了一个被较新事务持有的资源,旧事务就等待。
- 反之: 如果一个较新的事务请求了一个被较旧事务持有的资源,这个新事务就会被终止(死亡),并在稍后带着原始的时间戳重启。
逻辑示例:
假设 T1 (时间戳 10) 和 T2 (时间戳 20)。
- 如果 T2 持有资源 R,而 T1 想要 R -> T1 等待(因为 T1 更老)。
- 如果 T1 持有资源 R,而 T2 想要 R -> T2 被回滚(因为 T2 更新)。
优点: 不会发生"饿死"现象,因为老事务最终总是能完成。缺点: 新事务可能会频繁重启,导致系统资源浪费。
#### 方案 B:伤害-等待 —— 抢占式
这个方案比较"霸道",原则是:老事务拥有特权,可以抢占资源。
- 规则: 如果一个较旧的事务请求了较新事务所持有的资源,新事务会被回滚,资源交给旧事务。
- 反之: 如果较新的事务请求了较旧事务所持有的资源,新事务就等待。
逻辑示例:
同样假设 T1 (10) 和 T2 (20)。
- 如果 T2 持有资源 R,而 T1 想要 R -> T2 被"伤害"(回滚),把 R 交给 T1。
- 如果 T1 持有资源 R,而 T2 想要 R -> T2 等待。
优点: 旧事务执行速度非常快。缺点: 新事务可能会反复被老事务"踢开",导致长时间无法完成(回滚次数较多)。
对比总结:
等待-死亡
:—
非抢占(只等待)
等待新事务
较多(新事务总是死)
死锁带来的影响与应对策略
在应用层面,死锁通常会表现为以下几种情况,了解它们有助于我们排查问题:
- 事务延迟: 系统响应变慢,哪怕只是简单的查询也可能卡住。
- 事务丢失: 用户看到 "Error 1213 事务被回滚" 的提示,导致数据提交失败。
- 并发性降低: 大量的时间浪费在无意义的等待上,而不是处理业务。
实用排查技巧
当你在生产环境遇到死锁时,可以按照以下步骤进行排查:
- 开启死锁日志: 在 MySQL 中,可以通过
SHOW ENGINE INNODB STATUS;命令查看最近的死锁信息。它会详细展示哪个事务被回滚,以及锁的冲突细节。 - 审视业务逻辑: 仔细检查代码中涉及多表操作的部分。是否存在不一致的加锁顺序?
- 添加索引: 这是一个隐蔽的技巧。如果查询走了全表扫描,数据库可能会锁定大量的行(甚至间隙锁)。精确的索引可以缩小锁的范围,减少不同事务碰撞的概率。
总结与下一步
死锁是并发编程中不可避免的话题,但并非不可战胜。通过理解"循环等待"的本质,并在开发阶段养成良好的习惯——统一加锁顺序、保持事务简短、使用合理的索引——我们就能将死锁的发生率降到最低。
如果你在系统日志中发现了死锁,不要慌张。拿起 SHOW ENGINE INNODB STATUS 这个武器,分析是哪个环节出现了顺序冲突,然后重构你的代码逻辑。记住,在大多数简单的应用场景下,"顺序一致"是解决死锁最简单、最有效的银弹。
希望这篇文章能帮助你彻底搞定 DBMS 中的死锁问题!