你有没有遇到过这种情况:SQL 写在纸上,挑不出毛病;日志里却隔三差五蹦出一句 Deadlock found when trying to get lock,然后事务回滚、业务报错、监控报警。你翻遍代码找不到原因,最后在群里甩了一句「谁动了我的锁?」——然后就没有然后了。
死锁不是玄学,它是并发数据库世界里一个数学上必然会发生的现象。只要你的系统存在两个以上会修改同一批数据的并发事务,冲突就迟早会来。区别只在于:高手的数据库会当场检测到并干净利落地回滚掉一个事务,把矛盾摆在明面上;而糟糕的系统会耗尽连接池、拖垮延迟,让业务在沉默中窒息。
这篇咱们不讲玄学,直接把死锁的成因、检测手段和根治方法一层层扒开。目标很朴素:让你下次遇到死锁的时候,不用靠猜。
一、先造一个稳定复现的死锁
死锁要复现,第一步是造一张「够简单」的数据表:
CREATE TABLE account (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
balance DECIMAL(10,2) NOT NULL DEFAULT 0,
KEY idx_user (user_id)
) ENGINE=InnoDB;
然后开两个会话,干一件很朴素的事:转账。只不过 A 先转 B,B 先转 A。
-- 会话 A
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 1; -- 拿到 id=1 的行锁
UPDATE account SET balance = balance + 100 WHERE id = 2; -- 等待 id=2
-- 会话 B
BEGIN;
UPDATE account SET balance = balance - 50 WHERE id = 2; -- 拿到 id=2 的行锁
UPDATE account SET balance = balance + 50 WHERE id = 1; -- 等待 id=1
两行数据,两把锁,交叉持有。第二个 UPDATE 落下的一刻,InnoDB 的死锁检测器就喊话了:ERROR 1213 (40001): Deadlock found when trying to get lock,然后自作主张地回滚掉其中一个事务。
注意,被牺牲的通常不是「后到」的那个,而是「代价更小」的那个——回滚事务量少的那个。InnoDB 会计算回滚成本,理论上越新的事务、修改越少的,越容易被选中当场牺牲。所以你会看到一个很迷惑的现象:明明是 A 先开始的,最后回滚的却是 A。这不是玄学,是成本模型在起作用。
另外,
innodb_deadlock_detect控制死锁检测,默认是 ON。当检测开启时,系统会积极地找出死锁并回滚事务;如果关掉检测,请求会一直等锁,直到超时(innodb_lock_wait_timeout,默认 50 秒)。关掉检测不是「优化」,是「把问题从 1 秒变成 50 秒」。
二、为什么两行数据也会死锁:Next-Key Lock 的锅
很多人以为「我 UPDATE 的是主键,锁的就是那一行」。这句话只对了一半。真正的原因是 InnoDB 默认的隔离级别 REPEATABLE READ,以及它配套的 Next-Key Lock 机制。
Next-Key Lock 是「记录锁 + 间隙锁」的合体。简单说:
- Record Lock(记录锁):锁住索引上已经存在的某条记录。
- Gap Lock(间隙锁):锁住记录之间的「空隙」,阻止其他事务在这个范围内插入。
- Next-Key Lock:左开右闭的区间锁,锁住记录本身 + 它前面的间隙。
间隙锁是为了防幻读:你在 WHERE user_id BETWEEN 10 AND 20 里扫到 15 条数据,别人在你读的时候插入了 user_id=18,你两次相同查询的结果集就不一样了。间隙锁把这个窗口焊死。
间隙锁是死锁的真正放大器。举个更贴近生产的例子:
-- 会话 A
BEGIN;
SELECT * FROM account WHERE user_id = 100 FOR UPDATE; -- 范围锁,锁住 (100, 200) 整段间隙
-- ...业务处理中...
-- 会话 B
BEGIN;
INSERT INTO account (id, user_id, balance) VALUES (999, 150, 0); -- 想插进 (100,200) 间隙,被 A 阻塞
-- 回到会话 A
INSERT INTO account (id, user_id, balance) VALUES (888, 150, 0); -- 也想插进 (100,200),被 B 阻塞
双方各自持有对方需要的东西,而且都要等对方先松手。这就是教科书式的循环等待。
还有一个更隐蔽的坑:锁的范围取决于你实际扫描到的索引。有索引时锁的范围可能只有那几行加相邻间隙;没索引走全表扫描,锁的范围就可能扩大到整张表——生产上被称为「锁表级联阻塞」,能把一张表的写入全部拖死。
所以「加个索引」从来不是一句轻飘飘的优化建议,它直接决定了你锁的粒度和死锁的概率。这是我见过被低估最严重的一条性能常识。
三、抓现行:怎么在第一时间看到死锁现场
死锁是「事后」的,发生后想复现只能靠运气。但 MySQL 提供了几条取证路径。
1. SHOW ENGINE INNODB STATUS —— 最新一次死锁的完整现场
SHOW ENGINE INNODB STATUS\G
LATEST DETECTED DEADLOCK 段落包含死锁涉及的两个事务、持有与等待的锁,以及那段著名的 *** WE WILL ROLL BACK TRANSACTION (1) 标记。它只保留最近一次,生产上必须定期抓取存档。
2. performance_schema —— 在线看当前的锁等待
这是排障的主力。MySQL 8.0 之后可以直接查锁图:
-- 谁在等谁,一目了然
SELECT
r.trx_id AS waiting_trx,
r.trx_mysql_thread_id AS waiting_thread,
r.trx_query AS waiting_query,
w.trx_id AS blocking_trx,
w.trx_mysql_thread_id AS blocking_thread,
w.trx_query AS blocking_query,
TIMESTAMPDIFF(SECOND, r.trx_wait_started, NOW()) AS wait_seconds
FROM performance_schema.data_lock_waits w
JOIN information_schema.innodb_trx r ON r.trx_id = w.REQUESTING_TRX_ID
JOIN information_schema.innodb_trx w2 ON w2.trx_id = w.BLOCKING_TRX_ID;
锁类型、锁住的索引记录、是否间隙锁,可查 performance_schema.data_locks。
3. sys 库:一句话看完锁等待
SELECT * FROM sys.innodb_lock_waits\G
4. 长事务:死锁的最大帮凶
SELECT trx_id, trx_state,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration,
trx_rows_locked, trx_query
FROM information_schema.innodb_trx ORDER BY trx_started;
事务跑超过 5 秒,你就已经制造了一颗定时炸弹:它持有的锁越多,别的主题撞上它的概率越高,全系统死锁率跟着飙升。
四、五种最常见的死锁姿势
姿势 1:多表更新的顺序不一致
上面的转账例子就是它。业务 A 先改订单再改库存,业务 B 先改库存再改订单,交叉即死锁。
姿势 2:范围查询后插入(间隙锁冲突)
前面那个 FOR UPDATE + INSERT 的例子。这类死锁在高并发写入 + 范围检索的场景下极常见。
姿势 3:唯一索引冲突引发的连锁
两个事务插入同一个唯一键,一个拿到锁另一个等待;若前者随后回滚且持有别的锁,等待链就绕了回来。
姿势 4:索引缺失导致锁范围爆炸
本来只需锁一行,因为没索引变成扫全表,锁住整个索引间隙。删掉索引反而变慢,也是活久见。
姿势 5:事务里塞了不该塞的东西
事务中调用远程接口、发 MQ、写文件、甚至做一次 HTTP 请求——这期间连接一直挂着,锁一直握着。事务边界越胖,死锁概率越高。
一条经验法则:事务里只做数据库操作。跨系统的动作一律放到事务提交之后,靠消息队列或补偿机制保证最终一致。这不是洁癖,这是保命。
五、根治:让死锁从「事故」降级为「事件」
方案 A:统一加锁顺序(最有效)
如果所有事务都按 ID 升序获取锁,环状等待在数学上就不可能成立。这是成本最低、收益最高的一招。
-- ❌ 随机顺序
for _, id := range ids {
db.Exec("UPDATE account SET balance = balance - ? WHERE id = ?", amt, id)
}
// ✅ 强制排序
ids := sortInts(ids)
for _, id := range ids {
db.Exec("UPDATE account SET balance = balance - ? WHERE id = ?", amt, id)
}
代价是要把所有参与方都约束成同一顺序——跨团队协调才是真正的门槛,一旦约定清楚,效果立竿见影。
方案 B:缩短事务
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance + 100 WHERE id = 2;
COMMIT; -- 提交完立刻释放全部锁
事务从 500ms 缩到 5ms,撞上别人的概率就降两个数量级。把所有耗时逻辑搬出事务,是最直接有效的优化。
方案 C:一次锁多行,而不是锁一行查一次
-- ❌ N 次往返,锁的窗口被拉得极长
for _, id := range ids {
row := db.QueryRow("SELECT balance FROM account WHERE id = ?", id).Scan(&b)
db.Exec("UPDATE account SET balance = ? WHERE id = ?", b-amt, id)
}
// ✅ 一条语句锁住一批
db.Exec("UPDATE account SET balance = balance - ? WHERE id IN (?,?,?,?)", amt, ids...)
方案 D:把范围锁换成唯一索引点查
能用唯一索引点查的,绝不用范围扫描。间隙锁消失了,循环等待的一环也就断了。
方案 E:应用层重试(兜底,不是主力)
死锁是 InnoDB 主动「牺牲弱鸡」的行为,它自己都承认这是可重试错误,错误码 1213。所以重试是官方认可的正当手段,前提是事务必须幂等。
func (s *Store) TransferWithRetry(ctx context.Context, from, to int64, amt int64) error {
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
err := s.doTransfer(ctx, from, to, amt) // 单次事务
if err == nil {
return nil
}
if isDeadlock(err) || isLockTimeout(err) { // 1213 / 1205
lastErr = err
backoff := time.Duration(1<<attempt) * 10 * time.Millisecond
select {
case <-time.After(backoff):
case <-ctx.Done():
return ctx.Err()
}
continue
}
return err
}
return lastErr
}
三条纪律:
- 必须幂等——用业务唯一键 + 幂等表,别让重试变成重复扣款。
- 必须有退避——立刻重试大概率再撞一次,用指数退避加随机抖动。
- 必须有上限——重试 3 次还失败就该把问题往上抛,藏着掖着只会让故障定位更难。
方案 F:降低隔离级别(谨慎使用)
把 REPEATABLE READ 降到 READ COMMITTED,InnoDB 会禁用大部分间隙锁,死锁概率显著下降,代价是可能读到不可重复读的数据。建议先优化索引和加锁顺序,这招留到最后——很多团队一上来就降隔离,本质上是用「牺牲一致性」换「掩盖设计问题」。
六、一份可以直接抄的排查清单
线上出现死锁,按这个顺序走,别乱猜:
- 先止血:确认有没有长事务在拖时间,用
information_schema.innodb_trx捞出来,必要时 kill 掉。 - 再看锁图:用
sys.innodb_lock_waits或performance_schema.data_lock_waits找出阻塞链。 - 捞死锁现场:
SHOW ENGINE INNODB STATUS\G看 LATEST DETECTED DEADLOCK 段落,立刻存档,它只保留最近一次。 - 定位 SQL:从死锁记录里找出两条互相等待的 SQL,看它们的执行顺序是否交叉。
- 查索引:这两条 SQL 走的索引是什么?有没有隐式类型转换导致索引失效?
- 看事务边界:事务里有没有远程调用、有没有大事务、有没有先查后改的分步写法。
- 修,然后验证:统一加锁顺序 / 缩短事务 / 加索引 / 加重试。
还有一个容易被忽略的细节:隐式类型转换会让索引直接失效。比如字段是 VARCHAR,你却用数字 WHERE id = 123 去查,MySQL 会把每一行的字符串转成数字再比较,索引形同虚设,锁的范围随之扩大。同理,时间字段如果是整型时间戳,你却传了个字符串字面量去做比较,同样是灾难。线上排查时这条命中率相当高。
七、写在最后
死锁是并发系统的原罪,不是你代码写得多丑。但「原罪」和「每天报警三次」之间,隔着巨大的工程量。绝大多数死锁最后都能归到两条根因:加锁顺序不一致,或者 事务太长。这两条解决了,八成问题消失,剩下的靠索引设计和重试兜底。
送一句我贴在工位上的话:「死锁不可怕,可怕的是你不知道它为什么死。」 学会看 SHOW ENGINE INNODB STATUS,你就比大多数人强一半了。数据库的世界里,能读懂锁的人,永远比能写出锁的人从容。