文章目录
-
- [一. 项目背景](#一. 项目背景)
- [二. 分析过程](#二. 分析过程)
-
- [2.1 定位错误日志](#2.1 定位错误日志)
- [2.2 初步分析与子任务背景介绍](#2.2 初步分析与子任务背景介绍)
- [2.2 那么是不是发生并发了呢?](#2.2 那么是不是发生并发了呢?)
-
- [2.2.1 业务日志分析](#2.2.1 业务日志分析)
- [2.2.1 死锁分析](#2.2.1 死锁分析)
- [2.3 为什么发生了并发?](#2.3 为什么发生了并发?)
- [三. 处理](#三. 处理)
- [四. 后端服务优化](#四. 后端服务优化)
- [五. 其他](#五. 其他)
-
- [5.1 为什么插入排序不能100%避免批量插入的死锁? 待补充](#5.1 为什么插入排序不能100%避免批量插入的死锁? 待补充)
一. 项目背景
1. 负责某个项目的生产环境出现了数据库死锁的问题。该项目是springboot单体项目,集成了quartz调度定时任务。
国庆节第一天收到反馈,我的一个售后问题导入任务一直处于初始状态,部门老板很重视,所以下面记录下处理过程。
在这里说下该定时任务的具体情况, 该售后问题导入的任务是拆分成了5个子任务。
主任务是tb_problem_num_import_record 记录的是导入文件的信息(存储在阿里云),
第一个子任务将明细从阿里云读取出来,匹配公司其他业务的信息,然后存储到tb_problem_num_import_detail, 再修改tb_problem_num_import_record的状态。
2. 供职在一家小公司,部分操作不是那么规范,而这是问题的核心。
二. 分析过程
一切从日志开始
2.1 定位错误日志
分析日志之后,发现了2种不同的报错
1. ERROR c.h.r.s.i.ProblemCronServiceImpl - [updateRecordWithOptimisticLock,742] - 创建问题导入明细 导入记录ID:43 并发导致回滚,回滚 请下次继续。
触发点是乐观锁更新tb_problem_num_import_record时,发现版本是旧版本时,主动抛出异常,以触发事务回滚。

3. Cause: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction; Deadlock found when trying to get lock; try restarting transaction; nested exception is com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction 记录ID:43
触发点是批量插入tb_problem_num_import_detail死锁导致的。
2.2 初步分析与子任务背景介绍
两种错误日志都在表名该任务的执行发生了并发。
那么当前任务严格限制并发呢?
1. tb_problem_num_import_detail问题明细要用来生成日月年级别的进线率的问题数, 反馈率的问题数,任务重,执行流程长, 涉及到到多张表。
在这个过程中可能被一些更新,删除问题明细/更新删除售后问题分类的操作干扰,
操作多个子任务执行结果不一致(比如:日与年不一致) 所以在整个生命周期是有乐观锁做版本控制,保证数据的一致性。
2. 该定时子任务使用事务保证tb_problem_num_import_record,tb_problem_num_import_detail的数据一致性,
而tb_problem_num_import_detail的format_date索引是5个字段组成的唯一键,在2个事务大量写入数据时 会产生死锁。
2.2 那么是不是发生并发了呢?
2.2.1 业务日志分析
从子任务开发的日志以及终结日志可以看到相邻任务的生命周期没有在交叉,不存在并发。
那这个其实完全跟错误日志显示内容冲突的。

2.2.1 死锁分析
1. 死锁诊断 SHOW ENGINE INNODB STATUS;
txt
2025-10-07 14:22:01 140289037563648
*** (1) TRANSACTION:
TRANSACTION 97979832, ACTIVE 1 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 4 lock struct(s), heap size 1128, 1 row lock(s), undo log entries 1
MySQL thread id 7160065, OS thread handle 140288177653504, query id 461941933 172.24.106.186 ops_user update
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 6350 page no 35749 n bits 176 index format_date of table `hnjl`.`tb_problem_num_import_detail` trx id 97979832 lock mode S waiting
Record lock, heap no 68 PHYSICAL RECORD: n_fields 6; compact format; info bits 0
0: len 8; hex 3230323530393234; asc 20250924;;
1: len 15; hex e58585e4b88de8bf9be58ebbe794b5; asc ;;
2: len 19; hex 36393430353330353236353339353532363737; asc 6940530526539552677;;
3: len 19; hex 4b5843512d323632332d542d7863712d6c616e; asc KXCQ-2623-T-xcq-lan;;
4: len 1; hex 81; asc ;;
5: len 8; hex 800000000013540a; asc T ;;
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 6350 page no 35749 n bits 176 index format_date of table `hnjl`.`tb_problem_num_import_detail` trx id 97979832 lock mode S waiting
Record lock, heap no 68 PHYSICAL RECORD: n_fields 6; compact format; info bits 0
0: len 8; hex 3230323530393234; asc 20250924;;
1: len 15; hex e58585e4b88de8bf9be58ebbe794b5; asc ;;
2: len 19; hex 36393430353330353236353339353532363737; asc 6940530526539552677;;
3: len 19; hex 4b5843512d323632332d542d7863712d6c616e; asc KXCQ-2623-T-xcq-lan;;
4: len 1; hex 81; asc ;;
5: len 8; hex 800000000013540a; asc T ;;
*** (2) TRANSACTION:
TRANSACTION 97979827, ACTIVE 1 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 4 lock struct(s), heap size 1128, 2 row lock(s), undo log entries 1783
MySQL thread id 7157071, OS thread handle 140287595083520, query id 461941991 172.24.106.180 ops_user update
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 6350 page no 35749 n bits 176 index format_date of table `hnjl`.`tb_problem_num_import_detail` trx id 97979827 lock_mode X locks rec but not gap
Record lock, heap no 68 PHYSICAL RECORD: n_fields 6; compact format; info bits 0
0: len 8; hex 3230323530393234; asc 20250924;;
1: len 15; hex e58585e4b88de8bf9be58ebbe794b5; asc ;;
2: len 19; hex 36393430353330353236353339353532363737; asc 6940530526539552677;;
3: len 19; hex 4b5843512d323632332d542d7863712d6c616e; asc KXCQ-2623-T-xcq-lan;;
4: len 1; hex 81; asc ;;
5: len 8; hex 800000000013540a; asc T ;;
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 6350 page no 35749 n bits 176 index format_date of table `hnjl`.`tb_problem_num_import_detail` trx id 97979827 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 68 PHYSICAL RECORD: n_fields 6; compact format; info bits 0
0: len 8; hex 3230323530393234; asc 20250924;;
1: len 15; hex e58585e4b88de8bf9be58ebbe794b5; asc ;;
2: len 19; hex 36393430353330353236353339353532363737; asc 6940530526539552677;;
3: len 19; hex 4b5843512d323632332d542d7863712d6c616e; asc KXCQ-2623-T-xcq-lan;;
4: len 1; hex 81; asc ;;
5: len 8; hex 800000000013540a; asc T ;;
*** WE ROLL BACK TRANSACTION (1)
从上述信息可以看出
2. 涉及到2个事务
事务ID: 97979832 IP: 172.24.106.186
事务ID: 97979827 IP: 172.24.106.180
3. 这两个事务存在死锁
事务 | 持有锁 | 等待锁 |
---|---|---|
97979832 | 间隙锁 | 记录68的S锁 |
97979827 | 记录68的排它锁 | 等待间隙锁 |
4. 可能得执行流程如下

综上:
- 有2个实例在同时跑这个定时任务, ip分别是172.24.106.186,172.24.106.180, 是存在并发的。 (根据ip去服务器确认,果然是有2台是实例在运行)
- 业务日志之所以显示没有发生并发是因为业务日志是从一个后端实例上收集的。
2.3 为什么发生了并发?
公司这个项目是明确的单实例服务,为什么会出现2个后端服务呢?
跟运维同事确认后,发现运维同事之前搞了双实例的负载均衡,但是遇到了问题,所以放弃了负载均衡,但是没有停掉另外一个后端实例。
三. 处理
1. 关掉多出来的实例
2. 激活执行失败的任务
四. 后端服务优化
- 插入排序优化
tb_problem_num_import_detail明细列表在批量插入之前,按照唯一索引排序,使各个事务获取锁的顺序保持一致,规避循环等待。
java
private void batchSaveImportDetails(List<TbProblemNumImportDetail> detailList, TbProblemNumImportRecord earliestImportRecord) {
// 1.0 对detailList按照唯一索引排序,减少死锁的概率
detailList.sort(buildDetailComparator());
// 2.0 分小批插入减少,死锁的概率
StopWatch stopWatch = new StopWatch();
int batch = 0;
for (List<TbProblemNumImportDetail> partList : CollectionUtils.partition(detailList, MYSQL_INSERT_BATCH_SIZE)) {
batch++;
stopWatch.start("批量插入明细记录 批次:" + batch);
tbProblemNumImportDetailMapper.batchInsert(partList);
stopWatch.stop();
log.info("创建问题导入明细 导入记录ID:{} 批次:{} 批量插入耗时:{}", earliestImportRecord.getId(), batch, stopWatch.getLastTaskTimeMillis());
}
log.info("创建问题导入明细 导入记录ID:{} 导入明细数量:{} 批量插入总耗时:{}", earliestImportRecord.getId(), detailList.size(), stopWatch.getTotalTimeMillis());
}
/**
* 构建比较器
* @return
*/
private Comparator<TbProblemNumImportDetail> buildDetailComparator() {
return Comparator
.comparing(TbProblemNumImportDetail::getFormatDate, Comparator.nullsLast(String::compareTo))
.thenComparing(TbProblemNumImportDetail::getProblemName, Comparator.nullsLast(String::compareTo))
.thenComparing(TbProblemNumImportDetail::getOrderNo, Comparator.nullsLast(String::compareTo))
.thenComparing(TbProblemNumImportDetail::getSpecNo, Comparator.nullsLast(String::compareTo))
.thenComparing(TbProblemNumImportDetail::getUniqFlag, Comparator.nullsLast(Integer::compareTo));
}
- 分布式锁
加分布式锁
有点难绷: 公司不给批钱买redis, 需求非常的赶996模式下也功夫自己搭redis服务供各个服务使用。