什么是死锁?如何避免死锁?
Posted: Tue May 20, 2025 6:48 am
在数据库中,死锁(Deadlock)是一种特殊的并发控制问题,它发生在两个或多个事务在竞争资源时,每个事务都持有某种资源并等待着其他事务释放它所需要的资源,从而形成一个循环等待的局面,导致所有相关事务都无法继续执行。这就像交通十字路口上的僵局,每辆车都被其他车辆挡住,无法前行。
1. 死锁的构成条件(Coffman 条件)
死锁的发生需要同时满足以下四个条件:
互斥条件(Mutual Exclusion): 资源是不可共享的。在任何时刻,一个资源只能被一个事务独占。如果一个事务正在使用某个资源,其他事务就不能访问它。
持有并等待条件(Hold and Wait): 一个事务在持有至少一个资源的同时,又请求获取另一个由其他事务持有的资源,并且在等待该资源被释放。
不可抢占条件(No Preemption): 资源不能被强制性地从持有它的事务那里抢走。资源只能由持有它的事务在完成任务后自愿释放。
循环等待条件(Circular Wait): 存在一个事务链(T1 -> T2 -> ... -> Tn -> T1),其中 T1 正在等待 T2 持有的资源,T2 正在等待 T3 持有的资源,依此类推,直到 Tn 正在等待 T1 持有的资源。
这四个条件是死锁发生的必要条件,如果其中任何一个条件不满足,死锁就不会发生。
2. 死锁的示例
假设有两个事务 T1 和 T2,以及两个数据项 A 和 B:
事务 T1:
锁定数据项 A
尝试锁定数据项 B
事务 T2:
锁定数据项 B
尝试锁定数据项 A
死锁过程:
T1 成功锁定了 A。
T2 成功锁定了 B。
T1 尝试锁定 B,但 B 被 T2 锁定,所以 T1 等待 T2 释放 B。
T2 尝试锁定 A,但 A 被 T1 锁定,所以 T2 等待 T1 释放 A。
此时,T1 在等待 T2,T2 在等待 T1,形成了一个循环等待,死锁发生。两个事务都将无限期地等待下去,除非有外部干预。
3. 死锁的处理策略
数据库管理系统通常采用以下三种策略来处理死锁:
死锁预防(Deadlock Prevention):
通过破坏死锁的四个必要条件之一来确保死锁永远不会发生。这通常涉及对资源请求或持有方式施加严格的限制。
优点: 永远不会发生死锁。
缺点: 可能会降低并发性,导致资源利用率低下,或者编程复杂性增加。
常见方法:
一次性请求所有资源: 事务在开始执行之前就请求并 Mint 数据库 获取所有它可能需要的资源。如果不能一次性获得,则不开始执行。这破坏了“持有并等待”条件。
资源有序分配: 对所有资源进行排序,并强制所有事务都以相同的顺序请求资源。这破坏了“循环等待”条件。例如,总是先锁定表 A 的行,再锁定表 B 的行。
资源预占: 允许系统从一个事务中抢占资源并分配给另一个事务(这通常不适用于数据库锁)。
死锁避免(Deadlock Avoidance):
允许系统在运行时动态地检查资源分配状态,以确保资源分配永远不会导致不安全状态(即可能导致死锁的状态)。这种方法需要预先知道每个事务可能需要的最大资源量。
优点: 比预防更灵活,允许更高的并发性。
缺点: 实现复杂,需要事务声明其最大资源需求。
常见算法: 银行家算法(Banker's Algorithm)。但在实际数据库中很少完全实现,因为它对事务的预知能力要求太高。
死锁检测与恢复(Deadlock Detection and Recovery):
这是大多数现代数据库系统普遍采用的方法。DBMS 允许死锁发生,但会定期检查系统中是否存在死锁。一旦检测到死锁,它会选择一个“牺牲者(Victim)”事务并将其回滚,以打破死锁循环,释放资源,从而允许其他事务继续执行。
优点: 允许更高的并发性,因为没有严格的限制。
缺点: 死锁一旦发生,其中一个事务的工作将被撤销,可能需要应用程序进行重试。
常见方法:
等待图(Wait-for Graph): 数据库维护一个图,表示事务之间的等待关系。如果图中出现环,则表明发生了死锁。
选择牺牲者: 数据库会根据一定的策略选择一个事务作为牺牲者,例如:回滚成本最低的事务、最近才开始的事务、已修改数据量最少的事务等。
回滚和重试: 被选为牺牲者的事务将被回滚,其持有的锁被释放。应用程序通常需要捕获死锁错误并重试该事务。
4. 如何避免死锁(开发实践)
虽然数据库系统会自动检测和处理死锁,但在应用程序层面采取预防措施可以显著减少死锁的发生,从而提高系统的整体性能和用户体验:
按固定顺序访问资源: 始终以相同的顺序(例如,按表名、按主键 ID 顺序)锁定多个资源(行、表)。这是最有效且最常用的避免策略。
例如:如果事务通常需要 Order 表和 OrderItem 表的锁,始终先锁定 Order 表,再锁定 OrderItem 表。
缩短事务: 保持事务尽可能短。事务持有锁的时间越短,发生死锁的可能性就越小。
减少锁的范围和粒度: 尽量只锁定真正需要的行或页面,而不是整个表。使用行级锁而不是表级锁,可以提高并发性。
避免用户交互式事务: 不要让用户输入或确认操作成为事务的一部分,因为用户思考或输入时间过长会延长锁的持有时间。
考虑事务隔离级别: 了解并选择合适的事务隔离级别。较低的隔离级别(如 READ COMMITTED 或 READ COMMITTED SNAPSHOT)通常会减少锁争用,但可能引入其他一致性问题(如不可重复读或幻读),需要权衡。
使用 WITH (NOLOCK) (SQL Server) 或 FOR UPDATE NOWAIT (Oracle) / SKIP LOCKED (PostgreSQL): 在某些读多写少的场景下,如果允许脏读,可以使用无锁读来避免获取共享锁。NOWAIT 选项可以让事务在获取不到锁时立即报错,而不是等待,从而快速发现并处理潜在的死锁。
捕获并重试死锁错误: 在应用程序代码中,实现死锁错误的捕获机制。当检测到死锁时,回滚当前事务并以少量延迟重试该事务。
优化查询: 确保查询高效,减少长时间持有锁的可能性。检查索引、优化连接和 WHERE 子句。
死锁是并发数据库环境中不可避免的问题,但通过合理的设计和编码实践,可以大大降低其发生的频率,并通过数据库自身的检测与恢复机制来有效地处理。
1. 死锁的构成条件(Coffman 条件)
死锁的发生需要同时满足以下四个条件:
互斥条件(Mutual Exclusion): 资源是不可共享的。在任何时刻,一个资源只能被一个事务独占。如果一个事务正在使用某个资源,其他事务就不能访问它。
持有并等待条件(Hold and Wait): 一个事务在持有至少一个资源的同时,又请求获取另一个由其他事务持有的资源,并且在等待该资源被释放。
不可抢占条件(No Preemption): 资源不能被强制性地从持有它的事务那里抢走。资源只能由持有它的事务在完成任务后自愿释放。
循环等待条件(Circular Wait): 存在一个事务链(T1 -> T2 -> ... -> Tn -> T1),其中 T1 正在等待 T2 持有的资源,T2 正在等待 T3 持有的资源,依此类推,直到 Tn 正在等待 T1 持有的资源。
这四个条件是死锁发生的必要条件,如果其中任何一个条件不满足,死锁就不会发生。
2. 死锁的示例
假设有两个事务 T1 和 T2,以及两个数据项 A 和 B:
事务 T1:
锁定数据项 A
尝试锁定数据项 B
事务 T2:
锁定数据项 B
尝试锁定数据项 A
死锁过程:
T1 成功锁定了 A。
T2 成功锁定了 B。
T1 尝试锁定 B,但 B 被 T2 锁定,所以 T1 等待 T2 释放 B。
T2 尝试锁定 A,但 A 被 T1 锁定,所以 T2 等待 T1 释放 A。
此时,T1 在等待 T2,T2 在等待 T1,形成了一个循环等待,死锁发生。两个事务都将无限期地等待下去,除非有外部干预。
3. 死锁的处理策略
数据库管理系统通常采用以下三种策略来处理死锁:
死锁预防(Deadlock Prevention):
通过破坏死锁的四个必要条件之一来确保死锁永远不会发生。这通常涉及对资源请求或持有方式施加严格的限制。
优点: 永远不会发生死锁。
缺点: 可能会降低并发性,导致资源利用率低下,或者编程复杂性增加。
常见方法:
一次性请求所有资源: 事务在开始执行之前就请求并 Mint 数据库 获取所有它可能需要的资源。如果不能一次性获得,则不开始执行。这破坏了“持有并等待”条件。
资源有序分配: 对所有资源进行排序,并强制所有事务都以相同的顺序请求资源。这破坏了“循环等待”条件。例如,总是先锁定表 A 的行,再锁定表 B 的行。
资源预占: 允许系统从一个事务中抢占资源并分配给另一个事务(这通常不适用于数据库锁)。
死锁避免(Deadlock Avoidance):
允许系统在运行时动态地检查资源分配状态,以确保资源分配永远不会导致不安全状态(即可能导致死锁的状态)。这种方法需要预先知道每个事务可能需要的最大资源量。
优点: 比预防更灵活,允许更高的并发性。
缺点: 实现复杂,需要事务声明其最大资源需求。
常见算法: 银行家算法(Banker's Algorithm)。但在实际数据库中很少完全实现,因为它对事务的预知能力要求太高。
死锁检测与恢复(Deadlock Detection and Recovery):
这是大多数现代数据库系统普遍采用的方法。DBMS 允许死锁发生,但会定期检查系统中是否存在死锁。一旦检测到死锁,它会选择一个“牺牲者(Victim)”事务并将其回滚,以打破死锁循环,释放资源,从而允许其他事务继续执行。
优点: 允许更高的并发性,因为没有严格的限制。
缺点: 死锁一旦发生,其中一个事务的工作将被撤销,可能需要应用程序进行重试。
常见方法:
等待图(Wait-for Graph): 数据库维护一个图,表示事务之间的等待关系。如果图中出现环,则表明发生了死锁。
选择牺牲者: 数据库会根据一定的策略选择一个事务作为牺牲者,例如:回滚成本最低的事务、最近才开始的事务、已修改数据量最少的事务等。
回滚和重试: 被选为牺牲者的事务将被回滚,其持有的锁被释放。应用程序通常需要捕获死锁错误并重试该事务。
4. 如何避免死锁(开发实践)
虽然数据库系统会自动检测和处理死锁,但在应用程序层面采取预防措施可以显著减少死锁的发生,从而提高系统的整体性能和用户体验:
按固定顺序访问资源: 始终以相同的顺序(例如,按表名、按主键 ID 顺序)锁定多个资源(行、表)。这是最有效且最常用的避免策略。
例如:如果事务通常需要 Order 表和 OrderItem 表的锁,始终先锁定 Order 表,再锁定 OrderItem 表。
缩短事务: 保持事务尽可能短。事务持有锁的时间越短,发生死锁的可能性就越小。
减少锁的范围和粒度: 尽量只锁定真正需要的行或页面,而不是整个表。使用行级锁而不是表级锁,可以提高并发性。
避免用户交互式事务: 不要让用户输入或确认操作成为事务的一部分,因为用户思考或输入时间过长会延长锁的持有时间。
考虑事务隔离级别: 了解并选择合适的事务隔离级别。较低的隔离级别(如 READ COMMITTED 或 READ COMMITTED SNAPSHOT)通常会减少锁争用,但可能引入其他一致性问题(如不可重复读或幻读),需要权衡。
使用 WITH (NOLOCK) (SQL Server) 或 FOR UPDATE NOWAIT (Oracle) / SKIP LOCKED (PostgreSQL): 在某些读多写少的场景下,如果允许脏读,可以使用无锁读来避免获取共享锁。NOWAIT 选项可以让事务在获取不到锁时立即报错,而不是等待,从而快速发现并处理潜在的死锁。
捕获并重试死锁错误: 在应用程序代码中,实现死锁错误的捕获机制。当检测到死锁时,回滚当前事务并以少量延迟重试该事务。
优化查询: 确保查询高效,减少长时间持有锁的可能性。检查索引、优化连接和 WHERE 子句。
死锁是并发数据库环境中不可避免的问题,但通过合理的设计和编码实践,可以大大降低其发生的频率,并通过数据库自身的检测与恢复机制来有效地处理。