avatar

超级赛亚人技术站

保持饥饿,保持战斗

  • 首页
主页 解决MYSQL间隙锁引起的死锁
文章

解决MYSQL间隙锁引起的死锁

发表于 2025-05-22 更新于 2026-02- 3
作者 Administrator
33~43 分钟 阅读

​

项目场景:

在高并发场景下,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不产生间隙锁就可以了。

方案一

  1. 去掉 SQL 中的悲观锁,不再依赖数据库的锁来控制并发

  2. 不要使用本地事务

  3. 引入Redis 分布式锁,确保 同一时间只有一个线程/实例能创建账户。

注意!使用redis分布式锁,不要把锁放在本地事务内部

在使用分布式锁的时候,习惯性的尽量缩小同步代码块的范围。 

但是如果数据库隔离级别是可重复读,这种情况下不要把分布式锁加在@Transactional注解的事务方法内部。

因为可能会出现这种情况:

线程1开启事务A后获取分布式锁,执行业务代码后在事务内释放了分布式锁。

这时候线程2开启了事务B获取到了线程1释放的分布式锁,执行查询操作时查到的数据就可能出现问题。

因为此时事务A是在事务内释放了锁,事务A本身还没有完成提交

方案二

  1. 构建0银币的对象

  2. 先插入对象,如果存在,则忽略

  3. 再查找对象返回

改成 INSERT IGNORE 写法

插入阶段:

  • InnoDB 使用 唯一索引(user_id) 查找是否冲突;

若无冲突,则加 意向插入锁 + 行锁,成功插入;

若已存在记录,InnoDB 检测到唯一冲突,IGNORE 使错误被吞掉,不会加锁,直接跳过。

查询阶段:

普通 SELECT,不加锁;

开始测试

事务A 和 B 开启事务,事务A 插入数据

d23c23aa88c042eeb91cc4262a7de2a0-WTmZ.png

​

9334772189174353bf4b8c80f5c615cf-NFOk.png

​

事务B 

插入数据

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

0e59d0dacedc4dc8887c44c50033d8bc-gjoa.png

​编辑

查看加了什么锁

131bec8578ec4776ae3b961de8516102-GyMN.png

​编辑

事务 3461 试图插入数据,但被事务 3460 持有的 RECORD X 上的锁阻塞。

死锁的情况不再发生了!

为什么换了一下顺序死锁就不再发生了?

插入不会加间隙锁!

InnoDB 在插入新记录时:

如果你直接用 INSERT INTO ...,是不会主动加间隙锁的;

它只会申请插入意图锁(插入点的间隙),但不会去和别人抢已有的间隙锁;

也就是说,两个线程如果并发插入不同 user_id 的值,或者 user_id 是唯一值,即使插入同一个值,也只是一个成功一个失败(抛唯一键冲突),不会死锁;

即使并发插入同一个 user_id,InnoDB 会根据插入意图锁进行调度,但不会像 SELECT ... FOR UPDATE 那样导致“先加锁再插入”的反向竞争死锁。

模拟并发情况

步骤

T1

T2

1

检查 user_id=200 是否存在(不加锁)

检查 user_id=200 是否存在(不加锁)

2

发现不存在 → 加插入意向锁

发现不存在 → 加插入意向锁

3

试图插入记录(user_id=200) → 获得记录锁

T1 已插入成功

4

插入成功

再次尝试插入,发现记录已存在 → 触发唯一键冲突(被忽略)

5

提交

不插入、不加记录锁,不阻塞,成功返回(但影响行数为 0)

根据上表,大多数同学已经可以理解了,但是为什么步骤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隔离级别来解决幻读的一种措施。

​

故障排查
许可协议:  CC BY 4.0
分享

相关文章

下一篇

有 MySQL 为什么还要有 MongoDB?游戏业务的主力数据库

上一篇

MySQL 间隙锁导致的死锁!全链路实战排查!

最近更新

  • RustFS 容器健康检查问题排查文档
  • RPC接口超时怎么解决?
  • 有 MySQL 为什么还要有 MongoDB?游戏业务的主力数据库
  • 解决MYSQL间隙锁引起的死锁
  • MySQL 间隙锁导致的死锁!全链路实战排查!

热门标签

故障排查

目录

©2026 超级赛亚人技术站. 保留部分权利。

使用 Halo 主题 Chirpy