当多个事务形成不兼容锁请求的等待环时,就发生 InnoDB 死锁。开启死锁检测后,引擎选择一个受害者,回滚它的整个事务,使其他参与者能够继续。应用收到错误 1213(SQLSTATE 40001)。若只重试报错的那条语句,就会漏掉同一事务中更早执行、但已经一起被回滚的工作。
坏掉的样子
不能给所有锁错误套用同一种回滚规则。默认关闭超时整笔回滚时,锁等待超时只撤销正在等待的那条语句,事务内此前执行的语句仍然待决。开启对应服务端选项后,超时才会改为回滚整个事务。死锁本来就会回滚整个受害事务,不能把默认超时的语句级行为误套在死锁上。
受害者选择也不保证某个固定会话永远输。应用必须检查真实错误,再以有界次数重试完整逻辑事务。不可逆的外部副作用如果没有单独去重契约,不应跟着数据库事务自动重放。提交回复途中连接断开,也不等于已经得到死锁回滚的确定结论;错误类型与已知事务结果需要分开判断。
快速验证
在可丢弃的 MySQL 8.4 实例上打开甲、乙和观察者三个全新管理客户端,先确认三个会话均处于自动提交模式,关闭客户端自动重新连接,保持会话身份稳定。本次实际执行版本是 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;
这项验证通过真实数据值区分回滚范围,而不只是相信错误消息里的文字。遇到不符合预期的结果就停止,先回滚仍然打开的实验事务,再进行清理。特别是超时之后不要直接提交残余操作,否则可能把业务操作的一部分留下,却误认为整笔工作已经失败并全部撤销。
减少等待环,但不假定它们消失
对共同业务资源采用一致的取得顺序,缩短事务,并使用合适索引,避免锁住超出操作需要的记录。范围操作与二级索引维护,还可能引入这个双主键行例子之外的锁交互。一致顺序能够减少已经识别的等待环,但不能证明所有可能的死锁都被移除,也不能替代错误发生之后的正确恢复逻辑。
可以通过引擎状态查看最近一次检测到的死锁;如果事件频率需要更长历史,应配置相应采集。把涉及的语句和锁顺序关联回完整应用事务,不要只盯着最后那条报错语句。重试应该在截止时间和尝试次数约束下重新执行完整工作,同时区分超时语义。最终还要核对业务结果,避免仅因为最后一次语句没有报错,就认为逻辑操作已经完整恢复。
来源
- MySQL 8.4 InnoDB 错误处理,死锁整笔回滚与可配置的超时回滚范围,已核验 2026-09-12。
- MySQL 8.4 死锁示例,复现等待环并检查实际受害者,已核验 2026-09-12。
- MySQL 8.4 InnoDB 锁,并发操作涉及的记录锁与范围锁,已核验 2026-09-12。