高并發場景下Spring事務與JPA樂觀鎖重試機制導致的死鎖
環境:SpringBoot3.4.0
1. 簡介
在高并發場景中,庫存扣減是最典型的業務挑戰之一。當多個線程同時爭奪同一商品的庫存資源時,稍有不慎就會引發超賣(庫存扣減為負值)或死鎖(線程相互阻塞無法推進)等嚴重問題。為解決超賣,開發者常采用JPA樂觀鎖(基于版本號機制)配合重試策略,然而這一方案在實際應用中暗藏陷阱——若事務管理與重試邏輯設計不當,反而會觸發更隱蔽的死鎖問題。
以一段典型的Spring Data JPA代碼為例:當多個線程同時扣減庫存時,樂觀鎖沖突會觸發ObjectOptimisticLockingFailureException,而開發者試圖通過遞歸重試恢復操作。然而,這種設計可能因以下原因導致系統崩潰:
- 事務傳播機制缺陷
重試未開啟獨立事務,導致行鎖長期未釋放,線程相互阻塞。 - JPA一級緩存干擾
未清理的緩存使重試讀取到臟數據,與數據庫真實狀態不一致。 - 無限遞歸風險
持續沖突引發棧溢出或資源耗盡。
本文將以代碼實踐為切入點,逐步分析在高并發場景下樂觀鎖引發的各種問題。
2. 實戰案例
2.1 準備環境
準備JPA對應的實體類
@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表準備如下數據
INSERT INTO `mall`.`c_product` (`id`, `name`, `stock`, `version`)
VALUES (1, 'Spring Boot3實戰案例100例', 2, 1);
圖片
準備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("執行完成...") ;
}
測試用例中模擬了10個線程進行并發庫存扣減操作。
2.2 初始代碼測試
首先,我們測試初始代碼結果如下:
圖片
執行更新動作時自動加入了version版本字段;數據庫中的數據如下:
圖片
也就是只有一個線程扣減庫存成功,其它10個線程都發生了樂觀鎖異常。可是控制臺并沒有輸出異常啊,這是因為我們在單元測試中將異常吞了,修改測試用例代碼在catch中輸出異常,如下:
圖片
控制臺輸出。
圖片
9個線程都發生了并發修改樂觀鎖異常。
思考:我們在測試用例中進行了異常捕獲,那么我們能否在ProductService#deductStock方法中進行捕獲呢?以當前的代碼來看是不行的,也就是你想通過如下方式捕獲是不行的:
圖片
或者是你將deductStock方法都進行try...catch也是不能捕獲的。因為樂觀鎖是在事務提交的時候進行檢查的。
接下來,我們要加入樂觀鎖異常后重試機制。
2.3 樂觀鎖重試版本1
我們將代碼修改如下:
圖片
首先,我將原來的save方法修改為saveAndFlush;其次,catch捕獲了樂觀鎖異常并在其中自調用進行重試。
將方法修改為saveAndFlush會立即將update語句發送給db進行執行,這樣就能捕獲到樂觀鎖異常了。
如上代碼是否能改進行正確的庫存扣減呢?執行測試用例輸出如下:
圖片
庫存不足,那么是不是數據庫中已經都扣減完了?
圖片
查看數據庫,庫存還有,但控制臺確輸出的庫存不足,這又是為什么呢?我們在代碼中加入如下輸出:
圖片
再次運行,輸出結果。
圖片
這里我們以T5線程來說:
- T5線程首次查詢stock=2,versinotallow=1;
- 當update時由于其它線程已經修改了數據,版本發生了變化,所以T5線程拋出了樂觀鎖異常;
- 接著,再次調用deductStock方法,但是并沒有執行select語句,這是由于JPA一級緩存導致(同一個線程EntityManager使用的同一個),但是輸出的stock=1,versinotallow=1,這是因為update雖然沒有成功,但是我們的代碼中在上一步中確對stock進行了減1操作。
- 繼續執行update語句,由于已經有線程執行成功將version變成了2,所以這次還是拋出了樂觀鎖異常(此時,緩存中的stock進行了2次減1操作,已經變成了0)。
- 最后,第三次調用的時候一級緩存中的stock已經變為0了,所以最終拋出了庫存不足;這時候的庫存不足是內存中沒有了,可數據庫真實的數據還是有的。
以上是當前版本1中出現的問題。接下來,我們將繼續修改代碼。
2.4 樂觀鎖重試版本2
通過上面的說明,我們知道了在同一個線程中查詢相同的數據第二次將會從緩存中直接返回,那是不是我們將緩存清理了就可以再次從數據庫中查詢最新的數據呢?修改代碼如下:
首先,我們注入了EntityManager對象;接著,在catch中進行了clear清理一級緩存。
測試結果如下:
圖片
所有的線程無限循環(死循環了),并且通過輸出日志也確實每次都執行了SQL語句,那為什么輸出的stock=1,versinotallow=1呢?先來查看數據真實數據:
程序中查詢的與數據庫為什么不符?
原因很簡單,因為我們當前事務的隔離級別是可重復讀(REPEATABLE_READ),那么每次查詢的數據都將是快照讀(事務第一次查詢到的數據)。
接下來,我們將繼續修改代碼進行第三個版本的嘗試。
2.5 樂觀鎖重試版本3
既然知道了原因,那么我們是不是修改事務的隔離級別就可以了呢?代碼修改如下:
圖片
在事務注解上將事務的隔離級別設置為讀已提交(默認是你當前數據庫中默認的隔離級別)。如上修改后再次運行測試用例:
圖片
這次程序正常結束并且每次都從數據庫中查詢了最新的數據,但是又多了一個異常,意思是:"事務已被標記為僅回滾,因此已靜默回滾"。為什么?
這里我們以T3線程為例進行分析:
- 首次執行由于已經被其它線程更新,所以拋出樂觀鎖異常。
- 進入重試,重新查詢數據得到最新的stock=1,versinotallow=2,庫存大于0,進行扣減并執行update操作。
- T3線程進行方法結束開始提交事務,這個異常就是在提交事務時拋出的。當第一次發生樂觀鎖異常的時候JPA內部就已經將當前的事務狀態設置為回滾,所以最終提交的時候當然執行回滾操作。
這時候又該如何解決呢?接下來,我們繼續修改代碼。
2.6 樂觀鎖重試版本4
既然知道了原因,那么我們是不是在每次重試的時候只要開啟一個新的事務就可以了呢?修改代碼如下:
圖片
在上面的代碼中,我們修改了下面3點:
- 將事務的傳播屬性設置REQUIRES_NEW,每次都開始一個新的;并且刪除了事務的隔離級別,每次都是新的也就沒有必要了。
- 刪除了clear清理緩存的操作。
- 自己注入自己調用deductStock方法,這是為了防止事務失效。
測試結果如下:
圖片
死鎖了,等待默認的超時時間后結束,為什么?
這是因為當我們任何一個線程發生樂觀鎖異常進入重試階段時,雖然重試時開啟的是一個新的事務,但是之前的事務update操作還沒有結束(已經將id為1的記錄鎖定了,行鎖),當你再次開啟一個新事務執行update操作時那必須等待前一個事務結束,前一個事務肯定結束不了,因為我們是內部自己調用自己(遞歸調用),所以這里就產生了死鎖現象。
這該如何解決呢?接下來,我們將介紹幾種解決辦法。
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("商品不存在");
}) ;
}
注意觀察上面的代碼,我們做了如下的修改:
- 方法上的事務注解刪除了。
- 樂觀鎖異常處理中,我們直接調用了當前的方法。(因為當前的方法一級不是事務方法,不需要自己注入自己進行調用)
執行結果如下:
圖片
數據庫庫存及版本變化:
圖片
數據也正確了。
上面的代碼中不管你用save還是saveAndFlush都是可以的。因為每一次的重試調用都是開啟的一個新事務。