解决MYSQL间隙锁引起的死锁
项目场景:
在高并发场景下,MySQL InnoDB 引擎的锁机制(如间隙锁、插入意向锁、行锁等)可能会导致死锁,从而严重影响系统稳定性。本文将结合典型的先查询后插入业务场景,分析解决方案。
问题描述
先查看产生死锁的代码
@Transactional(rollbackFor = Exception.class)
public UserSilverCoinAccount getOrCreateUserSilverCoinAccount(int userId) {
UserSilverCoinAccount userSilverCoinAccount = userSilverCoinAccountMapper.getUserAccountForUpdate(userId);
if (userSilverCoinAccount == null) {
userSilverCoinAccount = new UserSilverCoinAccount().setUserId(userId).setBalance(NumberUtils.INTEGER_ZERO);
log.info("create UserSilverCoinAccount userId:{}", userId);
userSilverCoinAccountMapper.insertSelective(userSilverCoinAccount);
}
return userSilverCoinAccount;
}
原因分析:
代码产生死锁的原因
事务 A 先执行 SELECT ... FOR UPDATE,持有 (100, 300) 间隙锁。
事务 B 同时执行 SELECT ... FOR UPDATE,也持有 (100, 300) 间隙锁(非互斥)。
gap-lock 本身是非排它的,所以两笔事务都能先拿到它。
但插入新行时要把它「升级」成真正锁行(record-lock),升级操作却是排它的。
两者都持有 gap-lock,又都在等待对方释放 gap-lock,于是死锁。
具体完整分析请查看我的上一篇文章 : http://www.supersaiyan.online:8081/archives/wei-ming-ming-wen-zhang
解决方案:
我们发现大多数情况下,产生死锁的原因,大多数是因为间隙锁的原因,那我们是不是只要让mysql不产生间隙锁就可以了。
方案一
去掉 SQL 中的悲观锁,不再依赖数据库的锁来控制并发
不要使用本地事务
引入Redis 分布式锁,确保 同一时间只有一个线程/实例能创建账户。
注意!使用redis分布式锁,不要把锁放在本地事务内部
在使用分布式锁的时候,习惯性的尽量缩小同步代码块的范围。
但是如果数据库隔离级别是可重复读,这种情况下不要把分布式锁加在@Transactional注解的事务方法内部。
因为可能会出现这种情况:
线程1开启事务A后获取分布式锁,执行业务代码后在事务内释放了分布式锁。
这时候线程2开启了事务B获取到了线程1释放的分布式锁,执行查询操作时查到的数据就可能出现问题。
因为此时事务A是在事务内释放了锁,事务A本身还没有完成提交
方案二
构建0银币的对象
先插入对象,如果存在,则忽略
再查找对象返回
改成 INSERT IGNORE 写法
插入阶段:
InnoDB 使用 唯一索引(user_id) 查找是否冲突;
若无冲突,则加 意向插入锁 + 行锁,成功插入;
若已存在记录,InnoDB 检测到唯一冲突,IGNORE 使错误被吞掉,不会加锁,直接跳过。
查询阶段:
普通 SELECT,不加锁;
开始测试
事务A 和 B 开启事务,事务A 插入数据


事务B
插入数据
发现被阻塞 了,因为插入意向锁和排他锁之间是互斥的。

编辑
查看加了什么锁

编辑
事务 3461 试图插入数据,但被事务 3460 持有的 RECORD X 上的锁阻塞。
死锁的情况不再发生了!
为什么换了一下顺序死锁就不再发生了?
插入不会加间隙锁!
InnoDB 在插入新记录时:
如果你直接用
INSERT INTO ...,是不会主动加间隙锁的;它只会申请插入意图锁(插入点的间隙),但不会去和别人抢已有的间隙锁;
也就是说,两个线程如果并发插入不同 user_id 的值,或者 user_id 是唯一值,即使插入同一个值,也只是一个成功一个失败(抛唯一键冲突),不会死锁;
即使并发插入同一个 user_id,InnoDB 会根据插入意图锁进行调度,但不会像
SELECT ... FOR UPDATE那样导致“先加锁再插入”的反向竞争死锁。
模拟并发情况
根据上表,大多数同学已经可以理解了,但是为什么步骤3不是同时争夺记录锁呢?
这是由 InnoDB 的并发控制机制导致的串行化效果。
两个事务“并发”,只是用户层面发起是并行的,但数据库内部执行有先后。
数据库(尤其 InnoDB)通过各种锁来 对临界资源(如唯一键)加以保护,从而让逻辑变成“看似串行”,即:
虽然多个事务并发尝试插入同一条记录,但最终只有一个事务能成功获取插入位置的锁,其他事务被拦在插入阶段之外。
总结
这里也总结下如何尽量避免死锁发生。
1)不同的应用访问同一组表时,应尽量约定以相同的顺序访问各表。对一个表而言,应尽量以固定的顺序存取表中的行。这点真的很重要,它可以明显的减少死锁的发生。
举例:好比有a,b两张表,如果事务1先a后b,事务2先b后a,那就可能存在相互等待产生死锁。那如果事务1和事务2都先a后b,那事务1先拿到a的锁,事务2再去拿a的锁,如果锁冲突那就会等待事务1释放锁,那自然事务2就不会拿到b的锁,那就不会堵塞事务1拿到b的锁,这样就避免死锁了。
2)在主键等值更新的时候,尽量先查询看数据库中有没有满足条件的数据,如果不存在就不用更新,存在才更新。为什么要这么做呢,因为如果去更新一条数据库不存在的数据,一样会产生间隙锁。
举例:如果表中只有id=1和id=5的数据,那么如果你更新id=3的sql,因为这条记录表中不存在,那就会产生一个(1,5)的间隙锁,但其实这个锁就是多余的,因为你去更新一个数据都不存在的数据没有任何意义。
3)尽量使用主键更新数据,因为主键是唯一索引,在等值查询能查到数据的情况下只会产生行锁,不会产生间隙锁,这样产生死锁的概率就减少了。当然如果是范围查询,一样会产生间隙锁。
4)避免长事务,小事务发送锁冲突的几率也小。这点应该很好理解。
5)在允许幻读和不可重复度的情况下,尽量使用RC的隔离级别,避免gap lock造成的死锁,因为产生死锁经常都跟间隙锁有关,间隙锁的存在本身也是在RR隔离级别来解决幻读的一种措施。