现象

补全翻译接口(填充空白的语料)接口执行失败,MySQL 检测到死锁快速抛异常,接口执行耗时 426ms。

无论是从接口、具体表现、根因,都与上个问题有明显区别。

image-20250715105205154

image-20250715105658957

分析

1、查看死锁日志,可以看到有两个事务,是对同一个项目的不同语言的批量插入更新操作(INSERT ON DUPLICATE KEY UPDATE)。

------------------------
LATEST DETECTED DEADLOCK
------------------------
<timestamp> <os_thread_hex>
*** (1) TRANSACTION:
TRANSACTION <trx_1>, ACTIVE 0 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 6 lock struct(s), heap size 1136, 4 row lock(s)
MySQL thread id <tid_1>, OS thread handle <handle_1>, query id <qid_1> <private-ip> <db_name> update
INSERT INTO multilingual_item (item_id, corpus_id, lang, customize,`key`,value, status, type) VALUES
          
            (<item_id_1>, <corpus_id>, 'my-MM', 'default', '下一步', '',
            0, 1)
         , 
            (<item_id_2>, <corpus_id>, 'my-MM', 'default', '分组竞争', '',
            0, 1)
         , 
            (<item_id_3>, <corpus_id>, 'my-MM', 'default', '判断对错', '',
            0, 1)
         , 
            (<item_id_4>, <corpus_id>, 'my-MM', 'default', '完成', '',
            0, 1)
         , 
            (<item_id_5>, <corpus_id>, 'my-MM', 'default', '球球拼词', '',
            0, 1)
         , 
            (<item_id_6>, <corpus_id>, 'my-MM', 'default', '知识排序', '',
            0, 1)
         , 
            (<item_id_7>, <corpus_id>, 'my-MM', 'default', '知识配对', '',
            0, 1)
         , 
            (<item_id_8>, <corpus_id>, 'my-MM', 'default', '记忆卡片', '',
            0, 1)
         , 
            (<item_id_9>, <corpus_id>, 'my-MM', 'default', '试玩', '',
            0, 1)
         , 
            (<item_id_10>, <corpus_id>, 'my-MM', 'default', '超级分类', '',
            0, 1)
         , 
            (<item_id_11>, <corpus_id>, 'my-MM', 'default', '趣味分类', '',
            0, 1)
         , 
            (<item_id_12>, <corpus_id>, 'my-MM', 'default', '趣味选择', '',
            0, 1)
         , 
            (<item_id_13>, <corpus_id>, 'my-MM', 'default', '返回', '',
            0, 1)
         , 
            (<item_id_14>, <corpus_id>, 'my-MM', 'default', '选词填空', '',
            0, 1)
         
        ON DUPLICATE KEY UPDATE status = IF(value = '', VALUES(status), status), value = IF(value = '', VALUES(value), value)
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 345 page no 93049 n bits 144 index PRIMARY of table `<db_name>`.`multilingual_item` trx id <trx_1> lock_mode X insert intention waiting
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
 0: len 8; hex 73757072656d756d; asc supremum;;

*** (2) TRANSACTION:
TRANSACTION <trx_2>, ACTIVE 0 sec inserting
mysql tables in use 1, locked 1
7 lock struct(s), heap size 1136, 4 row lock(s)
MySQL thread id <tid_2>, OS thread handle <handle_2>, query id <qid_2> <private-ip> <db_name> update
INSERT INTO multilingual_item (item_id, corpus_id, lang, customize,`key`,value, status, type) VALUES
          
            (<item_id_a1>, <corpus_id>, 'ar-EG', 'default', '下一步', '',
            0, 1)
         , 
            (<item_id_a2>, <corpus_id>, 'ar-EG', 'default', '分组竞争', '',
            0, 1)
         , 
            (<item_id_a3>, <corpus_id>, 'ar-EG', 'default', '判断对错', '',
            0, 1)
         , 
            (<item_id_a4>, <corpus_id>, 'ar-EG', 'default', '完成', '',
            0, 1)
         , 
            (<item_id_a5>, <corpus_id>, 'ar-EG', 'default', '球球拼词', '',
            0, 1)
         , 
            (<item_id_a6>, <corpus_id>, 'ar-EG', 'default', '知识排序', '',
            0, 1)
         , 
            (<item_id_a7>, <corpus_id>, 'ar-EG', 'default', '知识配对', '',
            0, 1)
         , 
            (<item_id_a8>, <corpus_id>, 'ar-EG', 'default', '记忆卡片', '',
            0, 1)
         , 
            (<item_id_a9>, <corpus_id>, 'ar-EG', 'default', '试玩', '',
            0, 1)
         , 
            (<item_id_a10>, <corpus_id>, 'ar-EG', 'default', '超级分类', '',
            0, 1)
         , 
            (<item_id_a11>, <corpus_id>, 'ar-EG', 'default', '趣味分类', '',
            0, 1)
         , 
            (<item_id_a12>, <corpus_id>, 'ar-EG', 'default', '趣味选择', '',
            0, 1)
         , 
            (<item_id_a13>, <corpus_id>, 'ar-EG', 'default', '返回', '',
            0, 1)
         , 
            (<item_id_a14>, <corpus_id>, 'ar-EG', 'default', '选词填空', '',
            0, 1)
         
        ON DUPLICATE KEY UPDATE status = IF(value = '', VALUES(status), status), value = IF(value = '', VALUES(value), value)
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 345 page no 93049 n bits 144 index PRIMARY of table `<db_name>`.`multilingual_item` trx id <trx_2> lock_mode X
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
 0: len 8; hex 73757072656d756d; asc supremum;;

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 345 page no 93049 n bits 144 index PRIMARY of table `<db_name>`.`multilingual_item` trx id <trx_2> lock_mode X insert intention waiting
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
 0: len 8; hex 73757072656d756d; asc supremum;;

*** WE ROLL BACK TRANSACTION (1)

根据死锁日志画出时序图,从下面这个时序图可以看到死锁的形成过程。

sequenceDiagram participant T1 as 事务1 (my-MM) participant DB as 数据库引擎 participant T2 as 事务2 (ar-EG) Note over T1,T2: 同时开始执行 INSERT ON DUPLICATE KEY UPDATE T1->>DB: 请求 Next-Key Lock (my-MM 范围) DB-->>T1: 授予锁 T2->>DB: 请求 Next-Key Lock (ar-EG 范围) DB-->>T2: 授予锁 Note over T1,T2: 两个事务都持有各自的范围锁 T1->>DB: 请求 Insert Intention Lock (Supremum) Note right of DB: 被 T2 的 Next-Key Lock 阻塞 T2->>DB: 请求 Insert Intention Lock (Supremum) Note right of DB: 被 T1 的 Next-Key Lock 阻塞 Note over T1,T2: 循环等待形成,死锁检测器介入 DB-->>T1: 死锁检测,回滚事务1 DB-->>T2: 事务2 继续执行

2、查看项目代码,可以看到这个接口确实有多线程并发写数据库的操作,导致了死锁。

image-20250715110745075

根因分析

1、有个明显的问题,两个事务是针对两门不同语言的插入更新,为什么会争同一个锁?我们可以看到两个事务都有一个supremum 记录锁,为什么会这样大范围加锁?

  1. supremum记录:InnoDB索引页中的虚拟最大记录(逻辑上界),代表“大于该页所有实际记录的范围”(例如,页内实际记录是id=100、200、300supremum就是>300的虚拟边界)。

image-20250715113329947

2、查看相关资料,发现官方有一个说明,原因是为了解决更严重的数据一致性问题,引入了 supremum 记录锁,同时也引入了并发死锁的问题。可以参考 https://bugs.mysql.com/bug.php?id=98324 里 Jakub Lopuszanski 给出的解释。

假设有一个表:

CREATE TABLE t (
  id INT PRIMARY KEY,
  name VARCHAR(100) UNIQUE
);

-- 初始数据
INSERT INTO t VALUES (1, 'alice');

两个并发事务,使用相同的主键值不同的唯一键值

-- 事务1
INSERT INTO t VALUES (2, 'bob') ON DUPLICATE KEY UPDATE name = 'bob_new';

-- 事务2
INSERT INTO t VALUES (2, 'carol') ON DUPLICATE KEY UPDATE name = 'carol_new';

没有 Supremum 锁时的并发问题

sequenceDiagram participant T1 as 事务1 (bob) participant DB as 数据库 participant T2 as 事务2 (carol) Note over DB: 初始: (1, 'alice') par 并发检查约束 T1->>DB: 检查 id=2 → 不存在 ✓ T2->>DB: 检查 id=2 → 不存在 ✓ end par 并发检查约束 T1->>DB: 检查 name='bob' → 不存在 ✓ T2->>DB: 检查 name='carol' → 不存在 ✓ end Note over T1,T2: 两边都认为可以插入! T2->>DB: INSERT (2, 'carol') DB-->>T2: ✓ 成功 T1->>DB: INSERT (2, 'bob') DB-->>T1: ✗ 主键冲突! Note right of DB: 但 T1 已经检查过了
id=2 不存在...

问题本质:约束检查和实际插入之间存在时间窗口,其他事务可能在这个窗口内插入数据,导致约束检查结果失效。

有 Supremum 锁时

sequenceDiagram participant T1 as 事务1 (bob) participant DB as 数据库 participant T2 as 事务2 (carol) Note over DB: 初始: (1, 'alice') T1->>DB: 请求 Supremum 锁 DB-->>T1: ✓ 获得 Note right of T1: 在锁保护下检查 T1->>DB: 检查 id=2 → 不存在 T1->>DB: 检查 name='bob' → 不存在 T1->>DB: INSERT (2, 'bob') DB-->>T1: ✓ 成功 T1->>DB: 释放锁 Note over T2: 等待中... T2->>DB: 请求 Supremum 锁 DB-->>T2: ✓ 获得 Note right of T2: 在锁保护下检查 T2->>DB: 检查 id=2 → 存在! Note right of DB: 发现主键冲突
执行 UPDATE T2->>DB: UPDATE ... WHERE id=2 DB-->>T2: ✓ 更新为 (2, 'carol_new') T2->>DB: 释放锁

MySQL 的修复策略

1、锁定所有可能的冲突点 - 不仅锁定实际冲突的记录,还锁定所有可能冲突的位置

2、引入 Supremum 记录锁 - 将"检查-执行"这个复合操作变成原子操作

3、保证约束检查的原子性 - 避免在检查和执行之间被其他事务干扰

总结

1、不是代码bug:这是 MySQL 为了修复更严重的一致性问题而引入的必要机制,所以这是预期行为,需要在应用层面处理。

2、Supremum 记录锁:所有 INSERT … ON DUPLICATE KEY UPDATE 操作都需要获取这个锁

3、死锁是副作用:为了保证数据一致性,MySQL 宁愿承受更多死锁的代价

解决方案

避免并发执行INSERT … ON DUPLICATE KEY UPDATE 语句,并发翻译所有语言,全都翻译完成之后,再合并为一个事务去写数据。