【雜談】JPA樂觀鎖改悲觀鎖遇到的一些問題與思考

猫毛·波拿巴發表於2024-07-31

背景

接過一個外包的專案,該專案使用JPA作為ORM。

專案中有多個entity帶有@version欄位

當併發高的時候經常報樂觀鎖錯誤OptimisticLocingFailureException

原理知識

JPA的@version是透過在SQL語句上做手腳來實現樂觀鎖的

UPDATE table_name SET updated_column = new_value, version = new_version WHERE id = entity_id AND version = old_version

這個"Compare And Set"操作必須放到資料庫層,資料庫層能夠保證"Compare And Set"的原子性(update語句的原子性)

如果這個"Compare And Set"操作放在應用層,則無法保證原子性,即可能version比較成功了,但等到實際更新的時候,資料庫的version已被修改。

這時候就會出現錯誤修改的情況

需求

解決此類報錯,讓事務能夠正常完成

處理——重試

既然是樂觀鎖報錯,那就是修改衝突了,那就自動重試就好了

案例程式碼

修改前

@Service
public class ProductService {
       
     @Autowired
     private ProductRepository productRepository;

     @Transactional
     public void updateProductPrice(Long productId, Double newPrice) {
          Product product = productRepository.findById(productId).orElseThrow(()->new RuntimeException("Product not found")
          product.setPrice(newPrice);
          productRepository.save(product);
     }   
}

修改後

增加一個withRetry的方法,對於需要保證修改成功的地方(比如使用者在UI頁面上的操作),可以呼叫此方法。

@Service
public class ProductService {
       
     @Autowired
     private ProductRepository productRepository;

     public void updateProductPriceWithRetry(Long productId, Double newPrice) {
         boolean updated = false;
          //一直重試直到成功
          while(!updated) {
               try {
                   updateProductPrice(productId, newPrice);
                   updated = true;
               } catch (OpitimisticLockingFailureException e) {
           System.out.println("updateProductPrice lock error, retrying...")
               }
          } 
   }

     @Transactional
     public void updateProductPrice(Long productId, Double newPrice) {
          Product product = productRepository.findById(productId).orElseThrow(()->new RuntimeException("Product not found")
          product.setPrice(newPrice);
          productRepository.save(product);
     }   
} 

依賴樂觀鎖帶來的問題——高併發帶來高衝突

上面的重試能夠解決樂觀鎖報錯,並讓業務操作能夠正常完成。但是卻加重了資料庫的負擔。

另外樂觀鎖也有自己的問題:

業務層將事務修改直接提交給資料庫,讓樂觀鎖機制保障資料一致性

這時候併發越高,修改的衝突就更多,就有更多的無效提交,資料庫壓力就越大

高衝突的應對方式——引入悲觀鎖

解決高衝突的方式,就是在業務層引入悲觀鎖。

在業務操作之前,先獲得鎖。

一方面減少提交到資料庫的併發事務量,另一方面也能減少業務層的CPU開銷(獲得鎖後才執行業務程式碼)

@Service
public class ProductService {
       
     @Autowired
     private ProductRepository productRepository;

     
     public void someComplicateOperationWithLock(Object params) {
          
          //該業務涉及到的幾個物件修改,需要獲得該物件的鎖
          //key=類字首+物件id
          List<String> keys = Arrays.asList(....);
          
          //RedisLockUtil為分散式鎖,可自行封裝(可基於redisson實現)
          //獲得鎖之後才開始執行任務程式碼,然後在任務執行結束釋放鎖
          RedisLockUtil.runWithLock(keys, retryTime, retryLockTimeout, ()->someComplicateOperation(params)}):
    
     }
  

     @Transactional
     public void someComplicateOperation(Object params) {
         .....
     }   
}    

遇到的坑

正常在獲得鎖之後,需要重新載入最新的資料,這樣修改的時候才不會衝突。(前一個鎖獲得者可能修改了資料)

但是,JPA有持久化上下文,有一層快取。如果在獲得鎖之前就將物件撈了出來,等獲得鎖之後重新撈還會得到快取內的資料,而非資料庫最新資料。

這樣的話,即使用了悲觀鎖,事務提交的時候還是會出現衝突。

案例:

@Service
public class ProductService {
       
     @Autowired
     private ProductRepository productRepository;

     
     public void someComplicateOperationWithLock(Object params) {
//獲得鎖之前先查詢了一次,此次查詢資料將快取在持久化上下文中 String productId
= xxxx; Product product = productRepository.findById(productId).orElseThrow(()->throw new RuntimeException("Product not found")); //該業務涉及到的幾個物件修改,需要獲得該物件的鎖 //key=類字首+物件id List<String> keys = Arrays.asList(....); //RedisLockUtil為分散式鎖,可自行封裝 //獲得鎖之後才開始執行任務程式碼,然後在任務執行結束釋放鎖 RedisLockUtil.runWithLock(keys, retryTime, retryLockTimeout, ()->someComplicateOperation(params)}): } @Transactional public void someComplicateOperation(Object params) { ..... //取到快取內的舊資料 Product product = productRepository.findById(productId).orElseThrow(()->throw new RuntimeException("Product not found")); .... } }

應對方式——refresh

在悲觀鎖範圍內,首次載入entity資料的時候,使用refresh方法,強制從DB撈取最新資料。

@Service
public class ProductService {
       
     @Autowired
     private ProductRepository productRepository;

     
     public void someComplicateOperationWithLock(Object params) {
          //獲得鎖之前先查詢了一次,此次查詢資料將快取在持久化上下文中
          String productId = xxxx;
          Product product = productRepository.findById(productId).orElseThrow(()->throw new RuntimeException("Product not found"));
          
          //該業務涉及到的幾個物件修改,需要獲得該物件的鎖
          //key=類字首+物件id
          List<String> keys = Arrays.asList(....);
          
          //RedisLockUtil為分散式鎖,可自行封裝
          //獲得鎖之後才開始執行任務程式碼,然後在任務執行結束釋放鎖
          RedisLockUtil.runWithLock(keys, retryTime, retryLockTimeout, ()->someComplicateOperation(params)}):
    
     }
  

     @Transactional
     public void someComplicateOperation(Object params) {
         .....
         //取到快取內的舊資料
         Product product = productRepository.findById(productId).orElseThrow(()->throw new RuntimeException("Product not found"));
        //使用refresh方法,強制從資料庫撈取最新資料,並更新到持久化上下文中
        EntityManager entityManager = SpringUtil.getBean(EntityManager.class)
        product = entityManager.refresh(product);
         ....
     }   
}    

總結

此專案採用樂觀鎖+悲觀鎖混合方式,用悲觀鎖限制併發修改,用樂觀鎖做最基本的一致性保護。

關於一致性保護

對於一些簡單的應用,寫併發不高,事務+樂觀鎖就足夠了

  • entity裡面加一個@version欄位
  • 業務方法加上@Transactional

這樣程式碼最簡單。

只有當寫併發高的時候,或根據業務推斷可能出現高併發寫操作的時候,才需考慮引入悲觀鎖機制。

(程式碼越複雜越容易出問題,越難維護)

相關文章