数据库死锁的检测与解决
描述:
死锁是数据库并发控制中的一个经典问题,指两个或两个以上的事务在执行过程中,因争夺资源而造成的一种相互等待的现象,若无外力干预,这些事务都将无法向前推进。例如,事务A锁定了资源X,并试图获取资源Y;而事务B锁定了资源Y,并试图获取资源X。双方都在等待对方释放自己需要的锁,从而陷入无限等待。
知识要点:
- 死锁产生的必要条件:互斥条件、请求与保持条件、不剥夺条件、循环等待条件。
- 死锁的检测机制:数据库系统如何发现死锁的存在。
- 死锁的解决策略:一旦检测到死锁,系统如何采取措施打破僵局。
循序渐进讲解:
第一步:深入理解死锁产生的四个必要条件
死锁的发生必须同时满足以下四个条件,缺一不可。理解它们有助于我们从根源上预防死锁。
- 互斥条件(Mutual Exclusion):一个资源每次只能被一个事务使用。例如,一条数据记录在某个时刻只能被一个事务加上排他锁(X锁)。这是数据库锁的基本特性,无法改变。
- 请求与保持条件(Hold and Wait):一个事务在持有至少一个资源(锁)的同时,又提出了新的资源请求,而该新资源已被其他事务持有。例如,事务A已经锁定了行R1,现在它试图去锁定行R2。
- 不剥夺条件(No Preemption):事务已获得的资源,在未使用完之前,不能被其他事务强行剥夺,只能由持有该资源的事务自己释放。这是为了保证事务的原子性和一致性。
- 循环等待条件(Circular Wait):存在一个事务的循环链,链中的每个事务都在等待下一个事务所持有的资源。例如:T1等待T2占有的资源,T2等待T3占有的资源,...,Tn等待T1占有的资源。
结论:只要我们能打破这四个条件中的任意一个,死锁就可以被预防或避免。但在实际系统中,完全预防非常困难,因此主流数据库采用了“允许发生,但能检测并解除”的策略。
第二步:数据库如何检测死锁——等待图(Wait-for Graph)
数据库系统不会一直等待下去,它会主动检测死锁。最常用的方法是维护一个等待图(Wait-for Graph)。
-
图的构成:
- 节点(Nodes):代表当前正在运行的所有事务(T1, T2, T3...)。
- 有向边(Edges):如果事务T1正在等待被事务T2锁住的资源,就创建一条从T1指向T2的有向边(T1 -> T2)。这表示“T1在等待T2”。
-
检测算法:
数据库的死锁检测器会周期性地(例如每隔几秒)检查这个等待图。- 关键判断:如果在这个有向图中发现了任何环路(Cycle),就表明存在死锁。
- 举例说明:
- 事务A锁住了资源X,等待资源Y。
- 事务B锁住了资源Y,等待资源X。
- 在等待图中,会有一条边 A -> B(A等B释放Y),和一条边 B -> A(B等A释放X)。
- 这就形成了一个环路:A -> B -> A。检测器立即会识别出这个环路,从而判定发生了死锁。
第三步:解决死锁——选择牺牲者并回滚
一旦检测到死锁,系统必须采取措施来打破它。最常见的策略是选择一个“牺牲者”(Victim)事务,将其回滚。
-
选择牺牲者:系统不会随机选择一个事务回滚。它会根据预定义的策略来选择“代价最小”的事务作为牺牲者。考量因素通常包括:
- 事务已执行的时间:回滚一个刚开始的事务比回滚一个执行了很长时间的事务代价小。
- 事务已修改的数据量:回滚一个尚未修改任何数据或只修改了少量数据的事务,产生的回滚日志量小。
- 事务的优先级:某些系统可能为不同业务的事务设置优先级。
-
执行回滚:系统将选中的牺牲者事务完全回滚(ROLLBACK),释放该事务持有的所有锁。
-
通知应用程序:系统会向该事务对应的客户端应用程序返回一个明确的死锁错误(例如,在MySQL中常见的错误是
ERROR 1213 (40001): Deadlock found)。 -
打破僵局:牺牲者释放锁后,其他在循环等待链中等待这个锁的事务就可以获得所需资源,继续执行下去。死锁被解除。
第四步:如何从应用层面减少死锁的发生(最佳实践)
虽然数据库能自动处理死锁,但频繁的死锁会影响性能。开发者应在编写应用程序时遵循以下原则来尽量减少死锁:
- 保持事务简短:事务运行的时间越短,持有锁的时间就越短,与其他事务发生冲突的窗口就越小。避免在事务内进行复杂的计算或等待用户输入。
- 以固定的顺序访问资源:这是最重要且最有效的原则。如果所有事务都约定以相同的顺序(例如,先访问表A,再访问表B,最后访问表C)来申请锁,就可以从根本上避免循环等待的发生。
- 反面教材:事务1先更新A后更新B,事务2先更新B后更新A。这极易导致死锁。
- 正确做法:强制规定事务1和事务2都必须先更新A,再更新B。
- 使用较低的隔离级别:如果业务允许,可以考虑使用
READ COMMITTED而非REPEATABLE READ隔离级别。较低的隔离级别通常持有更少或范围更小的锁。 - 在单条SQL中完成操作:如果可能,尽量使用一条功能强大的SQL语句完成任务,而不是拆分成多条在事务中执行。例如,使用
UPDATE table SET value = value + 10 WHERE ...代替先SELECT再UPDATE。因为单条SQL是原子的,锁的持有时间极短。
总结:
死锁是并发系统的伴生现象。数据库通过等待图检测环路,并通过回滚牺牲者事务来自动解决死锁。作为开发者,我们的首要职责不是处理死锁错误(因为数据库已经做了),而是通过简化事务、固定访问顺序等最佳实践,从源头上降低死锁发生的概率。当应用程序收到死锁错误时,正确的做法是捕获该异常,然后自动重试整个事务。