InnoDB学习之死锁

死锁是指两个或更多事务因互相等待资源而形成的循环等待状态。本文通过实例详细解释了InnoDB中的死锁现象,包括死锁的必要条件、检测机制如wait-for graph,以及如何禁用死锁检测和避免死锁。建议通过优化事务顺序、减少锁定时间、选择合适的隔离级别等方式降低死锁发生概率。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

死锁

概念

​ 死锁是指由于每个事务都持有对方需要的锁而无法进行其他事务的情况,形成一个循环的依赖关系。因为这两个事务都在等待资源变得可用,所以两个都不会释放它持有的锁。

会话A持有一行数据的锁,会话B持有另一行数据的锁。
A申请获取B持有的那个锁,但是被B占用着,所以A等待。
B申请获取A持有的那个锁,但是被A占用着,所以B等待。

InnoDB死锁示例

  1. 以下示例说明了锁定请求将导致死锁时如何发生错误。该示例涉及两个客户端A和B。

    首先,客户端A创建一个包含一行的表,然后开始事务。在事务中,A通过LOCK IN SHARE MODE来获得对行的读锁:

    mysql> CREATE TABLE t (i INT) ENGINE = InnoDB;
    Query OK, 0 rows affected (1.07 sec)
    
    mysql> INSERT INTO t (i) VALUES(1);
    Query OK, 1 row affected (0.09 sec)
    
    mysql> START TRANSACTION;
    Query OK, 0 rows affected (0.00 sec)
    
    mysql> SELECT * FROM t WHERE i = 1 LOCK IN SHARE MODE;
    +------+
    | i    |
    +------+
    | 1    |
    +------+
    

    接下来,客户端B开始事务并尝试从表中删除该行:

    mysql> START TRANSACTION;
    Query OK, 0 rows affected (0.00 sec)
    
    mysql> DELETE FROM t WHERE i = 1;
    

    删除操作需要一个X锁。无法授予该S锁,因为它与客户端A持有的锁不兼容 ,因此该请求进入针对行和客户端B块的锁请求队列中。

    最后,客户端A还尝试从表中删除该行:

    mysql> DELETE FROM t WHERE i = 1;
    ERROR 1213 (40001): Deadlock found when trying to get lock;
    try restarting transaction
    

    此处发生死锁,因为客户端A需要 X锁才能删除该行。但是,不能授予该锁定请求,因为客户端B已经有一个X锁定请求,并且正在等待客户端A释放其S锁定。由于B事先要求锁,因此SA持有的锁也不能 升级 XX锁。结果, InnoDB为其中一个客户端生成错误并释放其锁。客户端返回此错误。

    届时,可以授予对另一个客户端的锁定请求,并从表中删除该行。

  2. 在RR隔离级别下,数据库有两条数据id=1和id=10。 且id为普通索引
    两个事务,事务 1: select * from table where id=3 for update。 insert (id=3),他会在(1,3) (3,10)之间加间隙锁,在(3)这个位置申请插入意向锁。
    事务2: select * from table where id=5 for update, insert(id=5)。 它会在(1,5) (5,10)之间加间隙锁,由于间隙锁互相兼容,故该锁可以获取,另外在(5)这个地方申请插入意向锁。
    当事务1要获取插入意向锁时,发现(3)被事务2的间隙锁锁住了,故等待事务2释放锁;
    事务2要获取插入意向锁时,发现(5)被事务1的间隙锁锁住了,故等待事务1释放锁;——死锁形成

  3. 银行转账的例子

    create table money(id int primary key,price int);
    insert into money values(1,1000);
    insert into money values(2,1000);
    

    事务A: 更新表,id=1的记录

    mysql> start transaction;
    Query OK, 0 rows affected (0.01 sec)
     
    mysql> update money set price=2000 where id=1;
    Query OK, 1 row affected (0.03 sec)
    

    事务B: 更新表,id=2的记录

    mysql> start transaction;
    Query OK, 0 rows affected (0.01 sec)
     
    mysql> update money set price=2000 where id=2;
    Query OK, 1 row affected (0.00 sec)
    

    事务A: 更新表,id=2的记录,此时会卡住(因为这条记录被加上了X锁)

    mysql> update money set price=3000 where id=2;
    

    事务B: 更新表,id=1的记录,此时会报错事务进行回滚,并且事务1会执行更新id=2的记录

    mysql> update money set price=3000 where id=1;
    ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
    

    上述,事务抛出1213这个出错提示,即发生了死锁,上例中当两个事务都执行了第一条UPDATE语句,更新了一行数据,同时也锁定了该行数据,接着每个事务都尝试去执行第二条UPDATE语句,却发现该行已经被对方锁定,然后两个事务都等待对方释放锁,同时又持有对方需要的锁,则陷入死循环。除非有外部因素接入才可能解除死锁。为了解决这种问题,数据库系统实现了各种死锁检测和死锁超时机制
    在这里插入图片描述

死锁的必要条件

  1. 多个并发事务(2个或者以上);
  2. 每个事务都持有锁(或者是已经在等待锁);
  3. 每个事务都需要再继续持有锁(为了完成事务逻辑,还必须更新更多的行);
  4. 事务之间产生加锁的循环等待,形成死锁。

总结:当两个或多个事务相互持有对方需要的锁时,就会产生死锁。

死锁的检测

当死锁检测启用时(默认),InnoDB会自动检测事务死锁并回滚一个或多个事务来打破死锁。InnoDB尝试选择小事务进行回滚,其中事务的大小由插入、更新或删除的行数决定。

InnoDB在innodb_table_locks = 1(默认值)和autocommit = 0时知道表锁,它上面的MySQL层知道行锁。否则,InnoDB无法检测到由MySQL锁表语句设置的表锁,或由InnoDB以外的存储引擎设置的锁。通过设置innodb_lock_wait_timeout系统变量的值来解决这些情况。

如果InnoDB监视器输出的最新检测到的死锁部分包含一条消息,“在锁表等待图中搜索太深或太长,我们将在事务之后回滚”,这表明等待列表中的事务数量已经达到了200的上限。超过200个事务的等待列表被视为死锁,试图检查等待列表的事务被回滚。如果锁定线程必须查看等待列表中事务拥有的超过1,000,000个锁,也可能会发生同样的错误。

死锁检测机制 - wait-for graph

核心就是数据库会把事务单元锁维持的锁和它所等待的锁都记录下来,Innodb提供了wait-for graph算法来主动进行死锁检测,每当加锁请求无法立即满足需要进入等待时,wait-for graph算法都会被触发。当数据库检测到两个事务不同方向地给同一个资源加锁(产生循序),它就认为发生了死锁,触发wait-for graph算法。比如,事务1给A加锁,事务2给B加锁,同时事务1给B加锁(等待),事务2给A加锁就发生了死锁。那么死锁解决办法就是终止一边事务的执行即可,这种效率一般来说是最高的,也是主流数据库采用的办法。

在这里插入图片描述

Innodb目前处理死锁的方法就是将持有最少行级排他锁的事务进行回滚。这也是相对比较简单的死锁回滚方式。死锁发生以后,只有部分或者完全回滚其中一个事务,才能打破死锁。对于事务型的系统,这是无法避免的,所以应用程序在设计必须考虑如何处理死锁。大多数情况下只需要重新执行因死锁回滚的事务即可。

wait-for graph原理

我们怎么知道图中四辆车是死锁的?

在这里插入图片描述

他们相互等待对方的资源,而且形成环路!我们将每辆车看为一个节点,当节点1需要等待节点2的资源时,就生成一条有向边指向节点2,最后形成一个有向图。我们只要检测这个有向图是否出现环路即可,出现环路就是死锁!这就是wait-for graph算法。
在这里插入图片描述

Innodb将各个事务看为一个个节点,资源就是各个事务占用的锁,当事务1需要等待事务2的锁时,就生成一条有向边从1指向2,最后行成一个有向图。

禁用死锁检测

在高并发性系统中,当多个线程等待同一锁时,死锁检测可能导致速度下降。有时,禁用死锁检测并依赖于innodb_lock_wait_timeout设置在发生死锁时执行事务回滚可能更有效。可以使用innodb_deadlock_detect配置选项禁用死锁检测。

避免死锁

死锁是事务性数据库中的一个典型问题,但它们并不危险,除非它们非常频繁以至于您根本无法运行某些事务。通常,您必须编写应用程序,以便在事务因死锁而回滚时,它们始终准备重新发出事务。

InnoDB使用自动行级锁定。即使在只插入或删除单行的事务中,也会出现死锁。这是因为这些操作并不是真正的“原子”操作;它们自动设置插入或删除行的索引记录(可能有几个)的锁。

你可以使用以下技巧来处理死锁,并降低发生死锁的可能性:

  • 在任何时候,发出显示引擎INNODB状态命令来确定最近死锁的原因。这可以帮助您优化应用程序以避免死锁。

  • 如果经常出现死锁警告,那么可以通过启用innodb_print_all_deadlocks配置选项来收集更多的调试信息。关于每个死锁的信息,而不仅仅是最近的死锁,都记录在MySQL错误日志中。完成调试后禁用此选项。

  • 如果事务由于死锁而失败,请随时准备重新发出事务。死锁并不危险。再试一次。

  • 保持事务较小且持续时间较短,以减少冲突的发生。

  • 在进行一组相关更改之后立即提交事务,以减少冲突的发生。特别是,不要让一个交互式mysql会话长时间打开一个未提交的事务。

  • 如果你使用锁读(SELECT … FOR UPDATE 或 SELECT … LOCK IN SHARE MODE),尝试使用较低的隔离级别,如READ COMMITTED。

  • 当修改一个事务中的多个表或同一表中的不同行集时,每次都要按照一致的顺序执行这些操作。这样,事务就形成了定义良好的队列,不会死锁。例如,将数据库操作组织成应用程序中的函数,或调用存储过程,而不是在不同的地方编写多个类似的INSERT、UPDATE和DELETE语句序列。

  • 向表中添加精心选择的索引。这样,查询需要扫描的索引记录就更少,因此设置的锁就更少。使用EXPLAIN SELECT来确定MySQL服务器认为哪些索引最适合您的查询。

  • 使用更少的锁定。如果允许SELECT从旧快照返回数据,则不要向其添加用于更新或锁定共享模式的子句。这里使用READ COMMITTED隔离级别很好,因为同一事务中的每次一致读取都是从它自己的新快照中读取的。

  • 如果没有其他帮助,使用表级锁序列化事务。对于事务性表,比如InnoDB表,使用锁表的正确方法是在事务开始时设置autocommit = 0(不是启动事务),然后是锁表,并且在显式提交事务之前不调用解锁表。例如,如果您需要写入表t1和读取表t2,您可以这样做:

    SET autocommit=0;
    LOCK TABLES t1 WRITE, t2 READ, ...;
    ... do something with tables t1 and t2 here ...
    COMMIT;
    UNLOCK TABLES;
    

    表级锁可以防止对表的并发更新,从而避免死锁,但对于繁忙的系统,响应能力会降低。

  • 序列化事务的另一种方法是创建一个只包含一行的辅助“信号量”表。让每个事务在访问其他表之前更新该行。这样,所有的事务都以连续的方式发生。注意,InnoDB的即时死锁检测算法也适用于这种情况,因为序列化锁是行级锁。对于MySQL表级锁,必须使用超时方法来解决死锁。

  • 优化表结构,优化schema,可在一定程度上避免死锁

  • 给记录集显式加锁时,最好一次性请求足够级别的锁。比如要修改数据的话,最好直接申请排他锁,而不是先申请共享锁,修改时再请求排他锁,这样容易产生死锁;

  • 尽量用相等条件访问数据,这样可以避免间隙锁对并发插入的影响; 不要申请超过实际需要的锁级别;除非必须,查询时不要显示加锁;

### 如何在 InnoDB 中避免死锁的最佳实践 为了有效减少和避免 InnoDB 数据库中的死锁现象,可以采取一系列最佳实践措施。以下是详细的说明: #### 死锁的原因分析 死锁通常发生在两个或多个事务相互等待对方释放资源的情况下。这种循环依赖使得任何一方都无法继续执行下去[^1]。 #### 避免死锁的最佳实践 1. **保持事务尽可能短** 将事务的操作范围缩小到最小程度有助于降低发生冲突的可能性。长时间运行的事务会占用更多的资源并增加与其他事务交互的机会。 2. **按照固定的顺序访问表和行** 如果所有的应用都遵循相同的锁定路径,则可以显著减少交叉请求引发的死锁风险。例如,在更新操作时总是先处理 `table_a` 后再处理 `table_b`。 3. **使用较低级别的隔离级别** 默认情况下 MySQL 使用 REPEATABLE READ 的隔离等级;然而通过调整至 READ COMMITTED 或更低可能会帮助缓解某些类型的并发问题从而间接减少了潜在的死锁情况出现几率。 4. **重试失败的事务** 应用程序应该设计成能够自动检测并重新尝试因死锁而回滚的交易过程。这一步骤对于构建健壮的应用至关重要因为即使采用了上述预防手段也无法完全杜绝所有可能发生的异常状况。 5. **优化查询语句** 编写高效的SQL查询不仅可以提高性能还可以减少不必要的索引扫描次数进而降低了加锁频率以及持续时间这两方面都是诱发死锁的重要因素之一。 6. **合理配置 innodb_lock_wait_timeout 参数** 调整此参数可以让系统更快地识别出无法解决的死锁情形,并及时终止其中一个受影响较大的进程以解除僵局状态。 7. **监控与诊断工具利用** 定期审查服务器日志文件寻找有关死锁的信息记录可以帮助我们发现隐藏模式或者特定场景下的高发区域以便进一步改进代码逻辑结构来规避此类事件再次发生。 ```sql -- 设置全局超时时间为5秒 (可根据实际需求修改) SET GLOBAL innodb_lock_wait_timeout = 5; ``` 8. **考虑分区表技术** 对于非常庞大的数据集来说适当引入物理分片机制也许能带来意想不到的效果——即把原本集中存储的数据分散开来分别管理这样做的好处在于它既减轻了个别热点区域的压力同时也让不同部分之间的互相干扰变得微乎其微因此理论上讲也就能更好地控制住整个系统的稳定性水平不至于轻易陷入混乱局面当中去了。 9. **采用乐观锁策略** 当读多写少的时候可以选择基于版本号或者其他元数据字段实现所谓的“乐观并发控制”,只有当最终提交阶段才发现存在竞争条件才会触发额外的动作而不是一开始就强行独占目标对象的所有权这种方式往往更加灵活高效而且天然具备良好的扩展特性适合现代分布式架构环境下的大规模协作开发项目使用。 ---
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值