记一次并发bug-程序锁与mysql事务的纠缠

本文记录了一次由于并发问题导致的库存记录被错误分配的bug,详细分析了问题的原因,包括非原子性的操作和未提交的事务。在定位问题后,提出了三种解决方案:调整代码结构、使用显式S锁和引入乐观锁,讨论了各自的优缺点。
摘要由CSDN通过智能技术生成

事件伊始

某天,产品经理华哥发现仓库系统里的一个库存记录被分配了两次,而两次分配后的剩余数量居然是一样的,属实震惊,当然,消息很快传到bug修复师的企业微信里,惊慌之余,就是赶紧打开代码、打开日志、打开数据库,进行漫长的排查之路

初识BUG

找到更新库存位置的代码

public Result reduce(InventoryUpdateDTO inventoryUpdateDTO) {
    //获取库存唯一码
    String uniqueCode = getUniqueCode(inventoryUpdateDTO);
    RLock lock = redissonClient.getLock(uniqueCode);
    try {
        boolean lockStatus = lock.tryLock();
        if (!lockStatus) {
            return Result.fail();
        }
        //查库存
        InventoryDTO inventoryDTO = inventoryService.get(uniqueCode);
        //修改库存记录,对库存数量进行 减一 操作
        xxx(inventoryDTO)
        //更新库存记录
        inventoryService.update(inventoryDTO);
        return Result.succeed();
    } catch (Exception e) {
        //更新库存异常,返回失败信息
        return Result.fail();
    } finally {
        //释放锁
        if (lock.isLocked()) {
            lock.unlock();
        }
    }
}

简化一下这个方法

1. 获取锁
2. 查库存
3. 修改库存
4. 更新库存到数据库
5. 释放锁

根据库存记录被分配了两次后的剩余数量一样,我们可以推测出:

  • 两次分配分别是在两个线程中执行的
  • 两次查库存查到的数据是一样的

多么典型的并发问题!

定位问题

我们先要找出为什么【两次查库存查到的数据是一样的】
一般来说有两个原因:

  • 查库存更新库存到数据库的操作不是原子性的
  • 开启了事务,且更新库存到数据库后没有提交事务
原因1

代码里其实是有使用了查库存更新库存到数据库合并成一个原子操作
并且通过查看系统日志得知,第2次库存分配获取锁是在第1次释放锁后,即两个线程是串行地执行了这个方法
所以原因1,排除

原因2

先说说为什么一个事务更新库存到数据库后没有提交,其他事务此时获取库存会是旧数据

这是因为innodb的一个特性【一致性非锁定读】
在基于事务隔离级别【repeatable read】下,有同学可能会认为

  • select会加S锁

但事实并非如此,innodb处理一般的select,并不会加锁,因为有了mvcc【多版本并发控制】,是去读取事务开始前的一个数据快照,以便增强处理并发的能力

这个方法并没有启用事务,于是我们点击到外层,果然发现问题所在

@Transactional(rollbackFor = Exception.class)
public Result allocateInventory(){
    ...
    reduce(inventoryUpdateDTO)
    //其他操作
    other(xxx);
    ...
}

事务A,在调用方法reduce()后,还要执行其他操作,所以事务A还未提交
事务B此时可以进入reduce()获得锁,读取事务B开始前的库存数据快照,与事务A读到的是一样的
假设此时最新且已提交的快照版本为001

事务A事务B
开始,确定使用快照001
获取锁
读取库存,读快照001的数据开始,确定使用快照001
更新库存,
释放锁
获取锁
读取库存,读快照001的数据
更新库存
释放锁
提交
提交

解决方案

找出了病根,我想应该有三种可行方案

方案1

reduce()挪到allocateInventory()的最下方,保证在释放锁后,事务可以立即提交
缺点:

  • 释放锁后,需要方法返回,这在jvm中是一个方法栈出栈的行为,需要时间,虽然很短,但还是存在【这个极短的时间内,另外一个事务去读取库存】的可能
方案2

reduce()的查库存显性加上S锁

select * from xxx where xxx=xxx lock in share mode;

这样就不会去读数据快照,而是等待其他有更新库存的事务提交,释放X锁后,再去加S锁并读取数据
缺点:

  • 给mysql加锁总是有风险,
  • 要理清楚事务里对于各个表更新的顺序,会不会有互逆的情况,如存在【先更新表A,再更新表B】和【先更新表B,再更新表A】,就极易发生死锁
  • 预估这里加S锁会导致线程阻塞多久,等待锁超时是会抛出异常的

庆幸的是:

就算发生了死锁、锁超时,mysql自己会检测抛出异常,然后回滚其中的某个事务,可以保证数据的一致性

方案3

reduce()的更新库存步骤,使用乐观锁,好好利用CAS机制
这应该是比较好的解决办法

各位同学有更好的方案也欢迎分享出来 (๑•̀ㅂ•́)و✧

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值