# 淺談悲觀鎖與實戰 在大量請求下,在業務上容易發生併發情況,如果不好好保持資料原子性,那麼我們的資料最終結果並不可信,可以想像若在與金流相關系統上發生這種問題,這將是個災難。 其實說白了,就是要避免**競爭條件(Race condition)**。 ## 悲觀鎖 **悲觀鎖(Pessimistic Locking)**,悲觀點在於認為資料並不安全,資料更新需求非常高,因此我們要在每次拿數據時都上鎖,在這期間其他請求將會 **Block** 直至放鎖,等待下一個請求進行上鎖。從上述的描述,其實我們不難發現 Java 中的 **Synchronized** 就屬於悲觀鎖的實現,甚至資料庫許多鎖表機制也是屬於悲觀鎖。 ## 實戰 在傳統 RDB 上,我們可使用 SELECT ... FOR UPDATE 來針對 ROW 進行上鎖,直到後續 Commit 後才進行放鎖動作。 假設現在我們有一個庫存資料表,表格內容如下。 | Column | Type | Primary Key | | ---------- | -------- | ----------- | | product_id | INT | YES | | amount | INT | | | version | INT | | ### Spring Data JPA 在 Spring Data JPA 中我們可以更加優雅的使用 **@Lock** 來幫助我們進行悲觀鎖的實現。 #### Entity ```java @Entity @Table(name = "stock") public class Stock { @Id @Column(name = "product_id") private int productId; @Column(name = "amount") private int amount; @Column(name = "version") private int version; // getters and setters } ``` #### Repository ```java @Repository public interface StockRepository extends JpaRepository<Stock, Integer> { @Lock(value = LockModeType.PESSIMISTIC_WRITE) @Query("SELECT s FROM Stock s WHERE s.productId = :productId") Optional<Stock> findByProductId(int productId); } ``` #### Service ```java @Service public class StockServiceImpl implements StockService { private final StockRepository stockRepository; public StockServiceImpl(StockRepository stockRepository) { this.stockRepository = stockRepository; } @Transactional @Override public StockDto checkStock(int productId, int amount) { Optional<Stock> optional = stockRepository.findByProductId(productId); if(!optional.isPresent()) { throw new RuntimeException("product id not found, id: " + productId); } Stock stock = optional.get(); int existAmount = stock.getAmount(); int existVersion = stock.getVersion(); if(existAmount >= amount) { stock.setAmount(existAmount - amount); } stock.setVersion(existVersion + 1); stockRepository.save(stock); return convertToDto(stock); } private StockDto convertToDto(Stock stock) { StockDto stockDto = new StockDto(); stockDto.setProductId(stock.getProductId()); stockDto.setAmount(stock.getAmount()); return stockDto; } } ``` ## 模擬測試 1. 庫存數量為 100 2. QPS 平均為 100/sec ### Test case 1 如上述條件為基礎下,我們將在 **stockRepository.save** 之前將 **Thread.sleep(3 * 1000L)**。在這個測試情結下,我們可以測試在併發時,是否有真的進行上鎖動作。 ![](https://i.imgur.com/rhgZp9K.png) 可以看到在前面幾個的 Request 都已經完成,但後續的 Request 很大概率會因為 Connection Timeout 機制導致出錯,這也根本上的代表我們確實將該筆資料進行上鎖了。 ### Test case 2 一樣在上述的基礎下,我們這次將移除 **Thread.sleep(3 * 1000L)** 並且觀察庫存是否有超賣問題。 ![](https://i.imgur.com/32Ulhz9.png) 這次可以看到我們請求都正常了,該檢視一下庫存是否超賣了。 ![](https://i.imgur.com/LOt0mQQ.png) 在上圖中我們可以發現,**version** 為 **200**,證明了我們確實都完成了請求,然而在 QPS 平均為 100 的情況下,我們也 **amount** 也沒有發生超賣情形。 ## 結論 在實現悲觀鎖的過程中,我們不難發現,在這種誰先取得 Lock 誰先動作的過程,其實也是確保了執行的順序。 大家不妨使用 Java Synchronized 來進行 Multi-thread 程式撰寫,其實也是相同道理,都是要處理競爭條件的。