好的,我们来深入详细地探讨数据库中的死锁问题。死锁是并发控制中一个经典且棘手的问题,理解它的成因、处理和预防至关重要。
一、死锁的定义
死锁是指两个或两个以上的事务在执行过程中,因争夺资源(主要是锁) 而陷入的一种相互等待的状态,若无外力干预,这些事务都将无法继续执行下去。
一个经典的死锁类比是 “十字路口堵车”:
- 四辆车同时到达十字路口。
- 每辆车都需要前方和侧方的路畅通才能通过。
- 但每辆车都被其他车挡住了去路。
- 结果就是所有车都无法移动,形成僵局。
二、死锁产生的必要条件
死锁的发生必须同时满足以下四个条件,缺一不可:
-
互斥条件
- 描述:资源(如数据项上的锁)一次只能被一个事务独占使用。
- 例子:一个数据项A上的X锁,在任意时刻最多只能被一个事务持有。
-
请求与保持条件
- 描述:一个事务在持有至少一个资源的同时,又提出了新的资源请求,而该新资源已被其他事务占有,此时该事务进入等待状态,但并不释放它已持有的资源。
- 例子:T1持有A的锁,同时请求B的锁;T2持有B的锁,同时请求A的锁。
-
不剥夺条件
- 描述:事务已获得的资源,在未使用完毕之前,不能被其他事务强行剥夺,只能由持有该资源的事务主动释放。
- 例子:DBMS不能强行从T1手中把A的锁抢过来给T2用。
-
循环等待条件
- 描述:存在一个事务-资源的循环等待链。即T1等待T2占用的资源,T2等待T3占用的资源,…,Tn等待T1占用的资源,形成一个闭合的环路。
- 例子:T1等待T2,T2等待T1。
这四个条件的关系是:互斥是资源本身的特性,请求与保持和不剥夺是事务的行为方式,而循环等待是死锁的最终表现形式。只要破坏其中任意一个条件,死锁就不会发生。
三、一个详细的死锁场景示例
假设有两个事务T1和T2,操作两个银行账户A和B。
| 时间 | 事务 T1 | 事务 T2 | 说明 |
|---|---|---|---|
| t1 | XLock(A) | T1成功获得A的X锁 | |
| t2 | XLock(B) | T2成功获得B的X锁 | |
| t3 | XLock(B) | T1请求B的X锁,但B已被T2锁定,T1进入等待状态 | |
| t4 | XLock(A) | T2请求A的X锁,但A已被T1锁定,T2进入等待状态 | |
| t5 | 等待T2释放B | 等待T1释放A | 死锁发生! |
此时:
- T1持有A,等待B。
- T2持有B,等待A。
- 两者互相等待,形成一个循环等待链,谁都无法继续执行,也无法释放自己持有的锁。
这个过程的交互序列可以清晰地用下图表示:
四、死锁的处理策略
数据库系统主要采用以下两种策略来处理死锁:
策略一:死锁预防
在事务执行前,通过制定严格的协议来破坏死锁产生的必要条件,从而确保系统永远不会进入死锁状态。
-
破坏"请求与保持"条件
- 方法:一次性封锁协议。
- 规则:要求每个事务在开始执行前,必须一次性申请其所需的所有锁。如果全部都能获得,则执行;只要有一个不能获得,就一个也不分配,让事务等待。
- 优点:简单有效,不会出现死锁。
- 缺点:
- 资源浪费严重:事务可能在很久之后才用到的锁,从一开始就被它占着。
- 并发度急剧下降:因为锁的持有时间变得非常长。
- 难以预知所有需要的锁:对于复杂事务,提前知道所有要访问的数据项非常困难。
-
破坏"不剥夺"条件
- 方法:如果一个事务申请锁失败,并已持有其他锁,则强制回滚该事务,释放其所有锁,让等待它资源的事务得以执行。
- 缺点:实现复杂,回滚代价高,可能造成事务饥饿(一个事务反复被回滚)。
-
破坏"循环等待"条件
- 方法:顺序封锁协议。
- 规则:对所有数据对象规定一个全局的加锁顺序(例如,按主键大小、按表名排序)。每个事务都必须严格按照这个顺序来申请锁。
- 原理:假设只有A和B两个对象,规定顺序必须是先A后B。那么:
- T1先锁A,再请求B,是允许的。
- T2如果想先锁B,是不被允许的,因为它必须先申请A的锁。如果A已被T1锁定,T2就必须等待,而不会出现T2持有B再去请求A的情况。
- 优点:有效地预防死锁,是实践中常用的预防性方法。
- 缺点:维护一个全局的封锁顺序可能很麻烦,尤其是在动态生成SQL的场景下。
策略二:死锁检测与恢复(更常用)
允许死锁发生,但系统会定期检测是否存在死锁,一旦发现,则立即解除它。
-
死锁检测
- 等待图法:
- 系统维护一个等待图。这是一个有向图
G = (V, E)。 - 顶点V:表示当前正在运行的事务。
- 边E:表示事务之间的等待关系。如果事务T1在等待T2释放某个锁,就画一条从T1指向T2的有向边
T1 -> T2。 - 检测算法:系统周期性地(例如每隔几秒)检查等待图。如果图中存在任何环路,则说明发生了死锁。
- 系统维护一个等待图。这是一个有向图
- 超时法:
- 为一个事务设置一个等待时间的阈值。
- 如果一个事务的等待时间超过了这个阈值,就认为它可能发生了死锁,将其回滚。
- 优点:实现简单。
- 缺点:可能产生误判(可能只是等待一个运行很慢的事务),且设置超时时间长短困难。
- 等待图法:
-
死锁恢复
一旦检测到死锁,系统必须选择一个"牺牲品"事务来回滚,以打破死锁。-
选择牺牲品的依据(目标是使回滚的代价最小):
- 最近启动的事务(它做的工作最少)。
- 已执行时间最短的事务。
- 已持有锁数量最少的事务。
- 回滚代价最小的事务(例如,修改的数据量最少)。
- 优先级低的事务(在业务系统中)。
-
恢复程度:
- 完全回滚:终止该事务,并重启它。这是最常用的方法。
- 部分回滚:只回滚到可以解开死锁的某个"安全点",然后让事务从该点重新开始。这需要系统维护更多的中间状态信息,实现复杂。
-
五、如何在应用层面避免死锁
除了数据库系统自身的机制,开发人员也可以通过良好的编程习惯来极大减少死锁的发生:
- 保持事务简短且紧凑:事务运行时间越短,持有锁的时间就越短,与其他事务冲突的窗口期就越小。
- 以固定的顺序访问数据:这是最重要且最有效的应用级预防措施。如果所有事务都约定先访问表A,再访问表B,那么就不会出现循环等待。
- 使用较低的隔离级别:如果业务允许,使用"读已提交"隔离级别比"可重复读"产生死锁的概率要低,因为读锁的持有时间更短。
- 在单条SQL中完成操作:例如,
UPDATE table SET col = col + 1 WHERE ...比先SELECT再UPDATE更好,因为前者在数据库内部被优化,锁的粒度和管理更高效。 - 使用乐观锁:在一些冲突较少的场景下,可以使用版本号或时间戳的乐观锁机制,避免使用悲观锁,从而从根源上避免死锁。
总结
死锁是并发系统中一个不可避免的副产品。数据库管理系统通过死锁预防和死锁检测与恢复两种主要策略来应对它。现代数据库(如MySQL InnoDB)通常采用等待图法进行周期性的死锁检测,并自动回滚代价最小的事务来解除死锁。
对于开发者和DBA而言,理解死锁的原理至关重要。这不仅有助于在死锁发生时分析日志、定位问题,更重要的是,能够通过良好的数据库设计和事务编码规范(如固定顺序访问),从源头上最大限度地减少死锁的出现,保障系统的稳定和高性能。
674

被折叠的 条评论
为什么被折叠?



