半夜被短信震醒,大多数人都不会有好脾气,但如果短信内容里出现了 Deadlock found when trying to get lock; try restarting transaction,那就不是单纯没睡好的问题,而是业务在给的钱包上一记闷棍。这是 MySQL InnoDB 最经典也最让人头疼的一类错误,往里深挖,就是今天要聊的“死锁”这个话题。
这篇文章我打算用一次真实处理过的转账业务的锁事故作为主线,把死锁这件事拆开讲清楚:先讲清楚死锁和普通锁等待到底有什么区别,再把死锁该有的四个条件按真实场景掰开,然后直接给出 MySQL 下查看死锁最常用的命令和排查路径,最后给出代码层面和数据库层面如何尽量避免死锁的落地套路。最后还会延伸到 Java 多线程里的死锁,毕竟两边的底层思考是完全相通的。适合所有被 1213 错误折磨过的后端开发、DBA,以及刚开始接触数据库事务又害怕锁问题的同学。
1. “死锁”这两个字被用得太滥了:先分清死锁、锁等待和阻塞
很多刚接触事务的人,只要看到一条 SQL 卡了很久,第一反应就是“这表死锁了”。但严格来说,死锁、锁等待、阻塞是三件不同的事情,排查方向和解决方案完全不同。
简单理解就是这样:事务 A 想更新某一行,这一行正被事务 B 锁着,A 只能等 B 提交或回滚,这叫锁等待;如果 B 因为逻辑太慢或者忘了提交,A 等到了数据库超时阈值,报出 Lock wait timeout exceeded,那是锁等待超时,常见错误码是 1205;而死锁是 A 握着资源 1 等资源 2,B 握着资源 2 等资源 1,两边谁都不可能先松手,只能靠数据库自己检测出来并回滚其中一个事务,常见错误码是 1213。
我处理过的一个线上事故,最能说明这种区别。先准备一张简化版的账号表:
sql复制CREATE TABLE `t_acct` (
`id` bigint NOT NULL AUTO_INCREMENT,
`account_no` varchar(32) NOT NULL,
`balance` decimal(12,2) NOT NULL DEFAULT '0.00',
`version` int NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_account_no` (`account_no`)
) ENGINE=InnoDB;
测试数据就两条:
sql复制INSERT INTO `t_acct` (`id`, `account_no`, `balance`) VALUES
(1, 'A001', 100.00),
(2, 'A002', 100.00);
假设业务要做一次转账,A001 给 A002 转 50 元,事务里执行了这样一串操作:
sql复制-- 事务1
UPDATE t_acct SET balance = balance - 50 WHERE account_no = 'A001';
-- ... 这里模拟耗费了一些时间
UPDATE t_acct SET balance = balance + 50 WHERE account_no = 'A002';
如果另一个事务正握着 A002 这行迟迟不放手,事务1 的第二个 UPDATE 就会一直等,等了超过 innodb_lock_wait_timeout 后直接报超时。这种情况下,事务1 并没有拿着一个资源去申请另一个资源,它只是单方面等着别人释放,所以这不算死锁,应该叫锁等待超时。
真正常见的死锁版本是反向操作。事务 T1 先更新 A001,再更新 A002;事务 T2 先更新 A002,再更新 A001。两个事务同时提交,就会出现下面这个循环:
sql复制-- 事务 T1
START TRANSACTION;
UPDATE t_acct SET balance = balance - 50 WHERE account_no = 'A001';
UPDATE t_acct SET balance = balance + 50 WHERE account_no = 'A002';
COMMIT;
-- 事务 T2
START TRANSACTION;
UPDATE t_acct SET balance = balance - 30 WHERE account_no = 'A002';
UPDATE t_acct SET balance = balance + 30 WHERE account_no = 'A001';
COMMIT;
如果 T1 执行完第一条 UPDATE 拿到了 A001 的行锁,T2 执行完第一条 UPDATE 拿到了 A002 的行锁,接下来 T1 想拿 A002 的锁发现被 T2 占着,T2 想拿 A001 的锁发现被 T1 占着,两边就抱死了。这时候 MySQL 会检测到等待关系形成了环,立刻回滚其中代价较小的一方,让另一方继续执行。
对比下来就能得到一个很关键的认知:死锁的前提是每个事务都持有一个锁不放,还去申请别人持有的锁。如果没有“持有资源再去申请资源”这个过程,最多只会卡到超时,不会死锁。那么排查方向上,前面这种要去看为什么另一个事务长时间不提交、是不是大事务或者慢 SQL;后面这种要去看应用层操作资源的顺序是不是不一致、是不是一次事务锁了太多行。
把这个问题区分清楚,后面所有排查命令才有意义。因为你如果拿查询死锁的命令去看锁等待超时,会发现根本查不到 Deadlock 记录,浪费大量时间。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 为什么说产生死锁需要四把钥匙同时凑齐
死锁不是随便就能出现的,业界总结的四个必要条件放今天依然管用:互斥、请求与保持、不可剥夺、循环等待。我在做培训时经常说,这四个条件不是四个步骤,而是四把锁,必须同时扣上才会出死锁,缺一把都成不了闭环。
2.1 用转账的并发场景去套这四个条件
一套行锁为例。
互斥条件:同一时刻,一条记录只能被一个事务加上排他锁。这里是 InnoDB 最基础的保证,否则两个事务同时改一行就无法保证数据一致。这个条件天然存在,业务上无法绕开。
请求与保持条件:事务 T1 拿着 A001 这行的锁不放手,又去申请 A002 这行的锁。注意“不放手”这三个字很关键,很多死锁都发生在事务内部多步更新时。如果 T1 每次只操作一行、更新完立刻提交,就不会形成“占着一个等一个”的状态。
不可剥夺条件:T1 已经获取的锁不能被数据库强行抢走,只能等 T1 自己提交或回滚后释放。InnoDB 面对死锁时虽然会回滚某个事务,但那是死锁已经发生之后的止损动作,并不是在死锁发生前剥夺谁的锁。
循环等待条件:T1 等 A002,T2 等 A001,形成 A001 -> T1 -> A002 -> T2 -> A001 的环形。这里不一定是两个事务,三个、四个事务之间串成环也很常见,比如 T1 等 T2 的资源,T2 等 T3 的资源,T3 又等 T1 的资源,看起来更隐蔽。
用伪代码不是最好理解的,我直接列一个小状态表,模拟两个事务在时间轴上的动作:
| 时间点 | 事务 T1 | 事务 T2 |
|---|---|---|
| t1 | UPDATE A001,持有 A001 行锁 | UPDATE A002,持有 A002 行锁 |
| t2 | UPDATE A002,发现 A002 被 T2 锁住,进入等待 | UPDATE A001,发现 A001 被 T1 锁住,进入等待 |
| t3 | 等待中 | 等待中,InnoDB 检测到死锁,选择 T2 回滚 |
| t4 | T2 回滚后 A002 锁释放,T1 继续执行成功 | 抛出 1213 错误 |
在这个时间线里,T1 和 T2 在第 2 步同时进入了等待,而且等待的方向完全相反,这就是教科书级的循环等待。
2.2 认识误区:不是所有死锁都能被立刻发现
一个常见的理解偏差,是认为只要发生死锁,InnoDB 都能瞬间检测出来。其实 InnoDB
