最近在项目里遇到了一次 MySQL 死锁,表面上看非常奇怪:两个事务都只是往同一张表里 INSERT 数据,而且插入的 doc_id 还不一样,为什么也会死锁?

本文就以这次真实案例为例,通俗地拆一下 InnoDB 死锁日志,以及代码层面应该怎么规避。

一、问题现象

应用报错如下:

Deadlock found when trying to get lock; try restarting transaction

对应 SQL 是:

INSERT INTO knowledge_document_chunk
(kb_id, doc_id, chunk_id, chunk_index, content, char_length, metadata, created_by, updated_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)

也就是说,死锁发生在文档切片入库时。

二、死锁日志怎么看

通过:

SHOW ENGINE INNODB STATUS\G

拿到死锁信息后,关键部分是:

*** (1) TRANSACTION:
INSERT INTO knowledge_document_chunk ...
doc_id = 2019941957409554450

*** (1) HOLDS THE LOCK(S):
index idx_doc_id ... lock_mode X
Record lock ... supremum

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
index idx_doc_id ... lock_mode X insert intention waiting
Record lock ... supremum

另一个事务也类似:

*** (2) TRANSACTION:
INSERT INTO knowledge_document_chunk ...
doc_id = 2019941957409554449

*** (2) HOLDS THE LOCK(S):
index idx_doc_id ... lock_mode X
Record lock ... supremum

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
index idx_doc_id ... lock_mode X insert intention waiting
Record lock ... supremum

这说明:

两个事务都在往 knowledge_document_chunk 插入数据,并且都卡在同一个索引:

idx_doc_id

而且锁住的位置是:

supremum

三、supremum 是什么?

supremum 不是业务表里真实存在的一行数据。

它是 InnoDB 在每个索引页里维护的一个“页尾伪记录”,可以理解为:

这个索引页里最大值之后的位置

假设 idx_doc_id 某个索引页里现在大概是这样:

2019941957409554448
2019941957409554449
2019941957409554450
supremum

当新的 doc_id 是递增值时,新数据通常会插到页尾附近,也就是 supremum 前面的间隙。

这次两个 doc_id 非常接近:

2019941957409554449
2019941957409554450

所以两个事务都挤到了 idx_doc_id 的同一个索引页尾。

四、为什么两个事务都能持有 X 锁?

日志里最迷惑人的地方是:

事务 1 持有 lock_mode X
事务 2 也持有 lock_mode X

很多人第一反应是:X 锁不是排他锁吗?为什么两个事务能同时持有?

原因是,这里的 lock_mode X 并不是普通意义上“锁住某一行真实记录”的行锁。

在 supremum 上,它通常表示的是:

X 型间隙锁,也就是 gap lock / next-key lock 的一部分

间隙锁有个反直觉的特点:

多个事务可以同时持有同一个 gap 上的 X gap lock。

它的作用不是阻止别人也持有 gap lock,而是阻止别人往这个 gap 里插入数据。

可以简单分成三类理解:

普通记录 X 锁:
锁真实的一行数据,不同事务互斥

gap X 锁:
锁两条索引记录之间的间隙,多个事务可以同时持有

insert intention lock:
插入意向锁,表示我要往这个 gap 插入数据

这次的情况就是:

事务 A 持有 gap X lock
事务 B 也持有 gap X lock

事务 A 想继续插入,申请 insert intention lock,被 B 的 gap lock 挡住
事务 B 想继续插入,申请 insert intention lock,被 A 的 gap lock 挡住

于是形成死锁。

五、为什么普通 INSERT 也会有锁?

因为 INSERT 不只是往表里追加一行。

它至少要维护:

1. 聚簇索引,也就是主键索引
2. 二级索引,比如 idx_doc_id
3. 唯一索引检查,如果存在唯一索引
4. 外键检查,如果存在外键
5. 索引页结构调整

所以即使是 INSERT,也需要在对应索引位置上加锁。

本次死锁发生在二级索引:

KEY idx_doc_id (doc_id)

由于 doc_id 是递增 ID,多个并发事务会集中往这个索引的页尾插入,锁竞争自然就更明显。

六、这次死锁的根因

结合日志,可以总结为:

多个异步文档切片任务并发执行,分别往 knowledge_document_chunk 插入递增的 doc_id 数据,导致它们集中争抢 idx_doc_id 二级索引页尾的 gap / supremum 锁,最终形成死锁。

这不是因为两个事务插入了相同的 doc_id。

实际上它们的 doc_id 不同:

2019941957409554449
2019941957409554450

但因为值很接近,又都是递增插入,所以落到了同一个索引页尾附近。

七、代码层面怎么避免?

严格来说,MySQL 死锁不能保证 100% 消灭。正确做法是:

降低死锁概率 + 发生后安全重试

1. 对死锁异常做重试

MySQL 报死锁时,会主动回滚其中一个事务。业务层应该捕获并重试。

在 Spring 里,可以针对:

DeadlockLoserDataAccessException

做重试。

示例:

private void executeWithDeadlockRetry(Runnable action) {
    int maxAttempts = 3;
    long backoffMillis = 100L;

    for (int attempt = 1; attempt <= maxAttempts; attempt++) {
        try {
            action.run();
            return;
        } catch (DeadlockLoserDataAccessException e) {
            if (attempt == maxAttempts) {
                throw e;
            }

            try {
                Thread.sleep(backoffMillis + ThreadLocalRandom.current().nextLong(100));
            } catch (InterruptedException interruptedException) {
                Thread.currentThread().interrupt();
                throw e;
            }

            backoffMillis *= 2;
        }
    }
}

2. 缩短数据库事务

这次代码里有一个很重要的问题:

txTemplate.executeWithoutResult(transactionStatus -> {
    deleteIndexedChunks(...);

    milvusVectorStore.add(finalDocuments);
    keywordIndexService.indexDocuments(finalDocuments);

    saveChunkPreviews(...);
    knowledgeDocumentMapper.updateById(...);
});

也就是说,事务里不只写 MySQL,还写了 Milvus 和 Elasticsearch。

这会导致 MySQL 事务持锁时间变长。

更推荐改成:

事务外:
处理文档解析、切片、向量库、ES

事务内:
只做 MySQL 删除 chunk、插入 chunk、更新文档状态

事务越短,锁持有时间越短,死锁概率越低。

3. 控制入库并发

当前如果按 docId 加锁,只能防止同一个文档重复切片:

knowledge:chunk:lock:{docId}

但这次死锁发生在不同 docId 之间。

所以可以考虑在最终 MySQL 入库阶段增加更粗粒度的限流,比如:

同一个 kbId 下,chunk 入库串行

或者限制线程池中“数据库写入阶段”的并发数。

解析可以并发,最终入库可以低并发。

4. 批量插入替代逐条插入

如果现在是:

for (...) {
    knowledgeDocumentChunkMapper.insert(chunk);
}

可以改成批量插入:

INSERT INTO knowledge_document_chunk
(kb_id, doc_id, chunk_id, chunk_index, content, char_length, metadata, created_by, updated_by)
VALUES
(...),
(...),
(...);

建议每批 100 到 500 条,不要一次塞太大。

批量插入不能完全消除锁,但可以减少 SQL 往返和事务执行时间。

5. 检查冗余索引

表结构里有:

KEY idx_doc_id (doc_id),
KEY idx_doc_index (doc_id, chunk_index)

其中:

idx_doc_index(doc_id, chunk_index)

已经可以支持:

WHERE doc_id = ?

因为联合索引满足最左前缀原则。

所以 idx_doc_id(doc_id) 很可能是冗余索引。

而这次死锁正好发生在:

idx_doc_id

如果确认执行计划不需要它,可以考虑删除:

ALTER TABLE knowledge_document_chunk DROP INDEX idx_doc_id;

少一个索引,插入时就少维护一个 B+ 树,也少一个锁竞争点。

八、最终建议优先级

如果要按性价比排序,我会这么处理:

1. 给死锁异常加重试
2. 缩短 MySQL 事务,不要把 Milvus/ES 操作放在事务里
3. 限制 chunk 入库阶段的并发
4. 把逐条 insert 改成批量 insert
5. 检查并删除冗余索引 idx_doc_id

其中最重要的是前两点。

九、总结

这次死锁看起来像是两个普通 INSERT 莫名其妙互相锁住,但本质上是:

并发事务往同一个二级索引页尾插入递增值

导致它们在 idx_doc_id 的 supremum 位置发生 gap lock 和 insert intention lock 竞争。

一句话总结:

INSERT 也会加锁;递增值并发写二级索引页尾时,也可能死锁。

工程上不要试图幻想“完全没有死锁”,而是应该:

事务尽量短
并发写入可控
索引尽量精简
死锁必须重试

这才是更稳的处理方式。

做自己,而不是解释自己!