你的 SQL 没写错,为什么还是会死锁?MySQL 锁机制实战与排查手册

2026-11-06 15 0

你有没有遇到过这种情况: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
}

三条纪律:

  1. 必须幂等——用业务唯一键 + 幂等表,别让重试变成重复扣款。
  2. 必须有退避——立刻重试大概率再撞一次,用指数退避加随机抖动。
  3. 必须有上限——重试 3 次还失败就该把问题往上抛,藏着掖着只会让故障定位更难。

方案 F:降低隔离级别(谨慎使用)

把 REPEATABLE READ 降到 READ COMMITTED,InnoDB 会禁用大部分间隙锁,死锁概率显著下降,代价是可能读到不可重复读的数据。建议先优化索引和加锁顺序,这招留到最后——很多团队一上来就降隔离,本质上是用「牺牲一致性」换「掩盖设计问题」。


六、一份可以直接抄的排查清单

线上出现死锁,按这个顺序走,别乱猜:

  1. 先止血:确认有没有长事务在拖时间,用 information_schema.innodb_trx 捞出来,必要时 kill 掉。
  2. 再看锁图:用 sys.innodb_lock_waits 或 performance_schema.data_lock_waits 找出阻塞链。
  3. 捞死锁现场:SHOW ENGINE INNODB STATUS\G 看 LATEST DETECTED DEADLOCK 段落,立刻存档,它只保留最近一次。
  4. 定位 SQL:从死锁记录里找出两条互相等待的 SQL,看它们的执行顺序是否交叉。
  5. 查索引:这两条 SQL 走的索引是什么?有没有隐式类型转换导致索引失效?
  6. 看事务边界:事务里有没有远程调用、有没有大事务、有没有先查后改的分步写法。
  7. 修,然后验证:统一加锁顺序 / 缩短事务 / 加索引 / 加重试。

还有一个容易被忽略的细节:隐式类型转换会让索引直接失效。比如字段是 VARCHAR,你却用数字 WHERE id = 123 去查,MySQL 会把每一行的字符串转成数字再比较,索引形同虚设,锁的范围随之扩大。同理,时间字段如果是整型时间戳,你却传了个字符串字面量去做比较,同样是灾难。线上排查时这条命中率相当高。


七、写在最后

死锁是并发系统的原罪,不是你代码写得多丑。但「原罪」和「每天报警三次」之间,隔着巨大的工程量。绝大多数死锁最后都能归到两条根因:加锁顺序不一致,或者 事务太长。这两条解决了,八成问题消失,剩下的靠索引设计和重试兜底。

送一句我贴在工位上的话:「死锁不可怕,可怕的是你不知道它为什么死。」 学会看 SHOW ENGINE INNODB STATUS,你就比大多数人强一半了。数据库的世界里,能读懂锁的人,永远比能写出锁的人从容。

相关文章

CPU 才 50% 却卡成狗:你的服务没算力不够,是被内核掐了脖子
你的 JSON 序列化,正在偷偷吃掉 30% 的 CPU:Go 后端的 pprof 现场
数据库没慢,是连接池在排队:一条公式算清你的池子该开多大
库存还剩 1,却卖出了 3 单:MySQL 行锁、间隙锁和一次真实死锁的现场复盘
你的 Go 服务在偷偷漏 goroutine:context 取消、channel 阻塞,以及那些看不见的泄漏
凌晨三点,所有副本同时醒来:定时任务惊群实战解剖

发布评论