错用synchronized和@Transactional被老板点名批量

业余草

共 3489字,需浏览 7分钟

 ·

2021-06-11 17:20

想不到,synchronized + @Transactional 造成的重大线程安全问题

昨天,微信群里一位小伙伴遇到了一个 synchronized + @Transactional 事务造成端午礼品分配不均的问题,最终导致数据不一致。今天我们一起来分享一下这个问题是如何产生的!

问题产生的现象是,这位小伙伴写的公司内部用的端午领礼品程序发生了“错领”问题。具体是,三只松鼠 500 箱,粽子 500 箱,粗粮 67 箱。公司一共 1067 人,通过程序去抢这些礼品。结果发现领导三只松鼠的人却超过 500 人。

该网友的具体实现代码我通过脱敏后,抽出核心代码,如下所示:

@Transactional(rollbackFor = ServiceException.class)
public void saveGiftTicket(GiftTicket giftTicket
{
    synchronized (this.class{
        // 检查对应礼品是否有剩余
        preCheckGiftTicket(giftTicket);
  
        // 扣减礼品
        modifyGiftTicketAmount(giftTicket);
    }
}

上面代码存在的问题我简单描述一下:

当对应分类的礼品剩余为 1 时,线程 A 拿到锁进入同步代码块,扣减礼品,线程 B 等待锁;当线程 A 执行完同步代码块时,线程 B 拿到锁,执行同步代码块,检查到剩余的礼品仍为 1 (此时,剩余礼品应该为 0,preCheckGiftTicket 方法应该抛出异常),于是也进行了扣减礼品;最终导致超过了 500 人领取到了三只松鼠。

很多人也都写过这种代码,也包括我的同事。只不过他的 synchronized 换了一个位置:

@Transactional(rollbackFor = ServiceException.class)
public synchronized void saveGiftTicket(GiftTicket giftTicket
{
    // 检查对应礼品是否有剩余
    preCheckGiftTicket(giftTicket);

    // 扣减礼品
    modifyGiftTicketAmount(giftTicket);
}

这些代码都是有问题的,一般还不容易发现。因为并发并不是很大,并没有一般电商项目的并发高,本身用户量也不大,还是一个单体应用,既有锁,又有事务。

当初这位网友遇到问题后,我看了他的代码,大概就猜出问题所在了。我让他写了单元测试,使用 CountDownLatch 来重现问题。

CountDownLatch countDownLatch = new CountDownLatch(1);
for (int i = 0; i < 888; i++) {
    new Thread(() -> {
        try {
            countDownLatch.await();
            String parter = "【" + Thread.currentThread().getName() + "】";
            System.out.println(parter + "开始执行……");
            GiftTicket giftTicket = new GiftTicket();
            saveGiftTicket(giftTicket);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }).start();
}

Thread.sleep(2000);
// 抢礼品
countDownLatch.countDown();

重现问题后,他问我为什么锁没有用,为什么 @Transactional 事务不起作用?

我在群里告诉他,根本原因是:@Transactional 事务时 Spring 通过 AOP 实现的,当我们调用 saveGiftTicket 方法后,在 saveGiftTicket 方法执行之前 Spring 就会开启事务,之后会有提交事务逻辑。而 synchronized 代码块执行是在事务之内执行的,当 synchronized 代码块执行完后,事务还未提交,其他线程进入 synchronized 代码块后,读取的数据不是最新的。

所以,解决这类问题就是要把 @Transactional 和 synchronized 理解清楚。要么不加锁,要么让锁的范围比事务大。当然如果能用无锁代码来实现这个功能时更好的。比如,我下面这个 SQL 就可以解决这个业务。

update xttblog_gift set amount = amount - 1 where gift_type = '三只松鼠' AND amount > 0;

或者使用悲观锁 for update,乐观锁版本控制,分布式锁,队列等。但我建议这里不要实现的太复杂,因为你这个项目本身的价值点不在这里,不要因为一个小问题就带来巨大的成本。

浏览 28
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报