mybatis批量入库死锁问题分析与解决
现象多线程批量 upsert 入库时偶发数据库死锁ydmkkYxSBChkMapper.updateById (batch index #1) failed. Cause: BatchUpdateException: 死锁结论多线程并发对同一张表做批量更新且加锁顺序不一致导致循环等待。解决方案为批量写入前按主键 id 升序排序统一加锁顺序。1. 错误日志2026-10-13 06:13:26,067 ERROR [自定义任务线程130] SBChkService:84 - ydmkkYxSBChkService更新出错了 com.ydmkk.db.mapper.ydmkkYxSBChkMapper.updateById (batch index #1) failed. Cause: java.sql.BatchUpdateException: 死锁 ; 死锁; nested exception is java.sql.BatchUpdateException: 死锁 org.springframework.dao.DataIntegrityViolationException: com.ydmkk.db.mapper.ydmkkYxSBChkMapper.updateById (batch index #1) failed. Cause: java.sql.BatchUpdateException: 死锁 ; 死锁; nested exception is java.sql.BatchUpdateException: 死锁线程名自定义任务线程130→ 来自dbExecutor线程池[ExecutorConfig](file:///d:/work_space_idea_new/dxDataServer/src/main/java/com/ydmkk/threadpool/ExecutorConfig.java)core8 / max40 / queue100。batch index #1→ 该 JDBC 批次中的第 2 条更新语句发生死锁说明是一次saveOrUpdateBatch批量更新过程中被数据库判定为死锁并回滚。入库链路解析线程 → ParserConstant.CON_CURRENT_LMD_DATA_QUEUE共享队列 → AsyncTask.run() Async(dbExecutor) 每批最多 poll 3000 条 → DBTaskAssignService.invokeFunction() 按表拆分成各 List → XxxService.saveOrUpdate(list) 逐表 saveOrUpdateBatch / updateBatchByIdDBTaskThead单线程每 100ms 调一次asyncTask.run()但run()带Async每次调用都会提交到dbExecutor异步执行当单批处理耗时 100ms 时多个任务/线程会并发执行同一套入库逻辑。2. 原因分析2.1 并发 行锁持有到提交一次saveOrUpdateBatch的多个updateById会被组织成一个批次在本事务提交/回滚前被更新行的排他锁一直持有中途不释放。多个线程同时对同一张表执行批量更新如果两个批次的“行加锁顺序”不同就可能互相持有对方接下来要锁的行。2.2 为什么会形成环以两行X、Y为例X Y线程A: 锁X ................ 等Y 线程B: 锁Y .... 等XA 锁 X、B 锁 YA 要 Y被 B 占→ A 等 BB 要 X被 A 占→ B 等 A彼此等待且都不释放 →环形等待 → 死锁数据库只能杀掉其中一个本例即batch index #1处被选中回滚。批次内的行顺序由内存 List 的遍历顺序决定而 List 顺序由队列 poll 的先后决定 → 多线程之间天然不保证一致。3. 死锁原理为什么“统一顺序”能根治死锁必须同时满足 Coffman 四条件互斥、持有并等待、不可抢占、循环等待。打破任一即可。“按同一顺序加锁”ordered locking打破的是循环等待。3.1 证明统一顺序后不可能成环给所有行定义一个全局全序如按主键id升序并规定任何事务只能由小到大申请行锁。反证假设仍存在死锁等待环T0 等 T1、T1 等 T2、… 、T(n-1) 等 T0记 Ti 正在等待的行是wi由 T(i1) 持有。由于每个事务只按升序申请锁Ti 一定能先拿到比wi更小的行才会去等wi即对环上的相邻边有key(w0) key(w1) key(w2) … key(w(n-1)) key(w0)最后一步又回到起点得到key(w0) key(w0)——自相矛盾。因此这样的环构造不出来循环等待不成立 →不会死锁。直观大家都“从小到大”排队谁先拿到最小号的锁谁就能一路走完拿不到的人只会排在某个较早的锁上永远不会回头去抢比自己已持有的更靠前的锁。3.2 落到本项目把每批待 upsert 的实体按主键 id 升序排序后再saveOrUpdateBatch两个并发批次的加锁顺序就一致都按 id 递增不会出现“持有 Y 再等 X”的倒序出问题的只是同一张表内部的批次顺序跨表顺序本就固定invokeFunction中 ttc→chk→chkKc→verKc… 对所有线程一致无需处理。4. 解决方案在每个服务的主表批量写入saveOrUpdateBatch/updateBatchById前按主键id升序排序//按主键id排序统一多线程批量更新的加锁顺序避免死锁xxxList.sort(Comparator.comparing(Xxx::getId,Comparator.nullsFirst(Comparator.naturalOrder())));Comparator.nullsFirst(...)兜底避免 id 为 null 时抛 NPE。5. 注意事项与边界必须是“全序 所有写入路径都遵守”只要还有别处其它服务、DAL、脚本用不同顺序更新同一张表环仍可能重现。行锁之外还有索引锁/间隙锁/页锁排序解决的是“按主键行的环”若两事务按不同索引访问仍可能因索引锁成环。因此排序是高性价比的预防不等于 100% 免疫。兜底手段压力极大时可选对死锁异常做重试死锁是瞬时的重试通常即可成功对同一张表串行化写入单线程/单队列消费从根本消除并发交叉。尚未覆盖的同类服务同样存在多线程批量写入建议后续评估SBRealService、YxQaFileheadService、YxQaEventService以及cir包下的CirStartService/CirNetService/CirMainJcServiceCirOtherService已注释。6. 小结现象并发批量updateById触发 DM 死锁。根因多线程对同一表批量更新行锁持有到提交 加锁顺序不一致 → 循环等待。原理统一加锁顺序使事务间的等待关系构成严格递增的有向无环图从数学上排除“环”。修复批量写入前按主键 id 升序排序提交2d34e91。