记一次Spring里在事务和同步锁中犯的错误

先简单描述一下状况,有订单相关业务,一共有2个service类,一个操作订单简称order(Service),一个操作库存的,简称inventory(Service),在order中有A方法,是在数据库中新增订单数据的,在inventory中,有B方法,是扣除库存用的,在方法A中,会调用B,这2个service的class都加了 @Transactional 注解,情况大概是这样。

class orderService{
    public void A(){
        //保存订单信息
        saveOrder();

        //扣除库存
        B(); 

        //其他业务代码
    }
}

在B方法,扣除库存当中,会先查询该商品的库存(大概意思,实际业务中会比较多的校验),然后做对应得库存扣减,但是呢,这个B方法,是个通用(公共)方法,在仓储模块中,也会调用到这个方法,所以我非(zi)常(zuo)得(cong)意(ming)的在 B 方法上加了 synchronized 关键字,企图给这个方法加个同步锁,好在并发的时候不要扣错库存。

class inventoryService{
    //扣除订单库存方法
    public synchronized void B(String productId){
        //获取库存对象
        InventoryItem ii = findProductInventory(productId);

        //扣除库存数量
        ii.setQuantity(ii.getQuantity - 100);

        //保存库存
        updateById(ii);
    }
}

但终究还是太年轻,在运行了几个月后,最近出现了一个BUG,mysql出现了锁等待超时的报错,这一串神秘代码相信大家不会陌生的

Lock wait timeout exceeded; try restarting transaction

查了一下,阿里云的默认锁等待时间是50s,所以肯定是哪里的业务相互卡死了,并且还没有 DeadLock的级别,查了一阵子,定位到了 A、B这两个方法来了,错误的场景是对发布的接口中,同时收到了多条第三方传递过来的数据,简单描述为第三方同时发了2个请求调用了A方法下单,并且这个2个订单中都有多个明细,且有相同的商品,最终在 B 方法中的 updateById(ii); 这一行抛出异常。

同事陪我找到很久,后来终于发现问题,简单来说,应该是代码中的同步锁加错位置了,下面就简单画一下,当时发生了什么
订单1:商品P1 + 商品P2
订单2:商品P1 + 商品P2

时间 订单1 订单2 描述
13:00:00.000 进入controller 进入 controller -
13:00:00.005 进入方法A 准备循环调用B扣减P1、P2的库存
13:00:00.008 进入方法A 准备循环调用B扣减P1、P2的库存
13:00:00.010 进入方法B操作P1 等待 拿到B的同步锁
13:00:00.010 获取P1商品库存为100 等待 -
13:00:00.011 扣减P1库存 等待 调用updateById更新P1库存
获取了mysql对应得行级锁
13:00:00.015 B方法操作P1执行完 等待 释放B方法的同步锁
注意,此时订单1的事务还没有提交
13:00:00.015 操作P1剩余业务 进入方法B操作P1 订单2拿到了B方法的同步锁
13:00:00.015 试图进入方法B操作P2 获取P1的商品库存为100 因为订单1的事务还没提交,订单2拿到P1的库存还是100,而订单2的线程已经在第二次循环操作P2了
13:00:00.018 等待 扣减P1库存 订单1更新P1的库存在整个订单流程中还未结束,事务未提交,所以,订单2的updateById在等待mysql的行级锁的释放
13:00:00.018 你好了吗? 你好了吗? 因为订单2的updateById还在等待,所以B方法的同步锁并没有释放,所以,订单1的B方法进不来
13:00:50.018 - - 50秒过后,大家都GG

问题大概就是这样,怎么修复还没想好,从整个业务结构上需要修改一下,大致是因为线程1的事务还没有提交,但是有个数据更新在这个事务中,而另外线程2需要更新数据主键和线程1还未提交的一致,在等这个事务提交,却又通过同步锁卡住了主线程1,不让他提交,最后两败俱伤。

在此记录一下这个问题,以后不要再犯。

站内相关文章:

Comment ()
如果您有不同的看法,或者疑问,欢迎指教