一次由“并发插入”引发的MySQL死锁排查与最优解

一、 事故现象:并不简单的“卡顿”

周五下午16:20,报警群突然炸锅。不是简单的慢查询,而是应用端大量报错:
java.sql.BatchUpdateException: Deadlock found when trying to get lock; try restarting transaction
现状:
业务是典型的“批量插入关联数据”场景。用户上传Excel,后端解析后批量插入订单明细(order_item)表。
代码使用的是 saveBatch 方法(MyBatis-Plus),批量大小设置为 1000。
DB监控显示:Innodb_row_lock_waits 瞬间飙升,但 CPU 和 IOPS 极低。
第一时间操作:

  1. 应用端回滚至上一版本(无效,问题依旧)。
  2. 紧急联系 DBA 开启死锁监控,抓取 SHOW ENGINE INNODB STATUS

二、 排查过程:谁是凶手?

这一步是最痛苦的。DBA 给出的死锁日志像天书,我们抽丝剥茧还原了真相。

1. 死锁日志解读

日志核心片段如下(简化):

*** (1) TRANSACTION:
TRANSACTION 26621, ACTIVE 2 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1136, 2 row lock(s)
INSERT INTO order_item (order_id, sku_id, num) VALUES (100, 2001, 1), (100, 2002, 1)...
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 58 page no 4 n bits 72 index idx_order_id of table `test`.`order_item` 
*** (1) 等待持有:idx_order_id 上的 Next-key Lock
*** (2) TRANSACTION:
TRANSACTION 26622, ACTIVE 1 sec inserting
INSERT INTO order_item (order_id, sku_id, num) VALUES (101, 3001, 1), (101, 3002, 1)...
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 58 page no 4 n bits 72 index idx_order_id of table `test`.`order_item`
*** (2) 持有:idx_order_id 上的 Next-key Lock
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
*** (2) 等待持有:idx_order_id 上的 Next-key Lock

2. 核心疑点

按理说,两个事务插入不同的 order_id(一个是100,一个是101),互不干扰,应该只需要简单的行锁,为什么会死锁?
而且日志显示,双方都在争抢 idx_order_id 这个普通索引上的锁。

3. 真相还原:间隙锁的“误杀”

经过排查表结构和数据分布,发现了致命问题:

  • order_item 表有一个普通索引 idx_order_id
  • 事故发生时,表里还没有 order_id=100order_id=101 的数据。
  • 关键点:并发插入时,数据量过大触发了 MySQL 的隐式锁升级
    真实死锁链路:
  1. 事务 A 插入 order_id=100 的数据。因为是批量插入,MySQL 为了防止幻读(RR隔离级别),在 idx_order_id 索引树上不仅锁住了目标位置,还加了一个间隙锁,锁住了 (前一个ID, 100] 这个区间,防止别的事务插入。
  2. 事务 B 插入 order_id=101 的数据。同理,它也在索引树上加间隙锁。由于 100 和 101 物理位置很近,双方的间隙锁范围发生了重叠/冲突,或者一方试图扩展锁范围时被另一方阻塞。
  3. 更具体的场景是:事务 A 扫描索引,发现待插入位置有其他事务(事务 B)已经生成的排他记录锁(隐式锁转显式锁),于是等待事务 B。
  4. 事务 B 同理,也在等待事务 A 释放锁。
    结论:
    这不是简单的行锁冲突,而是 唯一索引/普通索引在并发Insert时,间隙锁(Gap Lock)碰撞导致的死锁。在高并发批量插入场景下,MySQL 默认的锁策略过于激进。

三、 最优解:三个维度的方案对比

找到原因,解决方案不能只停留在“重启服务”或“少插点数据”,必须从代码和架构层面根治。

方案一:调优 SQL(治标)

措施:减小批量插入的 Batch Size。
分析:我们将 saveBatch 的 size 从 1000 降到了 100。
效果:死锁频率下降了 90%,但偶尔还会出现。因为只要间隙锁存在,并发就有概率碰撞。这不是最优解。

方案二:数据库参数调整(不推荐)

措施:将事务隔离级别从 REPEATABLE READ (RR) 降级为 READ COMMITTED (RC)。
分析:RC 级别下,MySQL 不会加间隙锁,死锁瞬间消失。
风险:这是伤筋动骨的改动。公司核心业务依赖 RR 级别的一致性保证,且主从复制架构在 RC 下可能出现主从延迟导致的数据读取问题。为了一个 Insert 死锁修改全局隔离级别,得不偿失。

方案三:代码层改造(最优解)

措施“去并发化”与“主键规避”

  1. 由并发 Insert 改为串行化处理
    既然死锁源于并发插入索引树的竞争,那最稳的方法就是消解并发。我们在代码层面引入了 Redisson 分布式锁。
    java // 伪代码 RLock lock = redisson.getLock("order_item_insert:" + orderId); lock.lock(); try { orderItemService.saveBatch(items); } finally { lock.unlock(); }
    代价:吞吐量略微下降,但在可接受范围。
  2. 优化索引结构
    死锁的根源是 idx_order_id 普通索引。我们分析了业务,发现 order_idsku_id 是联合唯一的。
    操作:删除单列索引 idx_order_id,创建唯一联合索引 uk_order_sku (order_id, sku_id)
    原理:在唯一索引上插入确定的值,MySQL 明确知道位置,不再需要加间隙锁,只会加行锁。这从根本上消灭了间隙锁冲突的土壤。

四、 最终落地

我们最终采用了方案三的混合策略

  1. 修改索引:将容易产生间隙锁的普通索引改为联合唯一索引。
  2. 逻辑串行:对于必须高并发写入但无法加唯一约束的场景,使用分布式锁串行化写入。
    效果验证:
    上线后,Innodb_row_lock_waits 指标归零,再无死锁报警。

五、 真实复盘总结

这次事故暴露了两个深层次问题:

  1. 对框架的盲目信任:MyBatis-Plus 的 saveBatch 很方便,但开发者没意识到它本质是一条条 INSERT 语句拼接,且底层走的是 MySQL 的批量锁机制。在大数据量下,必须手动控制 Batch Size。
  2. 索引设计的随意性:开发初期为了查询方便随手加的普通索引,在高并发写入时成了性能杀手。索引不仅影响读,更影响写的锁范围
    避坑指南:
  • 批量插入数据时,Batch Size 控制在 200 以内。
  • 能用唯一索引解决的冲突,绝不要依赖普通索引加锁。
  • 死锁排查不要只看 SQL,要看 SHOW ENGINE INNODB STATUS 里的 index 字段,那是锁冲突的物理坐标。
© 版权声明
THE END
喜欢就支持一下吧
点赞12 分享
评论 共3条

请登录后发表评论

    • 头像-NodeByte吨吨吨奶茶0
    • 头像-NodeByte追光者0
    • 头像-NodeByte星河滚烫0