高并發(fā)場景下Spring事務(wù)與JPA樂觀鎖重試機(jī)制導(dǎo)致的死鎖
環(huán)境:SpringBoot3.4.0
1. 簡介
在高并發(fā)場景中,庫存扣減是最典型的業(yè)務(wù)挑戰(zhàn)之一。當(dāng)多個線程同時爭奪同一商品的庫存資源時,稍有不慎就會引發(fā)超賣(庫存扣減為負(fù)值)或死鎖(線程相互阻塞無法推進(jìn))等嚴(yán)重問題。為解決超賣,開發(fā)者常采用JPA樂觀鎖(基于版本號機(jī)制)配合重試策略,然而這一方案在實際應(yīng)用中暗藏陷阱——若事務(wù)管理與重試邏輯設(shè)計不當(dāng),反而會觸發(fā)更隱蔽的死鎖問題。
以一段典型的Spring Data JPA代碼為例:當(dāng)多個線程同時扣減庫存時,樂觀鎖沖突會觸發(fā)ObjectOptimisticLockingFailureException,而開發(fā)者試圖通過遞歸重試恢復(fù)操作。然而,這種設(shè)計可能因以下原因?qū)е孪到y(tǒng)崩潰:
- 事務(wù)傳播機(jī)制缺陷
重試未開啟獨立事務(wù),導(dǎo)致行鎖長期未釋放,線程相互阻塞。 - JPA一級緩存干擾
未清理的緩存使重試讀取到臟數(shù)據(jù),與數(shù)據(jù)庫真實狀態(tài)不一致。 - 無限遞歸風(fēng)險
持續(xù)沖突引發(fā)棧溢出或資源耗盡。
本文將以代碼實踐為切入點,逐步分析在高并發(fā)場景下樂觀鎖引發(fā)的各種問題。
2. 實戰(zhàn)案例
2.1 準(zhǔn)備環(huán)境
準(zhǔn)備JPA對應(yīng)的實體類
@Entity
@Table(name = "c_product")
@DynamicUpdate
public class Product {
@Id
private Long id;
private String name;
private Integer stock;
@Version
private Integer version;
// getters, setters
}
c_product表準(zhǔn)備如下數(shù)據(jù)
INSERT INTO `mall`.`c_product` (`id`, `name`, `stock`, `version`)
VALUES (1, 'Spring Boot3實戰(zhàn)案例100例', 2, 1);
圖片
準(zhǔn)備Repository接口
public interface ProductRepository extends
JpaRepository<Product, Long> {
}
扣減庫存Service初始方法
@Transactional
public void deductStock(Long productId, int quantity) {
this.productRepository.findById(productId).ifPresentOrElse(p -> {
if (p.getStock() >= quantity) {
p.setStock(p.getStock() - quantity);
productRepository.save(p);
} else {
throw new RuntimeException("庫存不足");
}
}, () -> {
throw new RuntimeException("商品不存在");
});
}
在這初始扣減庫存中我們還并沒有加入重試的邏輯。
單元測試用例
@Test
public void testDeductStock() throws Exception {
final int MAX = 10 ;
CountDownLatch cdl = new CountDownLatch(MAX) ;
CyclicBarrier cb = new CyclicBarrier(MAX) ;
for (int i = 0; i < MAX; i++) {
new Thread(() -> {
try {
cb.await() ;
this.productService.deductStock(1L, 1) ;
} catch(Exception e) {
} finally {
cdl.countDown() ;
}
}, "T" + i).start() ;
}
cdl.await() ;
System.err.println("執(zhí)行完成...") ;
}
測試用例中模擬了10個線程進(jìn)行并發(fā)庫存扣減操作。
2.2 初始代碼測試
首先,我們測試初始代碼結(jié)果如下:
圖片
執(zhí)行更新動作時自動加入了version版本字段;數(shù)據(jù)庫中的數(shù)據(jù)如下:
圖片
也就是只有一個線程扣減庫存成功,其它10個線程都發(fā)生了樂觀鎖異常。可是控制臺并沒有輸出異常啊,這是因為我們在單元測試中將異常吞了,修改測試用例代碼在catch中輸出異常,如下:
圖片
控制臺輸出。
圖片
9個線程都發(fā)生了并發(fā)修改樂觀鎖異常。
思考:我們在測試用例中進(jìn)行了異常捕獲,那么我們能否在ProductService#deductStock方法中進(jìn)行捕獲呢?以當(dāng)前的代碼來看是不行的,也就是你想通過如下方式捕獲是不行的:
圖片
或者是你將deductStock方法都進(jìn)行try...catch也是不能捕獲的。因為樂觀鎖是在事務(wù)提交的時候進(jìn)行檢查的。
接下來,我們要加入樂觀鎖異常后重試機(jī)制。
2.3 樂觀鎖重試版本1
我們將代碼修改如下:
圖片
首先,我將原來的save方法修改為saveAndFlush;其次,catch捕獲了樂觀鎖異常并在其中自調(diào)用進(jìn)行重試。
將方法修改為saveAndFlush會立即將update語句發(fā)送給db進(jìn)行執(zhí)行,這樣就能捕獲到樂觀鎖異常了。
如上代碼是否能改進(jìn)行正確的庫存扣減呢?執(zhí)行測試用例輸出如下:
圖片
庫存不足,那么是不是數(shù)據(jù)庫中已經(jīng)都扣減完了?
圖片
查看數(shù)據(jù)庫,庫存還有,但控制臺確輸出的庫存不足,這又是為什么呢?我們在代碼中加入如下輸出:
圖片
再次運行,輸出結(jié)果。
圖片
這里我們以T5線程來說:
- T5線程首次查詢stock=2,versinotallow=1;
- 當(dāng)update時由于其它線程已經(jīng)修改了數(shù)據(jù),版本發(fā)生了變化,所以T5線程拋出了樂觀鎖異常;
- 接著,再次調(diào)用deductStock方法,但是并沒有執(zhí)行select語句,這是由于JPA一級緩存導(dǎo)致(同一個線程EntityManager使用的同一個),但是輸出的stock=1,versinotallow=1,這是因為update雖然沒有成功,但是我們的代碼中在上一步中確對stock進(jìn)行了減1操作。
- 繼續(xù)執(zhí)行update語句,由于已經(jīng)有線程執(zhí)行成功將version變成了2,所以這次還是拋出了樂觀鎖異常(此時,緩存中的stock進(jìn)行了2次減1操作,已經(jīng)變成了0)。
- 最后,第三次調(diào)用的時候一級緩存中的stock已經(jīng)變?yōu)?了,所以最終拋出了庫存不足;這時候的庫存不足是內(nèi)存中沒有了,可數(shù)據(jù)庫真實的數(shù)據(jù)還是有的。
以上是當(dāng)前版本1中出現(xiàn)的問題。接下來,我們將繼續(xù)修改代碼。
2.4 樂觀鎖重試版本2
通過上面的說明,我們知道了在同一個線程中查詢相同的數(shù)據(jù)第二次將會從緩存中直接返回,那是不是我們將緩存清理了就可以再次從數(shù)據(jù)庫中查詢最新的數(shù)據(jù)呢?修改代碼如下:
首先,我們注入了EntityManager對象;接著,在catch中進(jìn)行了clear清理一級緩存。
測試結(jié)果如下:
圖片
所有的線程無限循環(huán)(死循環(huán)了),并且通過輸出日志也確實每次都執(zhí)行了SQL語句,那為什么輸出的stock=1,versinotallow=1呢?先來查看數(shù)據(jù)真實數(shù)據(jù):
程序中查詢的與數(shù)據(jù)庫為什么不符?
原因很簡單,因為我們當(dāng)前事務(wù)的隔離級別是可重復(fù)讀(REPEATABLE_READ),那么每次查詢的數(shù)據(jù)都將是快照讀(事務(wù)第一次查詢到的數(shù)據(jù))。
接下來,我們將繼續(xù)修改代碼進(jìn)行第三個版本的嘗試。
2.5 樂觀鎖重試版本3
既然知道了原因,那么我們是不是修改事務(wù)的隔離級別就可以了呢?代碼修改如下:
圖片
在事務(wù)注解上將事務(wù)的隔離級別設(shè)置為讀已提交(默認(rèn)是你當(dāng)前數(shù)據(jù)庫中默認(rèn)的隔離級別)。如上修改后再次運行測試用例:
圖片
這次程序正常結(jié)束并且每次都從數(shù)據(jù)庫中查詢了最新的數(shù)據(jù),但是又多了一個異常,意思是:"事務(wù)已被標(biāo)記為僅回滾,因此已靜默回滾"。為什么?
這里我們以T3線程為例進(jìn)行分析:
- 首次執(zhí)行由于已經(jīng)被其它線程更新,所以拋出樂觀鎖異常。
- 進(jìn)入重試,重新查詢數(shù)據(jù)得到最新的stock=1,versinotallow=2,庫存大于0,進(jìn)行扣減并執(zhí)行update操作。
- T3線程進(jìn)行方法結(jié)束開始提交事務(wù),這個異常就是在提交事務(wù)時拋出的。當(dāng)?shù)谝淮伟l(fā)生樂觀鎖異常的時候JPA內(nèi)部就已經(jīng)將當(dāng)前的事務(wù)狀態(tài)設(shè)置為回滾,所以最終提交的時候當(dāng)然執(zhí)行回滾操作。
這時候又該如何解決呢?接下來,我們繼續(xù)修改代碼。
2.6 樂觀鎖重試版本4
既然知道了原因,那么我們是不是在每次重試的時候只要開啟一個新的事務(wù)就可以了呢?修改代碼如下:
圖片
在上面的代碼中,我們修改了下面3點:
- 將事務(wù)的傳播屬性設(shè)置REQUIRES_NEW,每次都開始一個新的;并且刪除了事務(wù)的隔離級別,每次都是新的也就沒有必要了。
- 刪除了clear清理緩存的操作。
- 自己注入自己調(diào)用deductStock方法,這是為了防止事務(wù)失效。
測試結(jié)果如下:
圖片
死鎖了,等待默認(rèn)的超時時間后結(jié)束,為什么?
這是因為當(dāng)我們?nèi)魏我粋€線程發(fā)生樂觀鎖異常進(jìn)入重試階段時,雖然重試時開啟的是一個新的事務(wù),但是之前的事務(wù)update操作還沒有結(jié)束(已經(jīng)將id為1的記錄鎖定了,行鎖),當(dāng)你再次開啟一個新事務(wù)執(zhí)行update操作時那必須等待前一個事務(wù)結(jié)束,前一個事務(wù)肯定結(jié)束不了,因為我們是內(nèi)部自己調(diào)用自己(遞歸調(diào)用),所以這里就產(chǎn)生了死鎖現(xiàn)象。
這該如何解決呢?接下來,我們將介紹幾種解決辦法。
2.7 樂觀鎖重試版本5
我們將代碼修改如下:
public void deductStock(Long productId, int quantity) {
this.productRepository.findById(productId).ifPresentOrElse(p -> {
if (p.getStock() >= quantity) {
p.setStock(p.getStock() - quantity);
try {
this.productRepository.saveAndFlush(p) ;
} catch (ObjectOptimisticLockingFailureException e) {
System.err.println(Thread.currentThread().getName() + " - 樂觀鎖異常, " + e.getMessage()) ;
deductStock(productId, quantity) ;
}
} else {
throw new RuntimeException("庫存不足");
}
}, () -> {
throw new RuntimeException("商品不存在");
}) ;
}
注意觀察上面的代碼,我們做了如下的修改:
- 方法上的事務(wù)注解刪除了。
- 樂觀鎖異常處理中,我們直接調(diào)用了當(dāng)前的方法。(因為當(dāng)前的方法一級不是事務(wù)方法,不需要自己注入自己進(jìn)行調(diào)用)
執(zhí)行結(jié)果如下:
圖片
數(shù)據(jù)庫庫存及版本變化:
圖片
數(shù)據(jù)也正確了。
上面的代碼中不管你用save還是saveAndFlush都是可以的。因為每一次的重試調(diào)用都是開啟的一個新事務(wù)。