半夜被短信震醒,大多数人都不会有好脾气,但如果短信内容里出现了 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 的死锁检测依赖事务等待图,如果等待关系形成环,数据库可以立即感知并回滚其中一个事务。但如果锁涉及的范围特别大、事务特别多,等待关系可能没能在第一时间形成闭环,这时候数据库不会一直等下去,而是靠 innodb_lock_wait_timeout 这个参数兜底,默认值一般是 50 秒,超过后某个事务放弃等待并释放它持有的锁,其他事务才能继续跑。
所以你会碰到一种现象:错误日志里没出现 LATEST DETECTED DEADLOCK,但业务报了一大堆锁等待超时。这种情况大概率是“近死锁状态”——每个事务都在等别人,只是没构成完美闭环,或者还没来得及闭环就触发了超时。
还有另一个误区,就是以为只有 UPDATE 会死锁。实际上 INSERT 也可能因为间隙锁或插入意向锁发生死锁,尤其是在 REPEATABLE READ 隔离级别下。比如一个事务在范围查询后插入新记录,另一个事务也插入相同范围的记录,双方的间隙锁和插入意向锁互相等待,一样能形成死锁。排查死锁时,如果只盯着动过哪些行,往往会漏掉“范围锁”这个元凶。
理解了这四个条件,接下来的问题就是:出了事,怎么让数据库把这个环交代出来。
3. MySQL 里查看死锁的三种姿势,别再靠乱杀线程碰运气
数据库死锁最恶心的点在于,它发生后会有一个事务自动被回滚,看起来就像一个偶发的业务异常。有些团队的处理方式是看线程太多就 kill 掉几个 processlist 里的线程,这属于赌徒手法,完全不推荐。正确做法是让 MySQL 把死亡现场留下来,然后按图索骥找到业务代码里的问题。
3.1 第一姿势:SHOW ENGINE INNODB STATUS 里读死锁段落
这是最经典、最不需要额外权限的命令。执行:
sql复制SHOW ENGINE INNODB STATUS\G
输出内容里有一段叫做 LATEST DETECTED DEADLOCK,用中文说就是“最近一次检测到的死锁”。这一段不是实时的,它只保留最近一次死锁的详细信息,如果后来你又触发了新的死锁,上一次的信息就会被覆盖。
真实输出大概是这个样子(我做了脱敏和精简):
text复制------------------------
LATEST DETECTED DEADLOCK
------------------------
2025-01-18 03:22:16 0x7f8b1c0b2700
*** (1) TRANSACTION:
TRANSACTION 871253, ACTIVE 18 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 45213, OS thread handle 14000...
UPDATE t_acct SET balance = balance + 30 WHERE account_no = 'A001'
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 88 page no 5 n bits 72 index uk_account_no of table `demo`.`t_acct` trx id 871253 lock_mode X locks rec but not gap waiting
*** (2) TRANSACTION:
TRANSACTION 871254, ACTIVE 12 sec starting index read
mysql tables in use 1, locked 1
4 lock struct(s), heap size 1136, 3 row lock(s)
MySQL thread id 45219, OS thread handle 14001...
UPDATE t_acct SET balance = balance + 50 WHERE account_no = 'A002'
*** (2) HOLDING THE LOCK(S):
RECORD LOCKS space id 88 page no 4 n bits 72 index uk_account_no of table `demo`.`t_acct` trx id 871254 lock_mode X locks rec but not gap
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 88 page no 5 n bits 72 index uk_account_no of table `demo`.`t_acct` trx id 871254 lock_mode X locks rec but not gap waiting
*** WE ROLL BACK TRANSACTION (2)
读这段日志,顺序非常重要。*** (1) TRANSACTION 是第一个被记录的事务,下面的 WAITING FOR THIS LOCK TO BE GRANTED 表示它当时在等哪一把锁;*** (2) TRANSACTION 是另一个事务,下面 HOLDING THE LOCK(S) 表示它占着哪些锁,如果它后面也有 WAITING FOR...,说明它也在等第一个事务的锁;最后一行 WE ROLL BACK TRANSACTION (2) 告诉你是谁被牺牲了。
把它翻译成人话就是:事务 1 想更新 A001,但 A001 被事务 2 锁着;事务 2 想更新 A002,但 A002 被事务 1 锁着。看到这里,为什么死锁就一目了然了。除了看等待的索引名和行数据,还要看 starting index read 和后面的 UPDATE 语句,通过这些信息能定位到具体是哪一段业务代码。
需要提醒的是,这个命令只能看到“最近一次”死锁。如果线上死锁频率高、相隔时间短,你可能只看到最后一次。所以更稳妥的做法,是把死锁日志打开并落盘到错误日志文件里,这样排查时可以看到一段时间内的完整记录。
3.2 进阶姿势:用 performance_schema 实时看锁和等待关系
SHOW ENGINE INNODB STATUS 看的是历史快照,是事后分析工具。但有时候死锁还没发生,只是业务已经出现大量锁等待,这时候我们需要实时看当前到底谁锁了谁。MySQL 8.0 里最值得用的两张表是 performance_schema.data_locks 和 performance_schema.data_lock_waits。
data_locks 表会列出当前所有活跃的锁记录,包含锁类型、锁模式、锁在哪一张表哪个索引哪一行。常用查询是把它和 threads 表关联,把锁归属到具体连接和正在执行的 SQL 上:
sql复制SELECT
l.ENGINE_TRANSACTION_ID AS trx_id,
l.OBJECT_NAME AS table_name,
l.INDEX_NAME,
l.LOCK_DATA,
l.LOCK_MODE,
t.PROCESSLIST_ID,
t.PROCESSLIST_TIME,
t.PROCESSLIST_INFO AS current_sql
FROM performance_schema.data_locks l
LEFT JOIN performance_schema.threads t
ON l.THREAD_ID = t.THREAD_ID
WHERE l.OBJECT_SCHEMA = 'demo';
如果某一条记录长期存在,PROCESSLIST_TIME 会很大,它大概率就是持锁不释放的源头。光看它不够,我们还需要看“谁在等谁”,这时要查 data_lock_waits:
sql复制SELECT
r.ENGINE_TRANSACTION_ID AS blocking_trx,
r.OBJECT_NAME AS table_name,
r.INDEX_NAME,
r.LOCK_MODE,
r.LOCK_DATA,
w.REQUESTING_THREAD_ID AS waiting_thread
FROM performance_schema.data_lock_waits w
JOIN performance_schema.data_locks r
ON w.BLOCKING_ENGINE_LOCK_ID = r.ENGINE_LOCK_ID
WHERE r.OBJECT_SCHEMA = 'demo';
这两条 SQL 出来的结果,配合事务开始时间和执行语句,基本可以拼出实时阻塞链。实际排查时,我还习惯直接查一下当前都有哪些事务正在跑:
sql复制SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id,
trx_query, trx_rows_locked, trx_rows_modified
FROM information_schema.INNODB_TRX;
这个视图在 8.0 里依然存在,它最大的价值是告诉你事务已经跑多久了。一个事务从 trx_started 到当前时刻超过几十秒甚至几分钟,基本就是长事务,这种事务持有的锁越多,制造死锁的概率就越大。对很多还没来得及开启 performance_schema 的环境,INNODB_TRX 和 SHOW PROCESSLIST 就是最后一道防线。
3.3 查看死锁相关的参数与开关
看过太多人在现场临时抓瞎,我建议提前把几个点确认好:
sql复制-- 查看锁等待超时时间,默认 50 秒
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
-- 查看事务隔离级别
SHOW VARIABLES LIKE 'transaction_isolation';
-- 查看当前开着的慢查询阈值
SHOW VARIABLES LIKE 'long_query_time';
关于 innodb_lock_wait_timeout,我的观点是它不是一个“越小越好”的参数。调小了,锁等待会更快暴露,但如果业务高峰确实存在合法的短暂锁等待,调太短会导致大量正常事务被误杀。调大了,又可能让锁等待拖死连接池。比较务实的做法是先保持默认或稍微下调,比如 30 秒,再通过监控观察报 1205 错误的数量,而不是拍脑袋调到一个极端值。
另外,如果你的数据库版本支持并且排查需要,你还可以临时开启 InnoDB 的锁监控,把锁信息输出到 SHOW ENGINE INNODB STATUS:
sql复制SET GLOBAL innodb_status_output_locks = ON;
SET GLOBAL innodb_status_output = ON;
注意,这个开关会大幅增加错误日志输出量,线上环境用完务必关掉,不要一直开着当常规监控手段。
4. 慢查询并不冤枉,它常常是死锁的源头:一次线上事故复盘
前面说的都比较理论,接下来分享一个实际案例。这是一个非常典型的场景:业务间歇性报“死锁”,但每次死锁事务看起来都是很简单的单行 UPDATE,怎么也看不出问题。最后顺着慢查询日志,才揪出了藏在背后的元凶。
4.1 现场现象:告警看起来很偶然
某天下午,支付对账服务突然报警,错误日志里滚动出现 Deadlock found when trying to get lock,同时数据库 CPU 不算高,但连接数明显上涨,大量线程堆在 updating 状态。第一时间我跑了:
sql复制SHOW FULL PROCESSLIST;
大概能看到几十个会话都在执行类似下面的 UPDATE:
sql复制UPDATE t_transfer SET status = 4 WHERE transfer_no = 'T202501180001';
每条语句看起来都是等值更新,执行计划应该走唯一索引,锁的行数就是 1 行。但奇怪的是,它们会互相等锁,最后演变成死锁。这种场景很容易让人误以为是数据库抽风,毕竟单条行更新怎么会锁冲突呢?
4.2 顺藤摸瓜:慢查询日志暴露了一个“扫表型”事务
我马上开启慢查询统计,把 long_query_time 临时调到 1 秒,观察了几分钟,在一个监控页面里发现一个响应耗时超过 3 秒的 UPDATE。它的形态是这样的:
sql复制UPDATE t_transfer SET status = 3
WHERE biz_date = '2025-01-17'
AND status IN (0, 1)
AND create_time >= '2025-01-17 00:00:00'
AND create_time < '2025-01-18 00:00:00';
这条 SQL 看起来也正常,有 biz_date,有 create_time,但仔细一看表结构:
sql复制CREATE TABLE `t_transfer` (
`id` bigint NOT NULL AUTO_INCREMENT,
`transfer_no` varchar(32) NOT NULL,
`biz_date` varchar(10) NOT NULL,
`status` tinyint NOT NULL DEFAULT '0',
`create_time` datetime NOT NULL,
`finish_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_biz_date` (`biz_date`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB;
问题来了。biz_date 字段的选择性太差,一整天的数据可能都是同一个 biz_date,所以走 idx_biz_date 之后还要回表过滤大量行。执行 EXPLAIN 输出里,rows 预估 8 万多,type 是 ref,Extra 里出现 Using where。一条 8 万行的数据更新虽然不是全表扫描,但 InnoDB 实际上会先锁住它扫描过程中触及的所有匹配行,而且由于事务隔离级别是 REPEATABLE READ,那些满足 status 条件但没有真正被更新的行也有可能被间隙锁覆盖。
以前面对账事务和转账事务同时触发为例:对账事务锁了几天内 8 万行中的很多记录,转账事务只更新一行却需要申请同一行上的锁,于是排队等待;对账事务里还要做另外一些表的更新,其他对账事务也在并行执行类似的大范围更新,多个大范围更新之间存在重叠锁集,最后就形成了死锁。慢 SQL 本身不一定直接报错,但它持锁时间太长、持锁范围太大,是周围所有小事务死锁的温床。
4.3 最终修复:给大更新瘦身,给业务顺序定规矩
这次事故的修复分了三步,每一步都值得参考。
第一步,优化大范围 UPDATE。把对账更新切分成小批量,每次只处理少量数据并提交。比如用主键范围分批:
sql复制UPDATE t_transfer
SET status = 3
WHERE id BETWEEN ? AND ?
AND status IN (0, 1);
同时让 biz_date 和 create_time 的联合索引更贴合过滤条件:
sql复制ALTER TABLE t_transfer
ADD INDEX idx_biz_date_status (biz_date, status);
这样 UPDATE 的 WHERE 条件能够直接命中索引,大幅减少扫描和加锁范围。
第二步,修改应用层对账逻辑。同一个总账数据的所有子更新,在代码层先按某个业务键排序,然后统一顺序执行,尽量避免交叉申请锁。这一步在下面的“避免死锁的落地做法”章节里会展开。
第三步,给容易死锁的业务代码加自动重试机制。因为死锁发生后必然会回滚一个事务,业务侧能做的就是收到 1213 错误后做有限次重试,通常重试一两次就能成功。这里加一个简单的 Spring 注解示意的重试逻辑:
java复制@Component
public class TransferService {
@Retryable(retryFor = CannotAcquireLockException.class, maxAttempts = 3,
backoff = @Backoff(delay = 100, multiplier = 2))
public void doTransfer(TransferReq req) {
// 转账事务
}
}
如果项目里没有 Spring Retry,也可以自己在 catch 里写循环,但务必要设置最大重试次数,避免死循环把数据库拖垮。
经过这一轮处理之后,死锁报警基本消失。可以说,这个案例里死锁只是症状,慢查询的大范围数据更新才是病根。所以当你看到死锁时,别只盯着死锁本身,先看看最近有没有慢 SQL 在“无差别锁人”。
5. 想少碰死锁,靠的不只是加 timeout 和重试
死锁被 InnoDB 检测后会自动回滚一个事务,所以严格来说数据库不会因为死锁而永久卡死。但业务层会收到报错,如果频繁发生,用户侧就能感知到异常。想降低死锁发生概率,下面这几个实践是我觉得投入产出比最高的。
5.1 应用层统一加锁顺序,是性价比最高的一招
回到最开始的转账例子,如果所有调用方在更新多个账户前,都先把账户号排序,那么两个并发事务的加锁顺序就会一致。T1 先锁 A001 再锁 A002,T2 也是先锁 A001 再锁 A002,T2 会直接等在 A001 上,等 T1 提交后它再执行。这就不再是死锁,只是普通的锁等待。
我在代码里一般会写一个工具方法,把要更新的账户列表排好序:
java复制List<String> accountNos = Arrays.asList("A001", "A002");
Collections.sort(accountNos);
for (String accountNo : accountNos) {
accountMapper.updateBalance(accountNo, adjustMap.get(accountNo));
}
这个思路可以推广到所有多资源更新的场景:订单、库存、优惠券、积分等,只要更新超过一张表或超过一行,先想一下是不是所有入口都按同一个全局顺序操作了。很多团队遇到死锁就加索引,加完没效果,就是因为它们忽略了这个应用层顺序问题。
但这里要强调,顺序一致性必须覆盖所有入口。如果有某个定时任务或者管理后台的脚本没有走同一套排序逻辑,仍然会死锁,而且这种死锁更隐蔽,因为两个入口的代码长得完全不一样。
5.2 事务尽量短小,能不锁就不锁
长事务是死锁的肥料。一个事务从开启到提交之间,所有被它修改过的行都处于锁住状态。如果这个事务还伴随着外部接口调用、文件读取、消息发送,那锁的持有时间会被拉得很长,其他事务等待的概率就非常大。
我见过不少代码是这样的:
java复制@Transactional
public void processOrder(OrderReq req) {
// 1. 锁订单行
orderMapper.lockOrder(req.getId());
// 2. 调用外部支付
String result = httpClient.post(payUrl, req);
// 3. 根据结果更新订单状态
orderMapper.updateStatus(req.getId(), result);
}
这个设计最大的问题是在持有数据库锁的情况下做了同步外部 HTTP 调用。外部接口慢,数据库锁就一直不释放,死锁和锁等待只是早晚的问题。正确姿势应该是先调用外部接口拿到结果,再开事务做本地状态更新,或者把外部调用放在事务外面。
如果确实需要读取并锁定订单,再调外部接口,那至少应该缩短事务内耗时的外部交互时间,并设置合理的超时时间。
5.3 让索引真正帮上忙,缩小锁范围
InnoDB 的行锁是基于索引实现的,如果一次 UPDATE 没走索引,数据库退化成全表扫描,锁的就是全表所有匹配行,哪怕你只想改一行,也可能把所有行的锁都拿到了。这种大范围锁最容易引起连锁死锁。
反过来,如果 UPDATE 的 WHERE 条件能精准命中唯一索引,锁的范围通常就是一两行。尽量让 WHERE 条件里包含唯一键或者选择度非常高的索引列,避免用状态字段、日期字段这类低选择度字段。像 status = 0 这种条件更新,除非能配合其他高选择度条件,否则谨慎使用。
这里我需要特别说明一下子查询和 IN 更新。比如下面这种写法就可能锁范围失控:
sql复制UPDATE t_order SET status = 5
WHERE user_id IN (
SELECT user_id FROM t_blacklist WHERE level > 3
);
如果子查询结果集很大,或者优化器选择了不合适的执行顺序,锁的行数会远超你预期。遇到这种需求,最好先查出候选列表,再分批精确更新。
5.4 权衡事务隔离级别与间隙锁
默认隔离级别 REPEATABLE READ 在 MySQL 里是主流,它会带来 next-key lock,也就是不仅锁住命中的行,还会锁住行之前的间隙,防止其他事务在这个范围内插入新记录。间隙锁极大地加剧了死锁概率,尤其是在范围查询 + 插入的场景。
如果业务对幻读没有强烈要求,把隔离级别降到 READ COMMITTED,InnoDB 就只锁行不锁间隙,死锁和锁等待概率会明显下降。但做这个调整前一定要评估业务是否依赖可重复读的语义,并确认 binlog 格式不是基于 statement 的,免得引入主从数据不一致的问题。这个决策需要 DBA 和业务开发共同确认,不能只为了消灭死锁就乱降隔离级别。
5.5 乐观锁和重试是最后的兜底
即使前面全做到了,死锁也做不到绝对为零,因为数据库内部的锁调度、并发请求的时序总有你无法预测的地方。所以生产环境代码必须捕获死锁异常并重试,同时把异常信息打印到日志里,留下一手资料供后续分析。
java复制public void safeTransfer(TransferReq req) {
int retry = 0;
while (retry < 3) {
try {
transferService.doTransfer(req);
return;
} catch (DeadlockLoserDataAccessException ex) {
retry++;
}
}
throw new BizException("系统繁忙,请稍后重试");
}
如果业务允许,还可以考虑乐观锁方案,给表加一个 version 字段,更新时带上 version 条件:
sql复制UPDATE t_acct
SET balance = balance - 50, version = version + 1
WHERE account_no = 'A001' AND version = 3;
当影响行数为 0 时说明版本变了,就直接回滚冲突或重新读取。这种方案很多时候可以避免让数据库行锁长时间等待,把冲突转移到业务控制台处理。
6. 线程死锁:从 JVM 的角度再看一遍“循环等待”
不只是数据库会死锁,JVM 多线程也一样。把两边放在一起看很有意思,因为它们的底层模型完全同构:多个线程持有锁资源,互相等待对方的锁。下面用一个最小 Java 例子演示,这个代码虽然简单,但我在很多项目里都见过类似的影子。
6.1 一个最小的 Java 死锁程序
java复制public class DeadLockDemo {
private static final Object LOCK_A = new Object();
private static final Object LOCK_B = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (LOCK_A) {
System.out.println("T1 持有 LOCK_A");
sleep(100);
synchronized (LOCK_B) {
System.out.println("T1 拿到 LOCK_B");
}
}
}, "T1");
Thread t2 = new Thread(() -> {
synchronized (LOCK_B) {
System.out.println("T2 持有 LOCK_B");
sleep(100);
synchronized (LOCK_A) {
System.out.println("T2 拿到 LOCK_A");
}
}
}, "T2");
t1.start();
t2.start();
}
private static void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException ignored) {
}
}
}
T1 先拿 LOCK_A,睡 100 毫秒后想拿 LOCK_B;T2 先拿 LOCK_B,睡 100 毫秒后想拿 LOCK_A。睡眠那 100 毫秒就是故意让两个线程都先拿到自己的第一个锁,然后再互相申请,于是死锁。
6.2 用 jstack 查看死锁线程
程序跑起来后卡住不动,用 JDK 自带的 jstack 查看线程栈:
bash复制jstack <pid>
输出最后很关键,JVM 会自动检测并输出 Found one Java-level deadlock:
text复制Found one Java-level deadlock:
=============================
"T2":
waiting to lock monitor 0x0000000001a4... (object 0x00000000..., a java.lang.Object)
which is held by "T1"
"T1":
waiting to lock monitor 0x0000000001b8... (object 0x00000000..., a java.lang.Object)
which is held by "T2"
Java stack information for the threads listed above:
===================================================
...
看到这段就不用怀疑了,T1 等 T2 的锁,T2 等 T1 的锁,经典的循环等待。比数据库好的一点是,JVM 在有 jstack 时能主动汇报死锁线程,不需要像 InnoDB 那样靠检测等待图。
6.3 在线程世界里怎么避免
和数据库的解法一模一样。第一原则仍然是“全局顺序加锁”,两个线程要锁多个对象时,都按相同的顺序去锁,比如都先锁 LOCK_A 再锁 LOCK_B,死锁就不会发生。第二原则是使用带超时时间的锁,比如 ReentrantLock 的 tryLock:
java复制Lock lockA = new ReentrantLock();
Lock lockB = new ReentrantLock();
boolean gotA = lockA.tryLock(1, TimeUnit.SECONDS);
if (gotA) {
try {
boolean gotB = lockB.tryLock(1, TimeUnit.SECONDS);
if (gotB) {
try {
// 业务逻辑
} finally {
lockB.unlock();
}
}
} finally {
lockA.unlock();
}
}
用 tryLock 的好处是,一旦拿不到第二个锁也不会无限等下去,而是主动放弃已持有的锁,打破“请求与保持”的条件。这和 MySQL 里 innodb_lock_wait_timeout 的兜底逻辑是同一个道理。
如果不想在每个方法里手工处理锁顺序,也可以使用 JVM 自带的 ThreadMXBean 做定时扫描:
java复制ThreadMXBean tmx = ManagementFactory.getThreadMXBean();
long[] ids = tmx.findDeadlockedThreads();
if (ids != null) {
ThreadInfo[] infos = tmx.getThreadInfo(ids, true, true);
for (ThreadInfo info : infos) {
System.out.println(info.getThreadName() + " 死锁");
}
}
这个检查器可以放在监控线程里,一旦发现死锁,把线程栈相关内容输出到日志,方便事后分析。但要注意,findDeadlockedThreads 只能检测出已经被 JVM 识别为 wait-for-monitor 的循环等待,像 ReentrantLock 这类 Lock 接口的锁死锁需要通过设置 -Djava.rmi.server.hostname 之类的远程监控或 jstack 日志分析来判断,单独依赖 ThreadMXBean 不一定能覆盖所有场景。
把数据库和 Java 线程对比看下来,你会得到一个很强烈的感受:所谓解决死锁,并不是设计一套永远不死锁的完美方案,那几乎不可能做到;真正可靠的是让它快速暴露、快速恢复、事后能定位。
我个人在实际排查中养成了一个习惯,每次接到死锁告警,先不急着改代码,而是打开 SHOW ENGINE INNODB STATUS 和慢查询日志,把死锁现场、锁等待时间、涉及的事务 SQL 全部截图存档,再反推业务代码有没有乱序加锁、大范围更新、长事务等问题。这套思路帮我处理过很多次线上问题,比起漫无目的地重启服务靠谱太多。如果你能按上面的链路走一遍,大部分死锁问题其实都能在日志里被找到,剩下的只是怎么改代码的问题。
