转账偶发死锁:MySQL Deadlock 成因与根治避坑

有个转账功能逻辑很简单:从账户 A 扣钱给账户 B 加钱,两步包在一个事务里,平时跑得好好的。可一到大促并发一高,日志里就零星冒出 Deadlock found when trying to get lock; try restarting transaction——死锁,受影响的转账直接失败回滚。我一开始百思不得其解:就两条 update 怎么会死锁?顺着日志把并发的两笔转账摆在一起对比才看清真相:转账是从付款方扣、给收款方加,而付款收款是用户随机指定的,于是甲给乙转(先锁甲再锁乙)与乙给甲转(先锁乙再锁甲)恰好以相反顺序去锁同样的两行,甲的事务锁住甲等着锁乙、乙的事务锁住乙等着锁甲,各持一把又都等对方手里那把,一个谁也解不开的环就这么形成了。这篇文章从这次转账偶发死锁的事故出发,讲透数据库死锁:循环等待环如何形成、统一加锁顺序按 ID 排序根治、死锁无法 100% 避免要捕获异常退避重试、让事务又短又小缩短持锁、确保 update 走索引避免锁一大片、用 SHOW ENGINE INNODB STATUS 读死锁现场,以及锁等待超时与间隙锁这两个近亲、唯一键并发插入的隐式死锁。

有个转账类的功能,逻辑很简单:从账户 A 扣钱,给账户 B 加钱,两步包在一个事务里。平时跑得好好的,可一到大促、并发一高,日志里就零星冒出一种以前没见过的报错:Deadlock found when trying to get lock; try restarting transaction——死锁。受影响的那笔转账直接失败回滚,用户重试一下大多能成,可这种"偶发失败"在财务场景里格外刺眼,而且量一大就成了规模性的告警。

我一开始百思不得其解:就两条 update,怎么会死锁?死锁不是得有"环"吗?顺着日志把并发的两笔转账摆在一起对比,真相才浮出水面。原来,转账逻辑是"从付款方扣、给收款方加",而付款方和收款方是用户随机指定的。于是当用户甲给乙转账(先锁甲、再锁乙)、与此同时用户乙给甲转账(先锁乙、再锁甲),两笔事务恰好以相反的顺序去锁同样的两行记录:甲的事务锁住了甲、正等着锁乙;乙的事务锁住了乙、正等着锁甲。双方各持一把锁、又都在等对方手里那把——一个谁也解不开的环,就这么形成了。

这就是数据库里最经典的并发难题:死锁(Deadlock)。它不像索引失效那样"稳定复现",而是只在特定的并发时序下才偶发,像个神出鬼没的幽灵。而它的成因,几乎总能归结到一句话上——多个事务以不一致的顺序去获取多把锁。这篇文章,就从这次"转账偶发死锁"的事故出发,把数据库死锁的成因、排查和根治,一次讲透。

先摆几个关于数据库锁的想当然

动手复盘前,先把我自己曾经深信、后来被死锁教育的几个念头摆出来。

想当然的念头 残酷的真相
"就两条 update,不可能死锁" 只要两个事务以相反顺序锁两行, 就能构成死锁环
"死锁了得我自己处理,很麻烦" 数据库会自动检测并回滚其中一个, 你要做的是捕获并重试
"加锁顺序无所谓,反正都要锁到" 顺序恰恰是关键, 统一加锁顺序能从根上消灭死锁
"死锁就是并发太高,加机器就好" 加机器只会让并发更高、死锁更频繁, 是火上浇油
"没走索引的 update 锁一行就够了" 没走索引会锁更多行甚至全表, 大大增加死锁概率

这些念头的共同病根,是对数据库的锁机制死锁形成的条件缺乏清晰认识,把"加锁"当成了一件不会互相冲突的孤立操作。要看清这次事故,得先理解死锁这个"环"到底是怎么形成的。

第一件事:死锁是怎么形成的——一个互相等待的环

先说锁。在 InnoDB 里,当一个事务执行 UPDATEDELETESELECT ... 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/DELETEWHERE 条件没有走索引,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、以什么顺序、锁了哪两行"撞成了环,从而对症地去统一它们的加锁顺序。死锁不是玄学,它的现场被数据库忠实地记录着——会读这份记录,你就从"碰运气改"升级成了"按图索骥地修"。到这儿,死锁的成因、预防、应对、排查就都齐了。我把它收成一张决策图:

把这套体系建起来,死锁就从"偶发的神秘故障"变成了"可预防、可应对、可排查"的常规问题。最后,拧成几条可直接照做的铁律:

  1. 死锁的根源是加锁顺序不一致,统一加锁顺序(如按 ID 排序)是最根本的根治手段。
  2. 死锁无法 100% 避免,应用层必须捕获死锁异常并带退避地重试整个事务。
  3. 事务要又短又小,把网络调用、发消息等耗时操作挪到事务外,缩短持锁时间。
  4. 确保增删改的 WHERE 走索引,避免因锁住过多行而抬高死锁概率。
  5. SHOW ENGINE INNODB STATUS 读死锁现场,精确定位是哪两条 SQL 撞的环。
  6. 别靠加机器解决死锁,并发更高只会更频繁,要从加锁逻辑上根治。
  7. 多线程加多把锁也遵守固定顺序,这个防死锁原则在代码和数据库里是相通的。

一张死锁排查速查表

把死锁的常见成因、信号和对策汇成一张表,出问题时对照着走。

成因 典型信号 对策
两事务反向锁同样的行 死锁日志里两条 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 UPDATEREPLACE 这类把"检查 + 写入"合并为单条原子语句的写法,减少多步加锁的窗口;二是在业务层面尽量避免对同一资源的高并发竞争。理解了"锁有显式和隐式之分",你对死锁的认知才算真正完整——它不只藏在你写的 FOR UPDATE 里,也藏在每一个约束、每一次唯一性检查的背后。

写在最后

这次"转账偶发死锁"的事故,给我最深的感触,是它把"并发"这件事的反直觉展现得淋漓尽致。我写的两段转账逻辑,单独看,每一段都无懈可击、清清爽爽;可正是这样两段"各自正确"的代码,在并发的时空里以相反的顺序相遇,就酿成了谁也走不下去的死局。并发世界里,代码的正确性不再是单段逻辑的属性,而是多段逻辑相遇时的'集体属性'——你不仅要保证自己这一段对,还要操心它和别人那一段撞在一起时会发生什么。这种思维的跃迁,正是从"会写功能"到"能驾驭并发"的分水岭。

而死锁的解法——统一加锁顺序——又朴素得近乎优雅:不需要多么复杂的机制,只要大家约定好"永远按同一个顺序来",那个致命的环就再也无法闭合。这背后其实是一个深刻的工程智慧:很多并发难题,与其在事后用复杂的检测和补救去化解,不如在事前用一个简单的、全局一致的约定去预防。顺序,这个最不起眼的东西,常常就是并发安全的关键。这次教训也让我对那句老话有了更切身的体会:在并发的世界里,纪律(一致的约定)比聪明(精巧的临场处理)更可贵。愿你我在写下每一段涉及多资源加锁的代码时,都能多想一步——它会不会,和某个此刻正在别处运行的'自己',撞成一个解不开的环?

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

单线程也崩:Java 遍历删除 fail-fast 异常避坑

2026-5-30 11:38:04

技术教程

下游换了 IP 我却死连旧址:JVM DNS 缓存避坑

2026-5-30 11:47:41

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索