Java中管理資料庫併發的6種鎖模式

banq發表於2024-05-30

併發資料庫更新是指多個使用者或程序試圖同時或快速連續地修改同一資料庫記錄或資料的情況。在多使用者或多執行緒環境中,當多個實體(例如使用者或應用程式)同時訪問和修改同一資料時,可能會發生併發更新。併發資料庫更新可能導致各種問題和挑戰,包括:

  1. 資料不一致:如果管理不當,併發更新可能會導致資料不一致,資料庫會包含衝突或不正確的資訊。
  2. 丟失更新:一個更新可能會覆蓋另一個更新所做的更改,從而導致資料丟失。
  3. 髒讀:一個事務可能會讀取另一個事務正在更新的資料,從而導致資訊不準確或不完整。
  4. 不可重複讀:一個事務可能多次讀取相同的資料,但由於其他事務的持續更新,每次都會得到不同的結果。

為了防止 Spring Boot 應用程式中的併發資料庫更新,您可以使用各種策略和技術:
  1. 資料庫鎖定:利用資料庫級鎖定(例如行級或表級鎖定)來確保一次只有一個事務可以更新特定記錄。Spring Boot 使用註釋支援宣告式事務管理@Transactional,可與資料庫鎖定機制結合使用。
  2. 樂觀鎖定:使用樂觀鎖定和版本控制。這涉及向資料庫表新增版本列並在更新期間檢查此版本。如果自檢索資料以來版本已發生更改,則更新將失敗,表示併發修改。
  3. 悲觀鎖定:透過使用SELECT ... FOR UPDATESQL 語句或類似的資料庫特定機制在更新之前明確鎖定記錄或表來實現悲觀鎖定。
  4. 隔離級別:配置資料庫事務的隔離級別。較高的隔離級別可SERIALIZABLE確保事務以防止併發更新的方式執行,但會影響效能。
  5. 應用程式級鎖定:使用 Java 構造(如塊或其他執行緒同步機制)實現應用程式級鎖定synchronized,以控制對程式碼關鍵部分的訪問。
  6. 資料庫事務:明智地使用資料庫事務,確保事務是短暫的並且僅在必要的時間內持有鎖,以最大限度地減少發生衝突的機會。
  7. 重試策略:在樂觀鎖定失敗的情況下實現重試機制,允許應用程式在短暫延遲後重試操作。

策略的選擇取決於應用程式的具體要求和約束。在許多情況下,可能需要結合使用這些技術才能有效地防止併發資料庫更新並確保資料一致性。

1、資料庫鎖
下面的程式碼塊演示瞭如何使用 Spring 的 @Transactional 註釋來確保一次只有一個執行緒可以更新特定的資料庫記錄。

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

     @Transactional 
    public void updateProductPrice(int productId, double newPrice){
         Product  product  = productRepository.findById(productId); product.setPrice(newPrice);
         <font>// @Transactional 註解確保此更新操作是原子的。<i>
       
// 每次只能有一個執行緒可以更新產品。 <i>
    } }

@Transactional:此註釋應用於updateProductPrice方法。它表示此方法應在事務中執行。事務用於將一個或多個資料庫操作分組為單個原子單元。當您將方法標記為時@Transactional,Spring 將為您處理事務管理。

updateProductPrice方法內部:

  • 它首先使用 productRepository.findById(productId) 方法,根據 ProductId 抓取 Product 實體。該獲取操作是事務的一部分。
  • 然後,使用 product.setPrice(newPrice) 為產品設定新價格。
  • 由於該方法使用 @Transactional 進行了註解,因此獲取產品和更新其價格的整個序列被視為一個單一的原子事務。

這裡需要注意的關鍵點是,@Transactional 註解確保一次只能有一個執行緒執行 updateProductPrice 方法,防止對同一產品記錄進行併發更新。如果另一個執行緒試圖在事務正在進行時呼叫該方法,它將不得不等待事務完成,從而確保資料一致性並防止併發問題。

2、樂觀鎖
以下程式碼塊演示瞭如何在 Spring Boot 應用程式中使用 JPA 中的 @Version 註解實現樂觀鎖定。

@Entity
public class Product {

    @Id
    private Long id;

    private String name;
    private double price;

    @Version
    private int version; <font>// Version field for optimistic locking<i>

   
// Getters and setters<i>
}

私有 int 版本:該欄位用 @Version 進行註解,這是一種用於樂觀鎖定的特殊註解。該欄位的目的是跟蹤實體的版本。每次更新實體時,JPA 都會自動增加版本。

樂觀鎖的工作原理如下:

  • 當您從資料庫檢索產品實體時,JPA 會記錄當時實體的版本號。
  • 當您更新產品實體並將其儲存回資料庫時,JPA 會自動檢查資料庫中實體的版本是否與您之前檢索到的版本一致。如果匹配,則允許繼續更新。如果不匹配,則表明另一個事務已併發更新了同一實體,通常會丟擲異常(如 OptimisticLockException)來處理這種情況。

這種機制可確保只有當實體的版本與最初檢索到的版本相匹配時,才會應用更新,從而防止併發更新導致資料不一致。

在服務或儲存庫方法中,通常會透過捕獲樂觀鎖定異常並採取適當措施(如通知使用者或重試操作)來處理該異常。

3、悲觀鎖
以下程式碼塊演示瞭如何在 Spring Boot 應用程式中使用帶有 FOR UPDATE 子句的本地 SQL 查詢實現悲觀鎖定,從而顯式鎖定所選資料庫記錄以進行更新。這意味著,如果另一個事務試圖併發更新同一記錄,它將被阻塞,直到鎖被釋放,從而確保資料一致性。

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;

@Repository
public class ProductRepository {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    public Product findByIdForUpdate(int productId) {
        <font>// Use a native SQL query with "FOR UPDATE" to lock the record for update.<i>
        return jdbcTemplate.queryForObject(
           
"SELECT * FROM product WHERE id = ? FOR UPDATE",
            new Object[]{productId},
            (rs, rowNum) -> new Product(
                rs.getInt(
"id"),
                rs.getString(
"name"),
                rs.getDouble(
"price")
            )
        );
    }
}

findByIdForUpdate(int productId):該方法旨在透過 productId 檢索產品實體,同時使用本地 SQL 查詢鎖定該實體。

方法內部:

  • 它使用 FOR UPDATE 子句構造了一個 SQL 查詢,該子句是資料庫特有的功能,可鎖定所選記錄直到事務提交,從而防止其他事務同時更新這些記錄。
  • jdbcTemplate.queryForObject 方法用於執行 SQL 查詢並檢索 Product 實體。該方法還將結果集(資料庫中的記錄)對映到 Product 物件。

請記住,FOR UPDATE 的具體 SQL 語法可能因資料庫系統(如 MySQL、PostgreSQL、Oracle)而異,因此應根據具體資料庫進行調整。此外,在使用悲觀鎖處理潛在爭用情況時,您需要在服務層中適當處理異常和事務管理。

4、隔離級別
實施隔離級別需要在 Spring Boot 應用程式中配置資料庫並指定隔離級別。下面的程式碼塊演示瞭如何在 Spring Boot 應用程式中設定 Serializable 隔離級別:

import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.stereotype.Service;

@Service
public class ProductService {

    @Autowired
    private ProductRepository productRepository;

    @Transactional(isolation = Isolation.SERIALIZABLE)
    public void updateProductPrice(int productId, double newPrice) {
        Product product = productRepository.findById(productId);
        product.setPrice(newPrice);
        <font>// The @Transactional annotation with SERIALIZABLE isolation level ensures the highest level of isolation.<i>
    }
}

@Transactional(isolation = Isolation.SERIALIZABLE):
  • 將 @Transactional 註解應用於 updateProductPrice 方法,
  • 並將隔離屬性設定為 Isolation.SERIALIZABLE。該屬性指定了事務的隔離級別。

隔離級別

  • Isolation.SERIALIZABLE:這是最高隔離級別。它確保事務一個接一個地序列執行。它提供完全隔離,防止併發事務影響彼此的資料。不過,由於嚴格的序列化,它可能會影響效能。
  • 其他可使用的隔離級別包括 Isolation.READ_COMMITTED、Isolation.REPEATABLE_READ 和 Isolation.READ_UNCOMMITTED。每個級別都提供了不同程度的資料一致性和併發控制。

Product product = productRepository.findById(productId);:在 updateProductPrice 方法中,將從資料庫中獲取 Product 實體。在 @Transactional 註解中配置的特定隔離級別將影響該讀取操作在併發事務中的表現。

SERIALIZABLE 可確保最高階別的隔離,但可能會降低併發性,並可能延長事務處理時間。其他隔離級別可在併發性和資料一致性之間取得平衡,允許多個事務同時發生,但相互之間的隔離程度各不相同。

隔離級別的選擇應符合應用程式的要求,並考慮資料完整性、效能和併發訪問的可能性等因素。

5、應用程式級鎖
使用同步(synchronized)等結構實現應用級鎖定,可確保一次只能有一個執行緒執行特定的程式碼塊。

import org.springframework.stereotype.Service;

@Service
public class ProductService {
    
    private final Object lock = new Object();

    public void updateProductPrice(int productId, double newPrice) {
        synchronized (lock) {
            <font>// This synchronized block ensures that only one thread can execute this code at a time.<i>
            Product product = productRepository.findById(productId);
            product.setPrice(newPrice);
        }
    }
}

synchronized (lock) { ...}:該程式碼塊使用鎖物件同步。這意味著在任何時候,只有一個執行緒可以執行該程式碼塊中的程式碼。

  • 在同步程式碼塊中,使用 productRepository.findById(productId); 從資料庫中獲取 Product 實體。此操作現在受同步保護,確保併發執行緒無法同時執行這部分程式碼。
  • 檢索產品後,使用 product.setPrice(newPrice); 設定新價格。

使用 synchronized 進行應用程式級鎖定是確保程式碼關鍵部分免受併發訪問的一種簡單方法。不過,必須謹慎使用應用程式級鎖定,因為過度同步會導致效能瓶頸和潛在的死鎖。

6、重試策略
在處理併發問題或網路相關問題時,實施重試策略非常有用。

import org.springframework.stereotype.Service;

@Service
public class ProductService {
    
    private static final int MAX_RETRIES = 3; <font>// Maximum number of retry attempts<i>
    
    public void updateProductPriceWithRetry(int productId, double newPrice) {
        int retryCount = 0;
        boolean success = false;

        while (retryCount < MAX_RETRIES && !success) {
            try {
                updateProductPrice(productId, newPrice);
                success = true;
            } catch (ConcurrencyException e) {
               
// 處理併發異常,如記錄或等待重試。<i>
                retryCount++;
            }
        }

        if (!success) {
           
// 處理重試次數用盡的情況,例如丟擲錯誤或記錄日誌。<i>
        }
    }

    private void updateProductPrice(int productId, double newPrice) throws ConcurrencyException {
       
// 透過丟擲異常來模擬併發問題。<i>
       
// 在實踐中,您將執行實際更新並在此處處理任何併發問題。<i>
        throw new ConcurrencyException(
"Concurrency issue occurred during update.");
    }
}

這段程式碼演示了一種重試次數有限的簡單重試策略。您可以自定義重試邏輯、錯誤處理和最大重試次數,以滿足您的特定要求和用例。此外,還可以考慮使用 Spring Retry 等更高階的重試庫,以獲得更強大和可配置的重試策略。

結論
在多使用者和多執行緒環境中,防止資料庫併發更新的策略在維護資料一致性和完整性方面發揮著關鍵作用。無論是透過資料庫鎖定機制、樂觀或悲觀鎖定、隔離級別,還是應用級同步,每種方法都有其獨特的優勢和利弊。關鍵在於根據應用程式的特定需求,深思熟慮地選擇和實施這些策略,以確保資料庫系統穩健可靠,經得起併發更新的挑戰。

相關文章