三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

批量更新主键排序防死锁 标准化规范(错误案例+正确案例+原理复盘)

批量更新主键排序防死锁 标准化规范(错误案例+正确案例+原理复盘)

批量更新主键排序防死锁 标准化规范(错误案例+正确案例+原理复盘)

一、核心概述

数据库批量更新、批量删除场景下,90%偶发性死锁、无规律锁超时故障,均源于「多线程事务加锁顺序不一致」。该类问题本地单线程测试100%正常,仅线上高并发场景触发,属于典型的“魔鬼细节隐形故障”。

根治核心方案:所有批量更新/批量删除,执行业务SQL前,必须按照数据表主键ID统一升序排序,强制全局锁获取顺序一致,彻底杜绝循环等待死锁。

本规范适用于:MySQL InnoDB、达梦DM8所有业务项目,统一编码标准。

二、故障底层原理(深度复盘)

2.1 锁竞争核心逻辑

InnoDB 数据库行锁特性:事务执行批量更新时,会按照待更新集合的遍历顺序逐条申请排他行锁。
成功获取一行锁就执行该行数据修改;如果某一行锁获取失败,事务会发生阻塞,已经获取到的行锁会继续保持持有,不会释放。
行锁生命周期绑定事务,所有行锁统一在事务 commit/rollback 时才释放,事务未提交前,已持有的行锁会持续阻塞其他并发事务。

行锁:逐条申请,事务结束统一释放;中间阻塞,已得锁不释放。

2.2 死锁产生的必要条件(缺一不可)

  • 多线程并发执行批量更新

  • 不同线程的待更新数据列表主键顺序不一致

  • 线程互相持有对方需要的行锁,形成循环等待

2.3 场景模拟(真实线上死锁流程)

假设存在两条数据主键:ID=140424、ID=140425,两个业务线程并发批量更新这两条数据

  • 线程A加锁顺序:140424 → 140425

  • 线程B加锁顺序:140425 → 140424

死锁执行链路

  1. 线程A 成功锁定 140424,等待锁定 140425

  2. 线程B 成功锁定 140425,等待锁定 140424

  3. 两个线程互相持有对方所需锁资源,无限等待

  4. 数据库死锁检测机制触发,强制回滚其中一个事务,业务抛出死锁异常

死锁

### 2.4 场景深度分析

场景分析

线程A上锁顺序:140424 → 140425
线程B上锁顺序:140425 → 140424

这个场景,在【应用层for循环逐条执行update】的前提下,真的会产生死锁,线上非常常见。

时序拆解(for循环逐条DML,不是单条IN)

  1. 线程A事务:拿到140424行锁;
  2. 线程B事务:拿到140425行锁;
  3. 线程A想去拿140425锁,被B持有 → A阻塞等待B释放;
  4. 线程B想去拿140424锁,被A持有 → B阻塞等待A释放;

A等B、B等A,形成循环等待,InnoDB检测到死锁,回滚其中一个事务,抛出1213错误
这就是真实线上死锁。

⚠️前提:必须是循环多次独立update语句,每条SQL只更新一行
如果是单条update xxx where id in(140424,140425),不管你in里面id书写顺序怎么写,InnoDB会按照聚簇索引从小到大顺序上锁,两个线程上锁顺序永远是140424→140425不会死锁

为什么线上这个坑特别多

  1. ID集合来源是MQ、前端、RPC返回,ID顺序完全随机
  2. 本地单线程测试,不会出现交错时序,永远复现不了;
  3. 只有高并发多线程,才会出现上面的“交叉拿锁”时序;
  4. 偶发,复现条件苛刻,属于典型“线上才出、本地好好的”隐形bug。

举个极简伪代码,就是这个故障的原型

//线程A List<Long> listA = Arrays.asList(140424L,140425L); for(Long id : listA){ updateById(id); //每条都是独立SQL } //线程B List<Long> listB = Arrays.asList(140425L,140424L); for(Long id : listB){ updateById(id); //每条都是独立SQL }

上面代码高并发跑,就有概率触发死锁。解决办法:循环前把list按主键升序排序。排序后两个线程上锁顺序完全一致,就破坏死锁的必要条件。

容易混淆的关键点总结

执行模式是否会出现截图里的死锁解决手段
Java for循环逐条update(多条独立SQL)✅会,线上高频业务层对ID集合主键升序排序
单条SQLupdate ... id in(?,?)❌不会,内核自动按主键排序上锁业务层排序ID无意义,不需要处理

补充思考

很多网上错误Demo,错误拿in()去演示这个死锁场景,这就是我们上一篇md博文勘误的那个点。
死锁模型本身没问题,但是演示工具选错了,用in()是复现不出来这个交叉锁;必须用循环多条DML。

三、错误代码案例(线上高频坑)

核心问题:直接使用上游传入的无序List批量更新,未做主键排序,完全依赖上游数据顺序,高并发必触发死锁。

/** * 【错误示例】批量更新告警数据(高危!会产生死锁) * 问题点:未排序,上游返回的list顺序随机,多线程并发锁顺序混乱 */ @Transactional(rollbackFor = Exception.class) public void batchUpdateAlarm(List<AlarmInfo> updateList) { // 直接执行批量更新,无任何排序处理 alarmInfoMapper.batchUpdate(updateList); }

错误代码风险说明

  • 前端、MQ、上游接口返回的数据列表顺序完全随机

  • 单线程测试无任何问题,无法复现bug

  • 线上高并发场景下,随机触发死锁、锁超时,日志偶发报错,极难排查

  • MySQL、达梦数据库均存在该问题,无数据库版本豁免

四、正确代码案例(生产级标准写法)

核心优化:批量操作前,强制根据数据表主键ID升序排序,统一全局加锁顺序,彻底消灭循环等待。

/** * 【正确示例】批量更新告警数据(防死锁标准写法) * 优化点:批量更新前强制主键升序排序 */ @Transactional(rollbackFor = Exception.class) public void batchUpdateAlarm(List<AlarmInfo> updateList) { // 核心必写:根据数据库主键ID 升序排序,统一全局锁顺序 updateList.sort(Comparator.comparing(AlarmInfo::getId)); // 排序后执行批量更新,杜绝死锁 alarmInfoMapper.batchUpdate(updateList); }

五、进阶增强版(生产终极方案:排序+去重+分批+降级)

适配大数据量、超高并发场景,除排序防死锁外,规避重复更新、大批量锁堆积、批量全败问题。

/** * 【生产终极方案】批量更新 防死锁+防重复+防锁堆积+异常降级 */ @Transactional(rollbackFor = Exception.class) public void safeBatchUpdateAlarm(List<AlarmInfo> updateList) { // 1. 根据主键去重(防止同事务重复锁同一行,触发约束冲突) List<AlarmInfo> distinctList = updateList.stream() .collect(Collectors.toMap(AlarmInfo::getId, Function.identity(), (k1, k2) -> k1)) .values() .stream() .collect(Collectors.toList()); // 2. 主键升序排序【防死锁核心代码】 distinctList.sort(Comparator.comparing(AlarmInfo::getId)); // 3. 小批量拆分(单次50条,避免一次性持有大量行锁,减少锁超时) List<List<AlarmInfo>> partitionList = ListUtils.partition(distinctList, 50); // 4. 分批执行,异常降级单条重试,避免整批回滚 for (List<AlarmInfo> partList : partitionList) { try { alarmInfoMapper.batchUpdate(partList); } catch (DataIntegrityViolationException | LockTimeoutException e) { // 批量冲突降级单条更新,保证大部分数据执行成功 log.warn("批量更新锁冲突,降级单条执行", e); partList.forEach(info -> { try { alarmInfoMapper.updateById(info); } catch (Exception ex) { log.error("单条告警更新失败,id:{}", info.getId(), ex); } }); } } }

六、关键知识点答疑(开发高频疑问)

6.1 为什么排序就能彻底杜绝死锁?

所有线程、所有批次统一按照主键从小到大加锁,所有人抢锁顺序完全一致,只会出现「后线程等待前线程」,不会出现双向循环等待,从算法层面直接消灭死锁必要条件。

6.2 批量新增需要排序吗?

不需要。批量新增是写入新行、加短暂行锁,不存在互相锁对方数据的场景,无死锁风险,无需排序。

6.3 批量删除需要排序吗?

必须排序。批量删除同样是逐条加行锁,锁机制和批量更新完全一致,无序删除同样会触发死锁,需按主键升序排序。

6.4 非主键唯一字段可以排序吗?

不建议。必须使用物理主键ID排序,唯一索引、业务ID无法替代,保证锁顺序绝对唯一、稳定。

七、强制编码规范(纳入代码评审卡点)

  1. 所有batchUpdate / 批量update / 批量delete代码,必须存在主键升序排序逻辑,无排序一律打回。

  2. 禁止直接使用上游原始List执行批量DML操作,必须经过排序、去重处理。

  3. 高并发业务批量操作,强制使用「排序+去重+小批量拆分+异常降级」终极方案。

  4. 该规范同时适配 MySQL、达梦DM8,全项目统一执行。

八、故障总结

批量更新死锁不属于数据库bug,是典型的编码细节缺失导致的人为故障。问题隐蔽性极强、复现难度极高、线上危害极大,仅需一行排序代码即可100%根治,是所有后端开发必须掌握的标准化细节规范。

九、场景复现

更新死锁问题

← 返回列表