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