Spring Boot并發更新還在掉坑?這5種解決方案讓你穩如泰山!
環境:SpringBoot3.4.2
1. 簡介
并發數據庫更新是指多個用戶或進程同時或在快速連續的時間內嘗試修改同一數據庫記錄或數據的情況。在多用戶或多線程環境中,當同時訪問并修改同一數據時,就可能發生并發更新問題。并發更新可能導致如下問題:
- 數據不一致性:當多個事務同時修改相關數據而沒有適當的同步機制時,數據庫可能進入一個違反業務規則或完整性約束的狀態
- 更新丟失:當兩個事務同時讀取同一數據項,各自修改后寫回,后提交的事務會覆蓋先提交事務的修改,導致先提交的修改“丟失”
- 臟讀:一個事務讀取了另一個尚未提交的事務所修改的數據。如果那個修改中的事務最終回滾,那么第一個事務讀到的就是“臟”數據
- 不可重復讀:一個事務可能多次讀取同一數據,但由于其他事務正在進行更新,每次讀取的結果卻不同
在開發中,要避免并發更新帶來的上述問題,我們可以采取如下的6種方案進行解決。
- 數據庫鎖定:用行/表級鎖,Spring Boot 結合@Transactional保證單事務更新
- 樂觀鎖定:加版本號,更新時添加版本條件不對則失敗,防并發修改
- 悲觀鎖定:更新前用特定語句顯式鎖定記錄或表
- 隔離級別:調高隔離級別可防并發更新,但會降低性能
- 應用層鎖定:用 Java 同步機制控制代碼關鍵部分訪問(JVM鎖或分布式鎖)
接下來,我們將詳細的介紹這5種方案的實現。
2.實戰案例
環境準備
實體對象
@Entity
@Table(name = "t_product")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id ;
private String name ;
private BigDecimal price ;
private Integer quantity ;
}數據庫操作Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
@Modifying
@Query("update Product p set quantity = ?2 where id = ?1")
int updateQuantity(Long id, Integer quantity) ;
}準備數據
圖片
2.1 數據庫鎖
數據的更新保證在一個事務中,確保相同的數據在更新的時候必須排隊。
@Transactional
public void productQuantity(Long id, Integer quantity) {
// 只要有事務在更新當前id的數據,那么其它線程必須等待(數據庫鎖)
this.productRepository.updateQuantity(id, quantity) ;
// 模型耗時操作
if (quantity == 20000) {
System.err.printf("%s - 進入等待狀態...%n", Thread.currentThread().getName()) ;
try {TimeUnit.SECONDS.sleep(200);} catch (InterruptedException e) {}
System.err.printf("%s - 等待狀態結束...%n", Thread.currentThread().getName()) ;
}
System.out.printf("%s - 更新完成...%n", Thread.currentThread().getName()) ;
}接下來,我們啟動2個線程執行上面的操作
// 線程1
this.productService.productQuantity(1L, 20000);
// 線程2
this.productService.productQuantity(1L, 30000);測試結果
線程1:
圖片
線程2:
圖片
線程2一直等待中,差不多1分鐘后,超時錯誤:
圖片
2.2 使用樂觀鎖
首先,我們在Product實體類中加入version字段同時使用 @Version 注解:
public class Product {
@Version
private Integer version ;
}數據庫中數據
圖片
修改,更新方法如下:
public void productQuantity(Long id, Integer quantity) {
this.productRepository.findById(id).ifPresent(product -> {
product.setQuantity(quantity) ;
// 模擬耗時
if (quantity == 20000) {
System.err.printf("%s - 進入等待%n", Thread.currentThread().getName()) ;
try {TimeUnit.SECONDS.sleep(10);} catch (InterruptedException e) {e.printStackTrace();}
}
try {
this.productRepository.save(product) ;
} catch(Exception e) {
e.printStackTrace() ;
}
}) ;
}啟動兩個線程執行上面的操作
// 線程1
this.productService.productQuantity(1L, 20000);
// 線程2
this.productService.productQuantity(1L, 30000);線程2會很快執行完;當線程1等待執行結束后執行save方法時,拋出如下異常:
圖片
發生了樂觀鎖異常。
接下來,我們結合重試機制進行重試操作,盡可能的完成此種異常情況下的操作。
引入依賴
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>開啟重試機制
@Configuration
@EnableRetry
public class AppConfig {}修改更新方法
@Retryable(maxAttempts = 3, retryFor = OptimisticLockingFailureException.class)
public void productQuantity(Long id, Integer quantity) {}設置了重試3次,包括了首次執行,并且是針對樂觀鎖異常進行重試。
測試結果如下:
圖片
2.3 使用悲觀鎖
悲觀鎖語法,用于事務中。執行 SELECT ... FOR UPDATE 會鎖住查詢出的行記錄,其他事務若想修改這些行會被阻塞,直到當前事務提交或回滾釋放鎖,可有效避免并發更新導致的數據不一致問題。
在Repository中添加如下方法:
// 重寫父類的方法,加入樂觀鎖for update
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Product> findById(Long id) ;修改Service方法:
@Transactional
public void productQuantity(Long id, Integer quantity) {
this.productRepository.findById(id).ifPresent(product -> {
product.setQuantity(quantity);
if (quantity == 20000) {
System.err.printf("%s - 進入等待%n", Thread.currentThread().getName());
try {TimeUnit.SECONDS.sleep(10);} catch (InterruptedException e) {e.printStackTrace();}
}
this.productRepository.save(product);
});
}注意,你必須在一個事務當中,否則啟動報錯。
// 線程1
this.productService.productQuantity(1L, 20000);
// 線程2
this.productService.productQuantity(1L, 30000);線程1先執行

線程2進入了等待狀態

2.4 設置事務隔離級別
我們可以在事務的方法上設置事務的隔離級別,通過設置串行化(Serializable)隔離級別。
@Transactional(isolation = Isolation.SERIALIZABLE)
public void productQuantity(Long id, Integer quantity) {
System.err.printf("%s - 準備執行%n", Thread.currentThread().getName()) ;
this.productRepository.updateQuantity(id, quantity) ;
if (quantity == 20000) {
System.err.printf("%s - 進入等待狀態...%n", Thread.currentThread().getName()) ;
try {TimeUnit.SECONDS.sleep(10);} catch (InterruptedException e) {}
}
System.out.printf("%s - 更新完成...%n", Thread.currentThread().getName()) ;
}還是按照上面的方式啟動2個線程,執行必須排隊
線程1:
圖片
線程2:
圖片
2.5 應用程序鎖
通過 synchronized 或 Lock 以及分布式鎖實現關鍵代碼每次只有一個線程執行。
private Object lock = new Object() ;
public void productQuantity(Long id, Integer quantity) {
synchronized (lock) {
this.productRepository.updateQuantity(id, quantity) ;
}
}通過鎖機制,確保了每次只有一個線程執行這里的更新操作。
注意,如果你寫的如下代碼可就是在寫bug了:
@Transactional
public synchronized void productQuantity(Long id, Integer quantity) {
this.productRepository.updateQuantity(id, quantity) ;
}該代碼執行時很可能出現,鎖釋放了,但是事務還沒有提交的場景。































