自拍偷在线精品自拍偷,亚洲欧美中文日韩v在线观看不卡

高并發(fā)場景下Spring事務(wù)與JPA樂觀鎖重試機(jī)制導(dǎo)致的死鎖

開發(fā) 前端
在高并發(fā)場景中,庫存扣減是最典型的業(yè)務(wù)挑戰(zhàn)之一。當(dāng)多個線程同時爭奪同一商品的庫存資源時,稍有不慎就會引發(fā)超賣(庫存扣減為負(fù)值)或死鎖(線程相互阻塞無法推進(jìn))等嚴(yán)重問題。

環(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ù)。

責(zé)任編輯:武曉燕 來源: Spring全家桶實戰(zhàn)案例源碼
相關(guān)推薦

2024-01-04 18:01:55

高并發(fā)SpringBoot

2021-02-20 10:02:22

Spring重試機(jī)制Java

2022-11-14 08:19:59

重試機(jī)制Kafka

2017-07-02 16:50:21

2017-06-16 15:16:15

2023-07-18 09:24:04

MySQL線程

2024-09-25 08:32:05

2020-07-06 08:03:32

Java悲觀鎖樂觀鎖

2025-02-26 10:49:14

2020-07-19 15:39:37

Python開發(fā)工具

2022-05-06 07:44:10

微服務(wù)系統(tǒng)設(shè)計重試機(jī)制

2024-01-05 18:01:17

高并發(fā)策略程序

2023-07-05 08:18:54

Atomic類樂觀鎖悲觀鎖

2023-10-27 08:20:12

springboot微服務(wù)

2025-01-03 08:44:37

kafka消息發(fā)送策略

2024-01-05 16:43:30

數(shù)據(jù)庫線程

2023-12-26 08:59:52

分布式場景事務(wù)機(jī)制

2025-04-18 03:00:00

2021-01-15 05:12:14

Java并發(fā)樂觀鎖

2023-11-27 07:44:59

RabbitMQ機(jī)制
點贊
收藏

51CTO技術(shù)棧公眾號