跳至主要内容

併發更新策略(樂觀鎖)

適用對象: 開發團隊、AI Agent、使用 app-server 範本的團隊 相關: 錯誤處理資料層設計

本文檔定義框架對「多個請求同時更新同一筆資料」的立場與做法。


1. 預設:last-write-wins(後寫覆蓋)

框架的基礎實體(AuditableBaseAuditableTenantEntity不含 @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);
}

PropertyMap PATCH 時,把 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. 與其他機制的關係

機制關係
多租戶正交——@Versiontenant_id 各管各的,可同時存在
ConflictException(409)樂觀鎖衝突與業務衝突同屬 409 家族,但 type 不同(optimistic-lock vs 業務語義),前端可分流提示
PropertyMap PATCHversion 納入 payload,套用後設回 entity(見 12-controller-service

6. 決策速查

問題答案
框架預設有樂觀鎖嗎?❌ 沒有,預設 last-write-wins
怎麼啟用?per-entity 加 @Version + 完整往返 pattern(第 3 節)
只加 @Version 夠嗎?❌ 不夠,沒往返 = 假保護(第 4 節)
衝突回什麼狀態碼?409 urn:appfuse:error:optimistic-lock(框架自動映射)
所有實體都該加嗎?❌ 只在會因覆蓋受損的實體加(第 2 節)