当多个事务形成不兼容锁请求的等待环时,就发生 InnoDB 死锁。开启死锁检测后,引擎选择一个受害者,回滚它的整个事务,使其他参与者能够继续。应用收到错误 1213(SQLSTATE 40001)。若只重试报错的那条语句,就会漏掉同一事务中更早执行、但已经一起被回滚的工作。

坏掉的样子

不能给所有锁错误套用同一种回滚规则。默认关闭超时整笔回滚时,锁等待超时只撤销正在等待的那条语句,事务内此前执行的语句仍然待决。开启对应服务端选项后,超时才会改为回滚整个事务。死锁本来就会回滚整个受害事务,不能把默认超时的语句级行为误套在死锁上。

受害者选择也不保证某个固定会话永远输。应用必须检查真实错误,再以有界次数重试完整逻辑事务。不可逆的外部副作用如果没有单独去重契约,不应跟着数据库事务自动重放。提交回复途中连接断开,也不等于已经得到死锁回滚的确定结论;错误类型与已知事务结果需要分开判断。

相反取行单元形成环,下列甲幸存 110/110 或乙幸存 200/200,完整重试后均为 210;独立超时对照保留乙待决的 105,观察者仍见 100。
死锁受害者可能是任一参与者。超时对照要求关闭超时整笔回滚,并在全部会话使用可重复读;所示结果须人工检查。

快速验证

历史三会话测试保持原样,本轮没有重跑。保留的审阅记录报告当时无法使用 MySQL,且未在保留的验证文件中找到原始 MySQL 日志;下述版本与受害者选择仍归属于原文报告。

在可丢弃的 MySQL 8.4 实例上打开甲、乙和观察者三个全新管理客户端,先确认三个会话均处于自动提交模式,且隔离级别为 REPEATABLE READ;修改过的服务器默认值可能改变可见性,或让后面的读取也进入锁等待。关闭客户端自动重新连接,保持会话身份稳定。既有运行记录中的版本是 MySQL 8.4.11。观察者先创建尚未使用的实验模式,并核对死锁检测开启、超时采用默认语句级回滚:

CREATE DATABASE xbcatm_deadlock_lab;
USE xbcatm_deadlock_lab;
CREATE TABLE accounts (id INT PRIMARY KEY, balance INT NOT NULL) ENGINE=InnoDB;
INSERT INTO accounts VALUES (1,100),(2,100);
SELECT @@innodb_deadlock_detect,@@innodb_rollback_on_timeout;

两个设置应分别为一和零。甲开始一项给两个账户各增加十的操作,先锁住第一个账户:

USE xbcatm_deadlock_lab;
SET SESSION innodb_lock_wait_timeout=30;
SET @increment=10;
START TRANSACTION;
UPDATE accounts SET balance=balance+@increment WHERE id=1;

乙开始一项各增加一百的操作,先锁住第二个账户:

USE xbcatm_deadlock_lab;
SET SESSION innodb_lock_wait_timeout=30;
SET @increment=100;
START TRANSACTION;
UPDATE accounts SET balance=balance+@increment WHERE id=2;

甲再请求乙持有的那一行,此时语句会等待:

UPDATE accounts SET balance=balance+@increment WHERE id=2;

在甲的三十秒锁等待超时之前,乙执行反向请求:

UPDATE accounts SET balance=balance+@increment WHERE id=1;

其中一个会话收到死锁错误,把实际报错者认作受害者;另一个更新则会完成。下面的提交只在幸存会话中执行:

COMMIT;

观察者检查已经提交的状态:

SELECT id,balance FROM accounts ORDER BY id;

如果甲幸存,两行均为一百一十;如果乙幸存,两行均为二百。受害者更早的更新也已经消失。既有运行选择乙为受害者,最后看到两行一百一十。在受害者原来的会话中,用户变量仍然记录该操作的原始增量;现在按一致顺序取得两行,重试完整操作:

SELECT @increment;
START TRANSACTION;
UPDATE accounts SET balance=balance+@increment WHERE id=1;
UPDATE accounts SET balance=balance+@increment WHERE id=2;
COMMIT;

观察者核对组合结果:

SELECT id,balance FROM accounts ORDER BY id;

两行都必须变成二百一十。接下来对照锁等待超时。观察者先重置实验数据:

UPDATE accounts SET balance=100;

甲持有第一行的写锁:

START TRANSACTION;
UPDATE accounts SET balance=balance+1 WHERE id=1;

乙修改第二行,并设定较短等待时间:

SET SESSION innodb_lock_wait_timeout=1;
START TRANSACTION;
UPDATE accounts SET balance=balance+5 WHERE id=2;

仍在乙中请求甲持有的行:

UPDATE accounts SET balance=balance+5 WHERE id=1;

这次得到 1205(SQLSTATE HY000)锁等待超时错误,而不是死锁错误。乙检查自己事务里仍然待决的工作:

SELECT id,balance FROM accounts ORDER BY id;

乙读到一百和一百零五;观察者却仍然看到已提交的一百和一百:

SELECT id,balance FROM accounts ORDER BY id;

超时没有撤销乙此前增加的五。现在显式丢弃剩余事务,先在乙回滚:

ROLLBACK;

再在甲回滚:

ROLLBACK;

最后由观察者确认原始余额,并删除实验模式:

SELECT id,balance FROM accounts ORDER BY id;
DROP DATABASE xbcatm_deadlock_lab;

应人工比较每个错误、设置和行结果;SQL 只打印数值,没有自动断言,预定对照用于区分回滚范围。MySQL DDL 会隐式提交,因此即使回滚事务,中断后仍可能留下实验模式。遇到不符合预期的结果就停止,先回滚仍然打开的实验事务,再进行清理。特别是超时之后不要直接提交残余操作,否则可能把业务操作的一部分留下,却误认为整笔工作已经失败并全部撤销。

减少等待环,但不假定它们消失

对共同业务资源采用一致的取得顺序,缩短事务,并使用合适索引,避免锁住超出操作需要的记录。范围操作与二级索引维护,还可能引入这个双主键行例子之外的锁交互。一致顺序能够减少已经识别的等待环,但不能证明所有可能的死锁都被移除,也不能替代错误发生之后的正确恢复逻辑。

可以通过引擎状态查看最近一次检测到的死锁;如果事件频率需要更长历史,应配置相应采集。把涉及的语句和锁顺序关联回完整应用事务,不要只盯着最后那条报错语句。重试应该在截止时间和尝试次数约束下重新执行完整工作,同时区分超时语义。最终还要核对业务结果,避免仅因为最后一次语句没有报错,就认为逻辑操作已经完整恢复。

来源