分享群友使用synchronized + @Transactional 造成的线程安全问题

JAVA herman 58浏览
公告:“业余草”微信公众号提供免费CSDN下载服务(只下Java资源),关注业余草微信公众号,添加作者微信:codedq,发送下载链接帮助你免费下载!
本博客日IP超过2000,PV 3000 左右,急需赞助商。
极客时间所有课程通过我的二维码购买后返现24元微信红包,请加博主新的微信号:codedq,之前的微信号好友位已满,备注:返现
受密码保护的文章请关注“业余草”公众号,回复关键字“0”获得密码
所有面试题(java、前端、数据库、springboot等)一网打尽,请关注文末小程序
视频教程免费领

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

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

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

synchronized + @Transactional线程安全问题

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

@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,乐观锁版本控制,分布式锁,队列等。但我建议这里不要实现的太复杂,因为你这个项目本身的价值点不在这里,不要因为一个小问题就带来巨大的成本。

业余草公众号

最后,欢迎关注我的个人微信公众号:业余草(yyucao)!可加作者微信号1:xmtxtt(5000人已满),微信号2:xttblog(5000人已满),微信号3:codedq(超3800)。备注:“1”,添加博主微信拉你进微信群。备注错误不会同意好友申请。再次感谢您的关注!后续有精彩内容会第一时间发给您!原创文章投稿请发送至532009913@qq.com邮箱。商务合作也可添加作者微信进行联系!

本文原文出处:业余草: » 分享群友使用synchronized + @Transactional 造成的线程安全问题