在 Spring Data JPA 中,如果你有「唯一資源(如序號)」要讓多個使用者同時競爭,必須採用**資料一致性與鎖的策略**來保護資源,否則會出現兩個使用者搶到同一筆資料的問題。
---
## ✅ 解法總覽
| 解法方式 | 說明 | 適用情境 |
| ---------------------------------- | --------------- | --------- |
| 1️⃣ 悲觀鎖(Pessimistic Lock) | 直接在 DB 層鎖定資料 | 高併發、強一致性 |
| 2️⃣ 樂觀鎖(Optimistic Lock) | 利用版本欄位做衝突檢查 | 衝突不頻繁 |
| 3️⃣ 唯一性約束(Unique Constraint)+ 例外處理 | DB 保證唯一,應用層捕捉衝突 | 最終一致性即可 |
| 4️⃣ 資料庫的 `SELECT FOR UPDATE` | 與悲觀鎖類似,使用原生 SQL | 自訂 SQL 邏輯 |
以下是具體實作方式 👇
---
## 1️⃣ 使用「悲觀鎖」保護資源(最推薦)
Spring Data JPA 支援 `@Lock(LockModeType.PESSIMISTIC_WRITE)`:
```java
@Entity
public class SerialNumber {
@Id
private Long id;
private String code;
private boolean used;
// getters/setters
}
```
```java
public interface SerialNumberRepository extends JpaRepository<SerialNumber, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT s FROM SerialNumber s WHERE s.used = false")
List<SerialNumber> findAvailableSerialNumbers(Pageable pageable);
}
```
### 使用方式:
```java
@Transactional
public SerialNumber grabSerialNumber() {
// 取得一筆未使用序號並上鎖(使用 LIMIT 1)
List<SerialNumber> list = serialNumberRepository.findAvailableSerialNumbers(PageRequest.of(0, 1));
if (list.isEmpty()) throw new RuntimeException("No available serial numbers");
SerialNumber sn = list.get(0);
sn.setUsed(true);
return serialNumberRepository.save(sn);
}
```
### ✅ 優點:
* 確保這筆序號在處理期間無法被別人搶走(DB 鎖定)
* 適合 **搶號碼牌、搶票** 等業務場景
---
## 2️⃣ 使用「樂觀鎖」加上版本控制
在 Entity 中加入 `@Version` 欄位:
```java
@Entity
public class SerialNumber {
@Id
private Long id;
private String code;
private boolean used;
@Version
private int version;
}
```
### 使用方式:
你只要正常更新 `SerialNumber`,JPA 會自動在更新時加上:
```sql
UPDATE serial_number SET used = true, version = ? WHERE id = ? AND version = ?
```
如果同時有兩人修改同一筆資料,第二人會拋出 `OptimisticLockingFailureException`。
### 適合:
* 衝突少(但不能容忍重複搶到)
* 願意在應用層重試操作
---
## 3️⃣ 靠唯一性約束阻擋重複搶奪
例如,建立一張「搶到序號的記錄表」,加上唯一索引(如 serial\_id):
```sql
CREATE TABLE serial_claim (
serial_id BIGINT PRIMARY KEY,
user_id BIGINT
);
```
讓資料庫幫你保證「一個序號只能被插入一次」,然後應用層捕捉 `DuplicateKeyException`。
### 適合:
* 不需強一致性,但需保證唯一
* 可容忍寫入失敗後重試
---
## 4️⃣ 使用原生 SQL + `FOR UPDATE`(更強控制)
```java
@Query(value = "SELECT * FROM serial_number WHERE used = false LIMIT 1 FOR UPDATE", nativeQuery = true)
SerialNumber lockAvailableSerialNumber();
```
搭配 `@Transactional` 使用,效果等同悲觀鎖。
---
## 🧠 小總結
| 目標 | 做法 |
| ------------- | --------------------- |
| 確保序號被唯一取得且不重複 | 使用悲觀鎖或樂觀鎖 |
| 想要可擴展、簡單錯誤處理 | 唯一鍵 + try-catch |
| 極致控制(如鎖定順序) | 原生 SQL + `FOR UPDATE` |
---
`@Lock(LockModeType.PESSIMISTIC_WRITE)` 是 Spring Data JPA 提供的一種**悲觀鎖**機制,可以在**查詢資料時同時對資料加鎖**,防止多個交易同時修改同一筆資料。
這種鎖定會轉換成資料庫的 `SELECT ... FOR UPDATE` 語句,只有當前交易完成(commit/rollback)後,其他交易才能讀寫被鎖的資料。
---
## ✅ 基本用法
### 1️⃣ Entity 範例
```java
@Entity
public class SerialNumber {
@Id
private Long id;
private String code;
private boolean used;
// getter/setter
}
```
---
### 2️⃣ Repository 中加上 @Lock
```java
public interface SerialNumberRepository extends JpaRepository<SerialNumber, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT s FROM SerialNumber s WHERE s.used = false")
List<SerialNumber> findFirstAvailable(Pageable pageable);
}
```
---
### 3️⃣ Service 層使用
```java
@Service
public class SerialService {
@Autowired
private SerialNumberRepository repository;
@Transactional
public SerialNumber grabSerial() {
// 限制只拿一筆未被使用的序號
List<SerialNumber> list = repository.findFirstAvailable(PageRequest.of(0, 1));
if (list.isEmpty()) {
throw new RuntimeException("No available serial number.");
}
SerialNumber sn = list.get(0);
sn.setUsed(true); // 標記為已使用
return repository.save(sn);
}
}
```
---
## 🔒 注意事項
| 注意點 | 說明 |
| -------------------- | ----------------------------------------------------------- |
| 必須用 `@Transactional` | 否則鎖不會發生作用 |
| 悲觀鎖會鎖整筆資料(行鎖) | 直到 transaction 結束前其他交易無法更新或讀取(depending on isolation level) |
| 大量併發要小心死鎖 | 使用前先評估資料庫支援與設計結構 |
| 非必要勿使用在全表查詢 | 因為可能導致鎖表或效能問題 |
---
## ✅ SQL 轉換說明
Spring Data JPA 在執行以下程式碼:
```java
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT s FROM SerialNumber s WHERE s.used = false")
```
會在底層執行轉成:
```sql
SELECT * FROM serial_number WHERE used = false FOR UPDATE;
```
---
## 🧪 小測試情境(高併發搶序號)
你可以用 JMeter 或多執行緒測試同時叫用 `grabSerial()` 方法,確保每個人都只能搶到一組不重複的序號。
---
如果你希望的是根據 ID 查詢後加鎖,範例如下:
```java
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<SerialNumber> findById(Long id);
```
這也會轉成:
```sql
SELECT * FROM serial_number WHERE id = ? FOR UPDATE;
```
---