併發更新策略(樂觀鎖)
本文檔定義框架對「多個請求同時更新同一筆資料」的立場與做法。
1. 預設:last-write-wins(後寫覆蓋)
框架的基礎實體(AuditableBase、AuditableTenantEntity)不含 @Version 欄位,框架也不強制樂觀鎖。因此預設併發行為是 last-write-wins:
兩個請求同時讀取同一筆、各自修改後存回 → 後存的覆蓋先存的,先存者的修改靜默遺失(lost update),系統無感知。
這是一個有意識的預設,而非疏漏:對多數低併發的後台 CRUD(單人或低機率同時編輯同一筆),last-write-wins 簡單且足夠,不值得為每個實體付出樂觀鎖的全棧成本。
2. 何時該 opt-in 樂觀鎖
當「靜默覆蓋」會造成實質損害時,對該實體啟用樂觀鎖:
| 情境 | 為何需要 |
|---|---|
| 高併發、多人同時編輯同一筆 | lost update 機率高 |
| 財務 / 庫存 / 額度等數值欄位 | 覆蓋會造成帳目錯誤 |
| 長編輯 session(開著表單很久才送出) | 期間他人已改、送出時覆蓋 |
| 狀態機流轉 | 並行流轉可能跳過合法狀態 |
opt-in 是 per-entity 的——只在需要的實體加,不影響其他實體。
3. 完整 opt-in pattern(全棧)
樂觀鎖不是「實體加一個註解」就好,而是一條跨前後端的契約。缺任何一環都會變成假保護(見第 4 節)。
3.1 Entity:加 @Version
@Entity
public class Order extends AuditableBase {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private String id;
/// 樂觀鎖版本號,由 Hibernate 自動遞增與檢查
@Version
@Column(name = "version", nullable = false)
private Long version;
// ... 其他欄位
}
DDL 會多一個 version 欄位(ddl-auto: update 自動建立)。
3.2 DTO / Response:暴露 version
回應 payload 必須帶出當前 version,前端才有版本可保存:
public record OrderResponse(String id, String status, Long version /* ... */) {}
3.3 前端:保存並回送 version
前端讀取時保存 version,更新時連同變更一起送回(不顯示給使用者,藏在表單狀態)。
3.4 Service / PATCH:把 version 設回 entity
關鍵一步——載入 entity 後,用 client 送來的 version 覆寫,再存回:
public Order update(String id, UpdateOrderRequest req) {
Order order = orderRepository.findById(id)
.orElseThrow(() -> new NotFoundException("Order ${0} not found", id));
// 用 client 讀到的版本設回——版本過期時 flush 會拋 OptimisticLockingFailureException
order.setVersion(req.version());
propertyMap.copyTo(order); // 套用其餘變更(見 12-controller-service)
return orderRepository.save(order);
}
採
PropertyMapPATCH 時,把version納入請求 payload 並在套用後設回 entity。
3.5 框架:自動映射 OptimisticLockingFailureException → 409
版本過期時 Hibernate 拋 ObjectOptimisticLockingFailureException,框架的 OptimisticLockExceptionMapper 自動轉成 RFC 7807 的 409 Conflict:
{
"type": "urn:appfuse:error:optimistic-lock",
"title": "Concurrent Modification",
"status": 409,
"detail": "The resource was modified by another request. Please reload and try again."
}
版本需求:此自動映射自
appfuse-server引入OptimisticLockExceptionMapper起提供(見 CHANGELOG)。較舊版本會把此例外映為 500,需自行在應用層的@ControllerAdvice註冊 mapper。
3.6 前端:處理 409
收到 409 optimistic-lock 時,提示使用者「資料已被他人修改,請重新載入後再試」,並重新拉取最新資料(含新 version)。
4. 為什麼 version 必須往返(最關鍵的陷阱)
最常見的錯誤是「加了 @Version 卻沒讓版本跨請求往返」,結果是假保護:
若 Service 只
findById載入 entity、直接套用變更後存回,載入到的version永遠等於 DB 現值 → flush 時版本一致 → 永遠不會衝突。樂觀鎖形同虛設。
要真正擋住跨 HTTP 請求的 lost update,必須讓 client 讀取時拿到的 version 回到 server(3.2 → 3.3 → 3.4),server 把它設回 entity,Hibernate 才能在 flush 時比對「client 當初讀的版本」與「DB 現在的版本」,不一致才拋例外。
這也是 ICT 等團隊「加了 @Version 又移除」的常見原因:只做了 3.1 沒做 3.2–3.4,發現「沒效果」或拿到醜陋的 500(3.5 未配置),便放棄。
5. 與其他機制的關係
| 機制 | 關係 |
|---|---|
| 多租戶 | 正交——@Version 與 tenant_id 各管各的,可同時存在 |
ConflictException(409) | 樂觀鎖衝突與業務衝突同屬 409 家族,但 type 不同(optimistic-lock vs 業務語義),前端可分流提示 |
PropertyMap PATCH | 把 version 納入 payload,套用後設回 entity(見 12-controller-service) |
6. 決策速查
| 問題 | 答案 |
|---|---|
| 框架預設有樂觀鎖嗎? | ❌ 沒有,預設 last-write-wins |
| 怎麼啟用? | per-entity 加 @Version + 完整往返 pattern(第 3 節) |
只加 @Version 夠嗎? | ❌ 不夠,沒往返 = 假保護(第 4 節) |
| 衝突回什麼狀態碼? | 409 urn:appfuse:error:optimistic-lock(框架自動映射) |
| 所有實體都該加嗎? | ❌ 只在會因覆蓋受損的實體加(第 2 節) |