有个转账类的功能,逻辑很简单:从账户 A 扣钱,给账户 B 加钱,两步包在一个事务里。平时跑得好好的,可一到大促、并发一高,日志里就零星冒出一种以前没见过的报错:Deadlock found when trying to get lock; try restarting transaction——死锁。受影响的那笔转账直接失败回滚,用户重试一下大多能成,可这种"偶发失败"在财务场景里格外刺眼,而且量一大就成了规模性的告警。
我一开始百思不得其解:就两条 update,怎么会死锁?死锁不是得有"环"吗?顺着日志把并发的两笔转账摆在一起对比,真相才浮出水面。原来,转账逻辑是"从付款方扣、给收款方加",而付款方和收款方是用户随机指定的。于是当用户甲给乙转账(先锁甲、再锁乙)、与此同时用户乙给甲转账(先锁乙、再锁甲),两笔事务恰好以相反的顺序去锁同样的两行记录:甲的事务锁住了甲、正等着锁乙;乙的事务锁住了乙、正等着锁甲。双方各持一把锁、又都在等对方手里那把——一个谁也解不开的环,就这么形成了。
这就是数据库里最经典的并发难题:死锁(Deadlock)。它不像索引失效那样"稳定复现",而是只在特定的并发时序下才偶发,像个神出鬼没的幽灵。而它的成因,几乎总能归结到一句话上——多个事务以不一致的顺序去获取多把锁。这篇文章,就从这次"转账偶发死锁"的事故出发,把数据库死锁的成因、排查和根治,一次讲透。
先摆几个关于数据库锁的想当然
动手复盘前,先把我自己曾经深信、后来被死锁教育的几个念头摆出来。
| 想当然的念头 | 残酷的真相 |
|---|---|
| "就两条 update,不可能死锁" | 只要两个事务以相反顺序锁两行, 就能构成死锁环 |
| "死锁了得我自己处理,很麻烦" | 数据库会自动检测并回滚其中一个, 你要做的是捕获并重试 |
| "加锁顺序无所谓,反正都要锁到" | 顺序恰恰是关键, 统一加锁顺序能从根上消灭死锁 |
| "死锁就是并发太高,加机器就好" | 加机器只会让并发更高、死锁更频繁, 是火上浇油 |
| "没走索引的 update 锁一行就够了" | 没走索引会锁更多行甚至全表, 大大增加死锁概率 |
这些念头的共同病根,是对数据库的锁机制和死锁形成的条件缺乏清晰认识,把"加锁"当成了一件不会互相冲突的孤立操作。要看清这次事故,得先理解死锁这个"环"到底是怎么形成的。
第一件事:死锁是怎么形成的——一个互相等待的环
先说锁。在 InnoDB 里,当一个事务执行 UPDATE、DELETE 或 SELECT ... FOR UPDATE 时,会给涉及的行加上排他锁(写锁):在这个事务提交或回滚之前,别的事务想再锁这一行,就得排队等待。这本身是保证数据一致性的正常机制。问题出在,当一个事务需要依次锁住多行、而多个事务锁的顺序又不一致时。
死锁的形成,需要几个条件同时满足,核心是"循环等待":事务 1 持有资源 A、等待资源 B;事务 2 持有资源 B、等待资源 A。两者都不肯松手(持有的锁要到事务结束才释放),又都在等对方松手——于是谁也走不下去,形成一个闭环的等待。我那次转账,正是这个模型最典型的现实投影:A 是甲的账户行,B 是乙的账户行,两笔反向转账各自锁住一头、等待另一头。下面这张图,把这个环画出来:
看懂这张图,事故的本质就清楚了:不是哪条 SQL 写错了,而是两个逻辑上都正确的事务,因为加锁顺序相反,在并发时撞成了一个互相等待的环。好消息是,数据库不会让这个环永远卡死下去——InnoDB 有死锁检测机制,发现环之后会主动选择其中一个事务回滚(通常是"代价较小"的那个),把锁释放出来,让另一个事务得以继续。这就是为什么报错信息里会建议你 try restarting transaction。接下来,我们就看怎么应对和根治。
第二件事:根治之道——统一加锁顺序
既然死锁的根源是"两个事务以相反顺序锁同样的资源",那最釜底抽薪的解法就呼之欲出了:让所有事务都以同一个固定的顺序去获取锁。只要大家锁的顺序一致,就永远不会出现"你先锁 A 我先锁 B"的交叉,循环等待的环就从根上无法形成。
具体到我的转账场景,办法很简单:不管是甲转乙还是乙转甲,都不按"付款方、收款方"的业务顺序加锁,而是按账户 ID 的大小排序后加锁——永远先锁 ID 小的那个账户,再锁 ID 大的。这样两笔反向转账锁的顺序就一致了,死锁消失。
// 反例:按业务顺序(先付款方、后收款方)加锁, 反向转账会死锁
public void transfer(long from, long to, BigDecimal amt) {
lockAndUpdate(from, amt.negate()); // 甲转乙先锁甲; 乙转甲先锁乙
lockAndUpdate(to, amt); // 顺序相反 → 并发时死锁
}
// 正解:统一按账户 ID 从小到大加锁, 彻底消除交叉
public void transfer(long from, long to, BigDecimal amt) {
long first = Math.min(from, to); // 永远先锁 ID 小的
long second = Math.max(from, to); // 再锁 ID 大的
// 在一个事务里, 按固定顺序锁两行
lockRow(first);
lockRow(second);
// 锁定后再做加减, 此时无论谁转谁, 加锁顺序都一致, 不会死锁
updateBalance(from, amt.negate());
updateBalance(to, amt);
}
对应到 SQL 层面,如果你用 SELECT ... FOR UPDATE 显式锁行,也是同样的道理——按一个确定的排序字段去锁:
-- 在事务里, 按 id 排序后再逐行加锁, 保证全局顺序一致
START TRANSACTION;
SELECT * FROM account WHERE id IN (101, 205)
ORDER BY id FOR UPDATE; -- ORDER BY id 让加锁顺序固定
-- 之后再做余额的加减
UPDATE account SET balance = balance - 100 WHERE id = 205;
UPDATE account SET balance = balance + 100 WHERE id = 101;
COMMIT;
"统一加锁顺序"是消除死锁最根本、最可靠的一招,核心思想是给所有要锁的资源定义一个全局一致的排序,所有事务都照这个序来锁。无论是按 ID、按某个唯一键,只要全局统一,环就不会形成。这个原则不仅适用于数据库,在多线程编程里"按固定顺序获取多把锁"防死锁,本质是一模一样的。
第三件事:死锁无法 100% 避免,应用层必须能重试
统一加锁顺序能消除绝大多数死锁,但要诚实地说:在复杂系统里,死锁很难被 100% 杜绝。不同业务路径、不同 SQL、间隙锁等因素交织在一起,总可能在某些刁钻时序下冒出死锁。所以除了努力预防,还必须接受一个现实——死锁是一种"正常的、预期内的"并发异常,应用层要有能力优雅地应对它,而那个应对方式就是:捕获死锁异常,然后重试整个事务。
// 死锁是预期内异常: 捕获它, 退避后重试整个事务
public void transferWithRetry(long from, long to, BigDecimal amt) {
int maxRetry = 3;
for (int i = 0; i < maxRetry; i++) {
try {
transfer(from, to, amt); // 内部是一个完整事务
return; // 成功就返回
} catch (DeadlockLoserDataAccessException e) {
// 死锁导致本事务被回滚, 短暂退避后重试
if (i == maxRetry - 1) throw e; // 重试到顶仍失败才抛出
sleepBackoff(i); // 加点随机退避, 错开时序
}
}
}
这里的关键认知是:数据库检测到死锁后,会回滚其中一个事务、释放它的锁,另一个事务其实是成功了的。被回滚的那个,只要重新执行一遍,这次大概率就能顺利拿到锁(因为冲突方已经走了)。所以重试不是"碰运气",而是有理有据的标准应对。配上随机退避(避免重试又撞在一起),绝大多数死锁对用户来说就变得无感了。预防(统一加锁顺序)降低死锁发生率,重试(捕获后重做)兜住漏网的那些——两者结合,才是完整的方案。
第四件事:让事务又短又小,缩小锁的"暴露面"
死锁的概率,和"锁被持有的时间"以及"锁住的行数"正相关。一个事务持有锁的时间越长、锁住的行越多,它和别人撞车的窗口就越大。所以另一条重要的预防原则是:让事务尽可能短小精悍——只把真正需要原子性的操作放进事务,别在事务里夹带耗时的"闲事"。
// 反例:事务里夹带远程调用/耗时操作, 锁被长时间持有, 死锁概率飙升
@Transactional
public void badTransfer(long from, long to, BigDecimal amt) {
lockAndUpdate(from, amt.negate());
callRemoteRiskCheck(from, to); // 调外部风控, 耗时几百 ms, 锁还攥着!
sendNotification(to); // 发通知, 又是几十 ms
lockAndUpdate(to, amt); // 锁被持有这么久, 撞车窗口巨大
}
// 正解:事务只包真正需要原子的核心操作, 耗时的事挪到事务外
public void goodTransfer(long from, long to, BigDecimal amt) {
callRemoteRiskCheck(from, to); // 先在事务外做完风控
doAtomicTransfer(from, to, amt); // 事务内只有快速的扣加余额
sendNotificationAsync(to); // 通知异步发, 不占事务
}
@Transactional
void doAtomicTransfer(long from, long to, BigDecimal amt) {
// 又短又快, 锁瞬间持有又瞬间释放, 极大降低死锁
updateBalanceOrdered(from, to, amt);
}
原则很朴素:事务里只放"必须放在一起、要么全成要么全滚"的操作,把网络调用、文件 IO、发消息、复杂计算这些耗时的、不需要被锁保护的事,统统挪到事务外面去。事务越短,锁持有的时间越短,死锁(乃至更普遍的锁等待超时)的概率就越低。这一条不仅防死锁,更是提升整个数据库并发吞吐的基本功。
第五件事:别让 update 因为没走索引而锁住一大片
这里有个和上一篇"索引"主题暗中呼应的坑:如果你的 UPDATE/DELETE 的 WHERE 条件没有走索引,InnoDB 可能会锁住远超你预期的行,甚至升级到锁很多行——锁的行越多,和别的事务冲突、进而死锁的概率就越大。这就把"索引失效"和"死锁"这两个看似不相关的问题,悄悄串在了一起。
-- 反例:status 没索引, 这条 update 可能锁住大量行(扫描到的行都加锁)
UPDATE orders SET status = 2 WHERE status = 1; -- status 无索引 → 锁一大片
-- 正解:确保 WHERE 条件能走索引, 把锁精确地缩小到目标行
-- 给 status 建索引, 或用走索引的精确条件(如主键)
UPDATE orders SET status = 2 WHERE id = 12345; -- 走主键, 只锁这一行
-- 给高频更新条件建好索引, 让加锁范围最小化
CREATE INDEX idx_status ON orders(status);
这个关联很值得记住:走索引的更新,锁是精确的、最小的;没走索引的更新,锁是粗放的、扩大的。锁的范围一扩大,不仅性能差,撞上别的事务、形成死锁的概率也水涨船高。所以确保增删改的 WHERE 条件走索引,既是性能优化,也是死锁预防——一举两得。这也再次说明,数据库的各类问题往往盘根错节,理解了底层机制,才能看到它们之间的隐秘关联。
第六件事:学会读死锁日志,让真凶现形
预防之外,排查同样重要。当死锁发生时,别只盯着应用层那句笼统的报错,MySQL 其实把死锁的详细"案发现场"都记了下来。用 SHOW ENGINE INNODB STATUS,在输出的 LATEST DETECTED DEADLOCK 段落里,你能看到死锁的两个事务分别在执行什么 SQL、各自持有什么锁、又在等什么锁——真凶一目了然。
-- 查看最近一次死锁的详细信息
SHOW ENGINE INNODB STATUS\G
-- 在输出里找 "LATEST DETECTED DEADLOCK" 段落, 它会告诉你:
-- TRANSACTION 1: 在执行哪条 SQL, 持有/等待哪个锁
-- TRANSACTION 2: 在执行哪条 SQL, 持有/等待哪个锁
-- WE ROLL BACK TRANSACTION (N): 数据库选择回滚了哪个
-- 还可以打开死锁日志, 让每次死锁都自动记进错误日志, 便于事后复盘
SET GLOBAL innodb_print_all_deadlocks = ON;
读懂这段日志,你就能精确还原"哪两条 SQL、以什么顺序、锁了哪两行"撞成了环,从而对症地去统一它们的加锁顺序。死锁不是玄学,它的现场被数据库忠实地记录着——会读这份记录,你就从"碰运气改"升级成了"按图索骥地修"。到这儿,死锁的成因、预防、应对、排查就都齐了。我把它收成一张决策图:
把这套体系建起来,死锁就从"偶发的神秘故障"变成了"可预防、可应对、可排查"的常规问题。最后,拧成几条可直接照做的铁律:
- 死锁的根源是加锁顺序不一致,统一加锁顺序(如按 ID 排序)是最根本的根治手段。
- 死锁无法 100% 避免,应用层必须捕获死锁异常并带退避地重试整个事务。
- 事务要又短又小,把网络调用、发消息等耗时操作挪到事务外,缩短持锁时间。
- 确保增删改的 WHERE 走索引,避免因锁住过多行而抬高死锁概率。
- 用
SHOW ENGINE INNODB STATUS读死锁现场,精确定位是哪两条 SQL 撞的环。 - 别靠加机器解决死锁,并发更高只会更频繁,要从加锁逻辑上根治。
- 多线程加多把锁也遵守固定顺序,这个防死锁原则在代码和数据库里是相通的。
一张死锁排查速查表
把死锁的常见成因、信号和对策汇成一张表,出问题时对照着走。
| 成因 | 典型信号 | 对策 |
|---|---|---|
| 两事务反向锁同样的行 | 死锁日志里两条 SQL 锁序相反 | 统一加锁顺序(按 ID 排序) |
| 事务太长, 持锁时间久 | 常伴随锁等待超时 | 缩短事务, 耗时操作移出 |
| update 没走索引锁一大片 | 锁的行数远超预期 | 给 WHERE 条件建索引 |
| 间隙锁(范围更新/插入)冲突 | RR 隔离级别下范围操作 | 缩小范围或调隔离级别 |
| 漏网死锁未被应对 | 用户偶发操作失败 | 捕获异常 + 退避重试 |
| 锁等待超时(非死锁) | Lock wait timeout exceeded | 同样靠短事务 + 优化锁范围 |
一个近亲:锁等待超时,以及间隙锁
和死锁经常一起出现、又容易被混淆的,是锁等待超时(Lock wait timeout exceeded)。它和死锁有本质区别:死锁是"循环等待",数据库能检测到环、立刻回滚一个;而锁等待超时是"单向等待"——A 一直占着锁不放(可能事务开了很久没提交),B 等 A 等到超过了 innodb_lock_wait_timeout 设定的时间,被迫放弃。它没有环,只是单纯地"等太久了"。两者的根治方向其实一致:缩短事务、减小锁范围、别让锁被长时间持有。
另一个进阶概念是间隙锁(Gap Lock)。在 InnoDB 默认的"可重复读(RR)"隔离级别下,为了防止幻读,范围查询/更新不仅会锁住命中的行,还会锁住行与行之间的"间隙"。这会让锁的范围比你直觉的更大,有时两个看似不冲突的范围操作,会因为间隙锁的重叠而意外死锁。这也是为什么有些死锁分析起来格外烧脑——锁的不只是"看得见的行",还有"看不见的缝"。
-- 查看/调整锁等待超时(单位秒), 让长时间的傻等更快失败
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
-- 这个值不宜过大: 太大则一笔卡住的事务会拖垮一片;
-- 也不宜过小: 太小则正常的短暂等待也被误杀
-- 排查长事务(它们是锁等待超时和死锁的常见祸首)
SELECT * FROM information_schema.innodb_trx
ORDER BY trx_started ASC; -- 看哪些事务开了很久还没结束
理解这两个近亲概念,能帮你在面对"锁相关"的报错时更精准地分类:看到 Deadlock 就往"加锁顺序"上想,看到 Lock wait timeout 就往"有长事务占着锁"上查。它们症状相似、根治方向相通,但精确区分能让你少走很多弯路。
另一种隐蔽死锁:唯一键冲突时的插入
除了转账这种"显式锁多行"的经典死锁,还有一类更隐蔽的,发生在并发插入相同唯一键时,值得警惕。设想多个事务几乎同时往一张有唯一索引的表里 INSERT 同一个值:它们会先各自拿到一些锁来做唯一性检查,当检测到冲突时,后来的插入会去等待,而这个等待若与别的事务的锁形成交叉,同样能凑成死锁。这种死锁不涉及你显式写的 SELECT FOR UPDATE,纯粹是唯一约束在背后加锁引起的,排查时格外容易让人摸不着头脑。
-- 场景:user_id 上有唯一索引, 多个事务并发插入同一个 user_id
-- 它们在唯一性检查时互相加锁等待, 可能凑成死锁
INSERT INTO user_profile (user_id, ...) VALUES (1001, ...);
-- 缓解一:用 INSERT ... ON DUPLICATE KEY UPDATE, 把"查了再插"合并成一步原子操作
INSERT INTO user_profile (user_id, score) VALUES (1001, 10)
ON DUPLICATE KEY UPDATE score = score + 10;
-- 缓解二:业务上先尽量避免并发插同一唯一键, 或对该 key 做应用层串行化
这类死锁的启示是:数据库加的锁,远不止你显式写出来的那些。唯一索引检查、外键约束、间隙锁、自增锁……很多锁是数据库为了维护约束和一致性,在你看不见的地方"自动"加上的。所以当你遇到一个"代码里明明没显式锁、却死锁了"的诡异情况时,不要急着否定死锁的可能,而要意识到——你写的 SQL 背后,数据库可能替你做了不少加锁的"隐形操作"。
应对的思路也随之清晰:一是用 INSERT ... ON DUPLICATE KEY UPDATE、REPLACE 这类把"检查 + 写入"合并为单条原子语句的写法,减少多步加锁的窗口;二是在业务层面尽量避免对同一资源的高并发竞争。理解了"锁有显式和隐式之分",你对死锁的认知才算真正完整——它不只藏在你写的 FOR UPDATE 里,也藏在每一个约束、每一次唯一性检查的背后。
写在最后
这次"转账偶发死锁"的事故,给我最深的感触,是它把"并发"这件事的反直觉展现得淋漓尽致。我写的两段转账逻辑,单独看,每一段都无懈可击、清清爽爽;可正是这样两段"各自正确"的代码,在并发的时空里以相反的顺序相遇,就酿成了谁也走不下去的死局。并发世界里,代码的正确性不再是单段逻辑的属性,而是多段逻辑相遇时的'集体属性'——你不仅要保证自己这一段对,还要操心它和别人那一段撞在一起时会发生什么。这种思维的跃迁,正是从"会写功能"到"能驾驭并发"的分水岭。
而死锁的解法——统一加锁顺序——又朴素得近乎优雅:不需要多么复杂的机制,只要大家约定好"永远按同一个顺序来",那个致命的环就再也无法闭合。这背后其实是一个深刻的工程智慧:很多并发难题,与其在事后用复杂的检测和补救去化解,不如在事前用一个简单的、全局一致的约定去预防。顺序,这个最不起眼的东西,常常就是并发安全的关键。这次教训也让我对那句老话有了更切身的体会:在并发的世界里,纪律(一致的约定)比聪明(精巧的临场处理)更可贵。愿你我在写下每一段涉及多资源加锁的代码时,都能多想一步——它会不会,和某个此刻正在别处运行的'自己',撞成一个解不开的环?
—— 别看了 · 2026