最近在项目里遇到了一次 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 也会加锁;递增值并发写二级索引页尾时,也可能死锁。
工程上不要试图幻想“完全没有死锁”,而是应该:
事务尽量短
并发写入可控
索引尽量精简
死锁必须重试这才是更稳的处理方式。