背景
接過一個外包的專案,該專案使用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
這樣程式碼最簡單。
只有當寫併發高的時候,或根據業務推斷可能出現高併發寫操作的時候,才需考慮引入悲觀鎖機制。
(程式碼越複雜越容易出問題,越難維護)