两笔转账并发执行,一笔从 A 转 B、一笔从 B 转 A,数据库突然报 Deadlock found 把其中一笔回滚了:一次数据库死锁、加锁顺序不一致循环等待的深度复盘

我的转账逻辑一个事务里更新两个账户(扣转出方、加转入方),平时好好的,可线上偶发数据库报 Deadlock found、其中一笔被回滚。把两笔并发转账的加锁顺序画出来才看明白:事务 T1 从 A 转 B 先锁 A 再要锁 B,事务 T2 从 B 转 A 先锁 B 再要锁 A——T1 锁住 A 等 B、T2 锁住 B 等 A,互相等对方释放、循环等待、谁也动不了,数据库检测到死锁就挑一个回滚。根因是两个事务以不一致的顺序(T1: A→B,T2: B→A)锁同样两行形成循环等待。这篇复盘从故障现场讲到死锁的本质循环等待、四个必要条件、为什么按业务自然顺序加锁会出问题,再到统一加锁顺序(按 id 大小固定顺序)打破循环等待、缩短事务、走索引精确锁、对被回滚的死锁事务退避重试的完整正解,以及破坏必要条件链中最可控的一环、个体正确不足以保证整体无问题多方共享需要共同的协调规则的认知。

两笔转账并发执行,一笔从 A 转 B、一笔从 B 转 A,数据库突然报 Deadlock found 把其中一笔回滚了:一次数据库死锁、加锁顺序不一致的深度复盘

那个死锁是转账业务偶发报错才暴露的:我有个转账逻辑,一个事务里要更新两个账户(扣转出方、加转入方)。功能平时好好的,可线上偶发:数据库突然报 Deadlock found when trying to get lock; try restarting transaction(发现死锁),其中一笔转账被回滚。我对着这个"偶发的死锁"查了好久,把两笔并发转账的加锁顺序在纸上画出来,才看明白,后背发凉:问题出在两个事务以"不一致的顺序"去锁同样的两行,形成了"互相等待"的死锁。设想两笔几乎同时发生的转账:事务 T1 是"从 A 转给 B",它先更新(锁住)账户 A,再要去更新账户 B;事务 T2 是"从 B 转给 A",它先更新(锁住)账户 B,再要去更新账户 A;于是:T1 锁住了 A、正等着 B;T2 锁住了 B、正等着 A——T1 在等 T2 手里的 B,T2 在等 T1 手里的 A,两个事务互相等着对方释放锁,谁也不肯先放、谁也等不到,就死锁了;数据库检测到这个循环等待,为了不让它们永远卡死,就挑一个事务"牺牲掉"(回滚它、报 Deadlock),让另一个能继续。根本原因是:多个事务以不一致的顺序去获取同一组资源(行)的锁,形成"循环等待",就会死锁。问题的根,是两个事务以不一致的加锁顺序(T1: A→B,T2: B→A)锁同样两行,互相持有对方要的锁、循环等待,形成死锁。这篇就把这次"数据库死锁"的坑,从头到尾复盘一遍。

故障现场:两个事务加锁顺序相反,互相等待

问题在于两个事务以相反的顺序锁同样两行,形成循环等待:

-- ✗ 出问题的转账: 事务里按"转出方→转入方"的顺序更新(加锁), 不同转账方向顺序就不同

-- 事务 T1: 从 A 转给 B
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 'A';  -- 1. 锁住 A
UPDATE account SET balance = balance + 100 WHERE id = 'B';  -- 2. 要锁 B(此时B被T2锁着, 等待...)
COMMIT;

-- 事务 T2: 从 B 转给 A (几乎同时)
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 'B';  -- 1. 锁住 B
UPDATE account SET balance = balance + 100 WHERE id = 'A';  -- 2. 要锁 A(此时A被T1锁着, 等待...)
COMMIT;

-- 死锁的时序:
--   T1: 锁住A → 要锁B(B被T2持有, 等待)
--   T2: 锁住B → 要锁A(A被T1持有, 等待)
--   → T1等T2的B, T2等T1的A → 互相等待、循环等待 → 死锁!
--   → 数据库检测到死锁, 回滚其中一个事务(报 Deadlock found), 另一个继续。

-- 为什么? 死锁的四个必要条件(这里关键是"循环等待"):
-- 1. 互斥: 行锁是排他的(一次只一个事务持有);
-- 2. 持有并等待: 事务持有一把锁的同时, 还在等另一把;
-- 3. 不可剥夺: 锁不能被强行抢走(只能持有者释放);
-- 4. 循环等待: T1等T2、T2等T1 → 形成环 ← 本文的直接原因。
-- 而"循环等待"的根源, 是两个事务【以不一致的顺序】获取同一组锁(T1:A→B, T2:B→A)。

-- 关键: 多个事务以不一致的顺序获取同一组资源(行)的锁, 会形成"循环等待"而死锁;
--       数据库会检测死锁并回滚一个事务 —— 加锁顺序不一致是数据库死锁的常见根源。

第一次在纸上画出"T1 锁 A 等 B、T2 锁 B 等 A,围成一个圈"时,我又懊恼又恍然:"我写转账时,很自然地按'先扣转出方、再加转入方'的业务顺序来,完全没想到不同方向的转账,加锁顺序就反了,并发起来就咬住了。"这个坑最隐蔽的地方在于:只在"两个事务恰好以相反顺序、并发锁同样的行"时才发作,偶发、依赖并发时序,低并发/串行下完全正常;而且报错是数据库抛的(Deadlock found),不是你代码的逻辑错,容易让人以为是数据库的问题下面就来拆解,死锁的成因和怎么避免。

第一件事:搞懂死锁的成因——循环等待

我顺着这次事故,把数据库死锁的成因和预防彻底理清了。

数据库死锁是怎么形成的? 怎么避免?

【核心: 死锁=多个事务循环等待对方持有的锁; 常因"加锁顺序不一致"形成环; 避免: 统一加锁顺序、减小事务/锁范围、加重试】

1. 死锁的本质: 循环等待
   - 事务A持有锁1、等锁2; 事务B持有锁2、等锁1 → 互相等对方释放 → 谁也动不了 → 死锁;
   - 更多事务也能形成环(A等B、B等C、C等A);
   - 数据库会【检测】到这个等待环, 选一个"牺牲者"回滚(报Deadlock), 打破死锁, 让其他继续。

2. 死锁的四个必要条件(经典理论):
   - 互斥、持有并等待、不可剥夺、循环等待; 破坏任一个即可避免死锁;
   - 实践中最可控的是【破坏"循环等待"】——让大家以【相同的顺序】获取锁, 就不会成环。

3. 本文的根源: 加锁顺序不一致
   - T1按 A→B 加锁, T2按 B→A 加锁 → 顺序相反 → 容易形成 T1等B、T2等A 的环;
   - 若两者都按【同一个顺序】(比如都先锁id小的), 就不会循环等待。

4. 避免/缓解死锁的手段:
   - ① 统一加锁顺序(最重要): 所有事务按【固定的、一致的顺序】访问/加锁多行(如按主键id排序后再依次更新);
     → 转账例子: 不按"转出→转入", 而按"id小的→id大的"顺序锁两个账户 → T1、T2顺序一致, 不成环;
   - ② 减小事务范围/缩短持锁时间: 事务尽量短小, 别在事务里做耗时操作; 持锁越久越容易撞上;
   - ③ 减小锁的粒度/范围: 走索引精确锁需要的行(别全表/大范围锁, 同558篇FOR UPDATE);
   - ④ 加重试: 死锁被回滚的事务, 捕获Deadlock异常后重试(死锁是偶发的, 重试常能成功);
   - ⑤ 降低隔离级别/合理设计: 必要时调整, 但优先靠加锁顺序解决。

5. 死锁 vs 锁等待超时:
   - 死锁: 循环等待, 数据库立刻检测并回滚一个(报Deadlock);
   - 锁等待超时: 没成环, 但等锁太久超过阈值(报Lock wait timeout) → 也要排查锁竞争。

一句话: 死锁=多个事务以不一致顺序加锁、形成循环等待; 数据库检测到会回滚一个; 避免的关键是【统一加锁顺序】
   (按固定顺序如id大小访问多行), 配合缩短事务、减小锁范围、对被回滚的事务加重试。

这套认知,是整个坑的根。死锁的本质:循环等待——事务 A 持有锁 1 等锁 2、事务 B 持有锁 2 等锁 1,互相等对方释放、谁也动不了;数据库会检测到等待环,选一个牺牲者回滚(报 Deadlock)打破死锁四个必要条件:互斥、持有并等待、不可剥夺、循环等待;实践中最可控的是破坏"循环等待"——让大家以相同顺序获取锁本文根源:加锁顺序不一致(T1 按 A→B、T2 按 B→A,顺序相反易成环)。避免手段:①统一加锁顺序(最重要,如都按 id 小→大锁多行)②减小事务范围/缩短持锁时间③减小锁粒度走索引精确锁④对被回滚的事务加重试(死锁偶发、重试常成功)⑤合理设计隔离级别死锁 vs 锁等待超时:死锁是循环等待立刻检测回滚、锁等待超时是没成环但等太久。一句话:死锁=多个事务以不一致顺序加锁、形成循环等待;数据库检测到会回滚一个;避免的关键是统一加锁顺序(按固定顺序如 id 大小访问多行),配合缩短事务、减小锁范围、对被回滚的事务加重试。

第二件事:正解——统一加锁顺序,配合短事务、重试

搞懂了原理,正解就清晰了:让所有事务按"固定的、一致的顺序"(如按账户 id 大小)去锁多行,打破循环等待;配合缩短事务、走索引精确锁、对被回滚的事务加重试

// ====== 正解一: 统一加锁顺序(最关键) ======
public void transfer(String from, String to, int amount) {
    // ★ 不按"转出→转入", 而是【按id排序后】依次加锁/更新, 保证所有事务顺序一致
    String first = from.compareTo(to) < 0 ? from : to;   // id小的先
    String second = from.compareTo(to) < 0 ? to : from;
    db.beginTransaction();
    try {
        // 按固定顺序(first→second)锁两行, 无论转账方向如何, 顺序都一致 → 不会循环等待
        Account a1 = db.selectForUpdate(first);   // 先锁id小的
        Account a2 = db.selectForUpdate(second);  // 再锁id大的
        // ... 在a1/a2上做扣减/增加(注意区分谁是from谁是to) ...
        db.commit();
    } catch (Exception e) {
        db.rollback();
        throw e;
    }
}
// → T1(A转B)和T2(B转A)现在都按"A→B"(假设A的id
// ====== 正解二: 对死锁(被回滚的事务)加重试 ======
public void transferWithRetry(String from, String to, int amount) {
    int maxRetry = 3;
    for (int i = 0; i < maxRetry; i++) {
        try {
            transfer(from, to, amount);
            return;   // 成功
        } catch (DeadlockException e) {   // 捕获死锁异常(被数据库回滚的那个)
            if (i == maxRetry - 1) throw e;
            sleep(randomBackoff());       // 退避一下再重试(死锁是偶发的, 重试常能成功)
        }
    }
}
# ====== 避免死锁的要点 ======
# 1. 统一加锁顺序(核心): 所有事务访问/更新多行时, 按【固定的、全局一致的顺序】(如主键id升序);
#    → 破坏"循环等待", 死锁的最根本预防;
# 2. 缩短事务: 事务尽量短小, 别在事务里做IO/RPC/耗时计算; 持锁时间越短, 撞死锁概率越低;
# 3. 走索引、精确锁: 让UPDATE/SELECT FOR UPDATE走索引只锁需要的行, 别大范围锁(减少冲突面);
# 4. 加重试: 对捕获到死锁异常(被回滚)的事务, 退避后重试(死锁偶发, 重试常成功); 是兜底而非根治;
# 5. 减少同一事务持有多把锁: 能拆分/能用原子UPDATE(同558篇)就别在事务里依次锁多行;
# 6. 监控死锁: 数据库一般有死锁日志(SHOW ENGINE INNODB STATUS等), 分析死锁的两个事务在锁什么、按什么顺序。

# 核心: 避免死锁的根本是【统一加锁顺序】(按固定顺序如id访问多行, 破坏循环等待); 配合缩短事务、
#   走索引精确锁、对被回滚的事务加重试; 死锁不可100%杜绝, 但统一顺序能消除绝大多数, 重试兜底其余。

修复的核心,是"统一加锁顺序打破循环等待,配合短事务和重试"正解一:统一加锁顺序(最关键)——不按"转出→转入",而是按 id 排序后依次加锁(id 小的先),无论转账方向顺序都一致,T1、T2 都按 A→B 锁、不成环、无死锁正解二:对死锁加重试——捕获 DeadlockException、退避后重试(死锁偶发、重试常成功)要点:统一加锁顺序(核心,破坏循环等待)、缩短事务、走索引精确锁、加重试兜底、减少同一事务持多把锁、监控死锁日志归根结底:避免死锁的根本是统一加锁顺序(按固定顺序如 id 访问多行,破坏循环等待);配合缩短事务、走索引精确锁、对被回滚的事务加重试;死锁不可 100% 杜绝,但统一顺序能消除绝大多数,重试兜底其余。

第三件事:数据库锁与事务的其他常见坑

排查后我把数据库锁、事务相关的其他坑也系统梳理了一遍。

数据库锁与事务的其他常见坑

# 1. 死锁/加锁顺序不一致(本文): 循环等待。→ 统一加锁顺序+重试。

# 2. 大事务/长事务: 持锁久、阻塞别人、回滚段大、易死锁。→ 事务短小, 别在事务里做IO/RPC。

# 3. 锁范围过大: 没走索引导致锁了大量行甚至间隙锁。→ 走索引精确锁需要的行。

# 4. 并发更新丢失(同558篇): 读改写非原子。→ 原子UPDATE/乐观锁/悲观锁。

# 5. 先查后插的并发重复: 并发都查到不存在都插入。→ 唯一索引+冲突处理(upsert/INSERT IGNORE)。

# 6. 事务里混入外部调用: 事务里发HTTP/MQ, 拉长事务且外部失败难回滚。→ 外部操作移出事务。

# 7. 忘了提交/回滚: 事务开了不结束, 连接和锁一直占着。→ 确保finally里提交或回滚。

# 8. 隔离级别理解错: 不知道当前级别能防什么(脏读/不可重复读/幻读)。→ 理解隔离级别。

# 共同根源: 数据库用"锁+事务"在并发下保证一致性, 但锁会带来"等待、冲突、死锁"; 事务持有锁的
#   "范围"和"时长"、多事务获取锁的"顺序", 共同决定了并发下会不会卡、会不会死锁; 不管控这些就会出并发问题。

# 核心: 管好事务的"锁范围、持锁时长、加锁顺序"——统一加锁顺序防死锁、事务短小减冲突、走索引缩小锁范围、
#   该原子的原子、该重试的重试; 让并发事务尽量"少争抢、不循环等待", 是数据库并发健康的关键。

排查让我把锁与事务的其他坑也梳理清了。一、死锁/加锁顺序不一致(本文)。二、大事务/长事务三、锁范围过大四、并发更新丢失五、先查后插的并发重复六、事务里混入外部调用七、忘了提交/回滚八、隔离级别理解错它们的共同根源是:数据库用"锁+事务"在并发下保证一致性,但锁会带来"等待、冲突、死锁";事务持有锁的范围和时长、多事务获取锁的顺序,共同决定了并发下会不会卡、会不会死锁;不管控这些就会出并发问题核心是:管好事务的"锁范围、持锁时长、加锁顺序"——统一加锁顺序防死锁、事务短小减冲突、走索引缩小锁范围、该原子的原子、该重试的重试;让并发事务尽量"少争抢、不循环等待",是数据库并发健康的关键下面这张图,是这次死锁坑的成因与解法:

第四件事:死锁四个必要条件与对应破解对比表

这次踩坑后,我把死锁的四个必要条件和对应的破解手段对比成一张表。

必要条件 含义 破解手段 实践可行性
互斥 锁是排他的 用无锁/乐观锁 有时可行(乐观锁)
持有并等待 持一把锁还等另一把 一次性获取所有锁 较难
不可剥夺 锁不能被强行抢 超时放弃 部分(锁超时)
循环等待 等待形成环 ★ 统一加锁顺序 最可行、最常用

这张表把破解死锁的着力点钉清了。核心是:死锁需要四个条件同时满足才发生,破坏其中任意一个就能避免;而在实践中,最容易、最有效的着力点是破坏"循环等待"——靠"统一加锁顺序"(让大家都按同一个顺序拿锁,就永远成不了环);其他三个条件(互斥/持有并等待/不可剥夺)要么难破坏、要么代价大它给我的最大启发是:面对一个"需要多个条件同时成立才会发生"的问题(死锁、某些故障、某些攻击),破解它不必逐个攻克所有条件,只需找到那个"最容易打破的环节"、破坏它一个即可;"找到必要条件链中最薄弱、最可控的一环, 集中力量破坏它",往往是解决这类'多条件触发问题'最高效的策略。这给了我一种解决复杂问题的方法论:分析一个"多因素共同导致"的问题时,先把它的"必要条件/成因"一一列出来,再评估"破坏/阻断哪一个, 成本最低、最可控、最彻底",然后集中打那一个——而不是面面俱到、什么都想管;"分解成因、找最薄弱可控的一环精准阻断",是高效解决复杂问题(而非蛮力硬刚)的关键思路认清死锁破坏任一条件即可、找最可控的环节(循环等待)精准破解——是这个坑带给我的认知。

第五件事:这次事故暴露的"按业务自然顺序"埋下的隐患

这次让我反思更深一层:我按"转出→转入"这个业务上最自然的顺序加锁,恰恰埋下了死锁。我把"按业务自然顺序"和"按统一规则顺序"对比成表。

维度 按业务自然顺序(转出→转入) 按统一规则顺序(id 小→大)
顺序由什么决定 每笔业务的方向(会变) 固定规则(不变)
不同操作间 顺序可能相反 顺序永远一致
是否易死锁 易(反向操作成环) 不会(不成环)
直觉 最自然、最先想到 要刻意设计
本质 局部视角的顺序 全局一致的顺序

这张表道出了问题的设计根源。核心是:我埋下死锁,是因为我按"每一笔业务自身最自然的顺序"(转出方先、转入方后)来加锁——这个顺序对"单笔"来说很合理,可它依赖于业务的方向,不同方向的操作顺序就相反了;而正确的做法,是采用一个"不依赖于具体业务、全局统一的顺序"(如按 id 大小);"局部看各自合理的顺序",凑在一起(并发)却因为不一致而冲突它给我的深刻启发是:当多个独立的参与者要访问/操作"共享的资源"时,如果每个参与者都按"自己局部视角下最自然的顺序"行事,这些局部顺序很可能互相不一致、进而冲突(死锁、抢占、混乱);要让它们和谐共处,往往需要一个"所有参与者都遵守的、全局一致的规则/顺序"(哪怕这个规则对某些个体不是"最自然"的);"全局的秩序, 常常需要让个体放弃局部的自然、遵守一个统一的约定"这给了我一种设计并发/协作系统的清醒:设计"多方访问共享资源"的系统时,不要让各方"各按各的自然顺序/逻辑"行事,而要为它们制定一个"全局统一的访问顺序/协议"并让所有人遵守——用"统一的约定"消除"局部各异"带来的冲突;"用全局一致的规则取代局部各异的自然做法",是让多方协作不内耗、不死锁的关键设计原则认清局部自然顺序凑一起会冲突、要用全局统一的顺序取代局部各异——是这个死锁坑带给我的认知。

第六件事:一个事务要锁多行时,我现在的自检习惯

现在每当我要在一个事务里锁/更新多行(或多个资源),我都会先按这张图问自己:

这张图的精髓,是"事务锁多行按全局统一顺序、缩短事务、加重试"会并发必须固定加锁顺序、按 id 等全局统一规则排序、再缩短事务+精确锁、兜底对死锁重试这套习惯,让我从"按业务方向自然加锁"变成了"按 id 等统一顺序加锁"——核心始终是:一个事务锁多行时,按全局统一的固定顺序(如主键 id 升序)加锁打破循环等待,配合缩短事务、走索引精确锁、对被回滚的死锁事务加重试。

我立下的几条规矩

这场"反向转账并发死锁"的事故,换来了我写数据库并发事务时,刻进骨子里的几条铁律:

  1. 死锁 = 多个事务以不一致顺序加锁、形成循环等待。数据库检测到会回滚一个。
  2. 避免死锁的核心是统一加锁顺序——按全局固定顺序(如主键 id 升序)访问多行。
  3. 别按"业务自然顺序"(如转出→转入)加锁,它依赖业务方向、会不一致。
  4. 事务尽量短小,别在事务里做 IO/RPC/耗时操作,缩短持锁时间。
  5. 走索引、精确锁需要的行,减小锁范围和冲突面。
  6. 对捕获到死锁异常(被回滚)的事务,退避后重试(死锁偶发,重试常成功)。
  7. 破坏死锁四条件中最可控的一环(循环等待);找最薄弱环节精准破解复杂问题。

写在最后

回头看,这场由"加锁顺序不一致"引发的、两笔转账互相卡死的事故,真正教给我的,远不止"统一加锁顺序"这一个技巧。它让我对"两个'各自都没错、都在合理地往前走'的个体, 仅仅因为'步调/顺序不协调', 就可能互相卡住、谁也走不了; 灾难不来自任何一方的错误, 而来自'缺乏协调'",有了一次刻骨的体会。我被这个死锁震撼,是因为它太"无辜"了——T1 想从 A 转 B,T2 想从 B 转 A,两笔转账各自的逻辑完全正确,都只是在"老老实实地、按自己的方式"完成转账;它们谁都没做错事,可仅仅因为"一个先拿 A、一个先拿 B"这个顺序上的不协调,就死死地卡住了对方——T1 攥着 A 等 B,T2 攥着 B 等 A,两个"正确"的事务,把对方逼进了谁也动不了的绝境;错的不是它们各自的行为, 而是它们之间"没有一个一致的、协调好的顺序"这让我领悟到一个关于"协调的价值"的深刻认知:当多个独立的个体要共享、争夺有限的资源时,"每个个体各自行为正确" 远不足以保证 "整体不出问题"——它们之间还需要"协调(coordination)":一套大家都遵守的、关于"谁先谁后、如何避让"的共同约定;缺了这层协调, 哪怕每个个体都"很讲理", 也可能因为"步调撞车"而集体陷入僵局;"个体的正确" 之上, 还需要 "群体的协调"这给了我一种构建多方系统的根本视角:设计任何"多个独立主体共享资源/并发协作"的系统时,不能只确保"每个主体自己的逻辑对",更要为它们之间设计好"协调机制"——一套大家共同遵守的、避免冲突和僵局的规则(统一的加锁顺序、统一的让行规则、统一的协议);"在保证个体正确之上, 用共同的协调规则避免群体的冲突与僵局",是构建一个多方能和谐高效运转、而非互相卡死的系统的核心智慧认清个体正确不足以保证整体无问题、多方共享需要共同的协调规则——这,是我用一次数据库死锁的事故,换来的、关于并发、也关于如何让多方协调共处的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次在事务里锁多行时,顺手按 id 排个序、统一加锁顺序,那我对着那两笔互相卡死的转账排查的这段时间,就值了。

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

我从 Map 里取一个计数赋给 int,平时好好的,某次那个 key 不存在、map 返回 null,自动拆箱直接抛了 NullPointerException:一次 Java 自动拆箱 NPE 的深度复盘

2026-6-2 22:23:03

技术教程

下游服务只是抖了一下,我们配的失败就重试三次反而把它彻底打死了,而且越打越死、再也起不来:一次重试风暴压垮下游、正反馈雪崩的深度复盘

2026-6-2 22:34:25

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