# Race condition
[TOC]
###### tags: `chatgpt`
---
## TL;DR
> 一個實作 Spring Data JPA 的 Spring Boot 的專案,在 service layer 裡有兩個 methods,第一個 method 為 @Transactional(readOnly = true)、第二個為 @Transactional 且呼叫了第一個 method 檢查數據之前是否有被寫入,另有一個 controller method 呼叫了這個 service layer 的第二個 method。
>
> 當前述的 controller method 同時或極短時間內被請求了數次,也就是這個 service layer 的第二個 method 被呼叫了多次,照理來說進入第二個 method 後其呼叫第一個 method 應該會發現數據之前已被寫入,但事實並非如此,在後面的幾次執行/呼叫第二個 method 整個交易似乎都有被完整完成。問題可能出在哪呢?
這個問題可能出在事務的隔離性(transaction isolation)上,事務的隔離性是指在並發情況下,保證每個事務的操作互相獨立,不會相互干擾。在這個場景下,第一個方法和第二個方法都被設置了事務屬性,但沒有明確指定事務的隔離級別(isolation level),因此可能存在並發操作時,兩個方法之間的讀寫衝突。
在預設情況下,Spring 使用的是數據庫預設的隔離級別,通常是 READ COMMITTED,這意味著當一個事務正在讀取一個數據時,另一個事務仍然可以寫入同一個數據,但這些寫入操作對於正在進行的讀取操作是不可見的。因此,第二個方法可能會在第一個方法執行完畢之前就被呼叫,導致第二個方法讀取到的數據與第一個方法執行之前的數據相同,而不是最新的數據。
為了解決這個問題,你可以在第二個方法上指定較高的事務隔離級別,例如 REPEATABLE READ 或 SERIALIZABLE,這樣可以確保在整個事務進行期間,讀取的數據不會被其他事務所修改。你也可以考慮在第二個方法中使用 Spring Data JPA 的樂觀鎖(optimistic locking)機制,以確保多個事務對同一個數據進行操作時不會產生衝突。
## 什麼是 Spring Data JPA 的樂觀鎖(optimistic locking)機制,能舉實例說明嗎?還有若將第二個方法加上 syncronized 關鍵字會更有幫助嗎?
Spring Data JPA 的樂觀鎖機制是一種在並發環境下保護資料完整性的方法。它通過在資料庫表格中增加一個版本號或者時間戳(timestamp)欄位,每次更新資料時同時更新版本號或時間戳欄位,然後在提交更新操作時檢查版本號或時間戳欄位的值是否和更新前一致,如果不一致則拋出異常,告訴應用程序該操作已經被其他使用者修改了,需要重新操作。
以下是一個簡單的實例說明如何在 Spring Data JPA 中使用樂觀鎖機制:
假設有一個名為 `User` 的實體類,代表系統中的用戶,其中有一個 `username` 屬性表示用戶名,現在需要實現一個更新用戶名的方法。
首先在 `User` 實體類中增加一個版本號欄位:
```java
@Entity
public class User {
// ...
@Version
private long version;
// ...
}
```
然後在 `UserRepository` 中定義更新用戶名的方法:
```java
public interface UserRepository extends JpaRepository<User, Long> {
@Modifying
@Query("update User u set u.username = :newUsername where u.id = :id and u.version = :version")
int updateUsername(@Param("id") Long id, @Param("newUsername") String newUsername, @Param("version") long version);
}
```
在這個方法中,使用 `@Modifying` 和 `@Query` 標註表示使用 JPQL 執行更新操作,其中包含了 `version` 欄位的條件限制,確保在更新操作之前檢查版本號是否一致。接下來在 `Service` 類中調用該方法:
```java
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
@Transactional
public void updateUsername(Long id, String newUsername) {
User user = userRepository.findById(id).orElseThrow(() -> new EntityNotFoundException("User not found"));
user.setUsername(newUsername);
userRepository.updateUsername(id, newUsername, user.getVersion());
}
}
```
在這個方法中,首先根據 `id` 查詢 `User` 實體,然後更新 `username` 屬性,最後調用 `UserRepository` 中定義的 `updateUsername` 方法,進行更新操作。在更新操作中,傳遞 `user.getVersion()` 參數表示使用當前 `User` 物件中的版本號進行檢查。
如果在並發環境下多個使用者同時進行更新操作,只有其中一個操作可以成功,其他操作會拋出 `org.springframework.dao.OptimisticLockingFailureException` 異常。
至於加上 synchronized 關鍵字,這是一種 Java 語言級別的
## 最後還是加上了 `synchronized` 才真的解決這個問題
如果在多個並發請求中出現交易數據不一致的情況,即使使用了樂觀鎖機制,也可能無法完全解決問題,此時可以考慮使用 `synchronized` 關鍵字,將多個請求的操作序列化,從而保證交易數據的完整性。
但是需要注意的是,使用 `synchronized` 會對系統的性能產生一定的影響,因為多個請求會被強制等待,直到上一個請求的操作完成。此外,如果應用程序中有多個實例運行在不同的 JVM 中,則需要考慮使用分佈式鎖(distributed lock)機制,例如使用 Redis 等分佈式鎖服務。
總之,在解決並發環境下交易數據一致性問題時,需要根據具體的應用場景,選擇合適的鎖機制,並在性能和數據完整性之間做出權衡。
## 如何使用 Redis 分佈式鎖服務
使用 Redis 分佈式鎖服務,可以解決多個 JVM 實例之間的並發問題,下面是一個簡單的示例:
首先,在 `pom.xml` 中添加 Redis 相關依賴:
```xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
```
然後,在需要使用分佈式鎖的地方,可以使用 `RedisTemplate` 進行操作,例如:
```java
@Autowired
private RedisTemplate<String, String> redisTemplate;
public boolean acquireDistributedLock(String lockKey, String requestId, int expireTime) {
Boolean result = redisTemplate.execute((RedisCallback<Boolean>) connection -> {
long now = System.currentTimeMillis();
byte[] lockBytes = redisTemplate.getStringSerializer().serialize(lockKey);
byte[] requestBytes = redisTemplate.getStringSerializer().serialize(requestId);
boolean locked = connection.setNX(lockBytes, requestBytes);
if (locked) {
connection.expire(lockBytes, expireTime);
}
return locked;
});
return result != null && result;
}
public boolean releaseDistributedLock(String lockKey, String requestId) {
Boolean result = redisTemplate.execute((RedisCallback<Boolean>) connection -> {
byte[] lockBytes = redisTemplate.getStringSerializer().serialize(lockKey);
byte[] requestBytes = redisTemplate.getStringSerializer().serialize(requestId);
byte[] valueBytes = connection.get(lockBytes);
if (valueBytes != null && Arrays.equals(valueBytes, requestBytes)) {
return connection.del(lockBytes) == 1;
}
return false;
});
return result != null && result;
}
```
這裡定義了 `acquireDistributedLock` 和 `releaseDistributedLock` 兩個方法,分別用於獲取鎖和釋放鎖。
在 `acquireDistributedLock` 方法中,我們使用 Redis 的 `setNX` 命令,該命令可以保證在鎖不存在的情況下,將鎖設置為指定的值。如果設置成功,我們還可以使用 `expire` 命令來設置鎖的過期時間,從而避免鎖永久存在。
在 `releaseDistributedLock` 方法中,我們首先通過 `get` 命令獲取鎖的值,然後判斷該值是否等於請求 ID,如果相等,則使用 `del` 命令刪除鎖。
需要注意的是,上述示例中的鎖都是基於 Redis 的單點實例實現的,如果需要實現高可用的分佈式鎖,可以使用 Redis Sentinel 或者 Redis Cluster 等技術。
總之,使用 Redis 分佈式鎖可以很好地解決多個 JVM 實例之間的並發問題,但需要注意鎖的過期時間、鎖的粒度以及鎖的實現方式等問題。
## 若改用 MQ 是否同樣也能改善這個問題
使用消息隊列(MQ)也可以解決多個應用程序之間的並發問題,但是這種方式與使用分佈式鎖的方式有一些區別。
在使用 MQ 的情況下,當需要執行某些操作時,應用程序會將操作請求發送到 MQ 中,然後由其他應用程序進行處理。這樣可以保證操作的順序和一致性,並且可以分散負載,減少單個應用程序的負載。
但是,使用 MQ 也存在一些問題。首先,由於消息的延遲時間,使用 MQ 可能會導致操作的執行時間變長。其次,如果 MQ 發生故障,可能會導致消息的丟失或重複處理,進而影響系統的一致性和可靠性。最後,使用 MQ 需要對應用程序進行一定的修改,包括將操作轉換為消息、設置消息的傳輸方式和協議等。
因此,在選擇使用 MQ 還是分佈式鎖時,需要根據具體情況進行綜合考慮。如果需要實現高性能和低延遲的操作,可以考慮使用分佈式鎖;如果需要實現可靠性和擴展性,可以考慮使用 MQ。當然,在實際應用中,可能需要根據不同的情況,選擇不同的解決方案。
## Spring Data JPA 的樂觀鎖(optimistic locking)機制需要在 entity class 中新增一個 `@Version` property,這個 property 需要增加到實體資料表嗎
是的,使用 Spring Data JPA 的樂觀鎖機制需要在實體類上定義一個版本屬性,通常使用` @Version` 注解進行標記,並將其映射到資料表中。這個版本屬性會在每次更新資料時自動進行更新,以用於檢測並發操作。
在資料庫中,版本屬性通常是一個整數型別,每次更新時都會自動遞增。當多個應用程序同時對同一筆資料進行更新時,如果其中一個應用程序更新了資料,將會增加版本屬性的值,這樣其他應用程序就可以檢測到資料已經被更新過了,進而進行相應的處理。
在實體類上定義版本屬性的方式如下所示:
```java
@Entity
public class MyEntity {
@Id
private Long id;
@Version
private Long version;
// other fields and methods
}
```
這樣可以讓 Spring Data JPA 自動地將版本屬性映射到資料表中。需要注意的是,版本屬性的類型必須是一個可比較的類型,例如整數型別或時間戳型別。