這頁不談引擎內部,而是應用/ORM 層如何主動防止 lost update。同一個問題有兩條路線:悲觀鎖先上鎖擋住衝突,樂觀鎖不鎖、提交時才比對版本。差別只在一句話——你預期衝突多不多。
🧠 Intuition
TIP
lost update 出在「讀 → 改 → 寫」這個循環:兩個交易各自讀到同一個值、各自改、各自寫回,後者覆蓋前者,前者的修改就憑空消失。悲觀鎖的態度是「先鎖住這一列,別人等我」;樂觀鎖的態度是「大家先跑,我提交時檢查有沒有人動過,動過就重來」。
- lost update:讀-改-寫循環互相覆蓋——遞增計數器、更新餘額、改 JSON 某欄位、兩人同時編輯同一頁都屬此類。
- 悲觀 (Pessimistic):透過預防處理衝突,先取得鎖再改;代價落在鎖、死鎖與擴展性。
- 樂觀 (Optimistic):透過衝突偵測處理,提交時才檢查是否有衝突;代價落在偵測到衝突後的回滾與重試。
- 這是應用層的主動防護,與資料庫層的隔離級別互補而非取代。
⚖️ Tradeoffs
lost update 的四條通用解 — 先認清可選武器
- 原子寫入操作:如
UPDATE counters SET value = value + 1 WHERE key = 'foo',把讀-改-寫收進一句由資料庫保證原子性,通常靠排他鎖或單執行緒執行實作。 - 顯式鎖定:應用用
SELECT ... FOR UPDATE鎖住要改的列,其他交易必須等待——這就是悲觀路線。 - Compare-and-set:只在值沒被別人動過時才更新,如
UPDATE ... SET content = 'new' WHERE id = 1234 AND content = 'old'——這是樂觀路線的思路。 - 自動偵測 lost update:允許並行、偵測到就中止重試;但注意 MySQL/InnoDB 的 repeatable read 不會偵測 lost update。
悲觀鎖 — 先鎖再改,用阻塞避開衝突
- JPA 用
LockModeType.PESSIMISTIC_WRITE取得 exclusive lock,Hibernate 產生FOR UPDATE子句;PESSIMISTIC_READ取得 shared lock,產生FOR SHARE。 - 各家語法不同:Oracle 只有
FOR UPDATE、SQL Server 用 table hints、MySQL 用LOCK IN SHARE MODE/FOR UPDATE;底層不支援 shared lock 時PESSIMISTIC_READ會退化成 exclusive lock。 - 建議設 lock timeout(JPA 的
javax.persistence.lock.timeouthint、Hibernate 的LockOptions.setTimeOut());NO_WAIT(timeout 0)會在鎖已被持有時立即失敗(FOR UPDATE NOWAIT)。 SKIP LOCKED(HibernateLockMode.UPGRADE_SKIPLOCKED)可跳過已鎖定的列,實作 job queue 時多個 worker 才不會互相卡住。- 代價:鎖持有期間阻塞他人、鎖太多會傷擴展性,因此要精確控制 lock scope。
樂觀鎖 — 版本欄位比對,衝突丟例外讓應用重試
- entity 加
@Version屬性(數值版本或 timestamp);每次 UPDATE,Hibernate 在 WHERE 子句附上version條件。 - 若版本已被別人改過,update count 為 0,內部
StaleStateException會被包成 JPA 規範的OptimisticLockException,交由應用重試。 - 它用衝突偵測而非預防,能防止 lost update——即使在 multi-request workflow 中,只要 entity 狀態在請求之間被保留。
- 建議用數值版本而非 timestamp:系統時間不保證單調遞增,且各資料庫時間精度差異極大(nanoseconds 到 seconds);分散式下 logical clock 永遠優於 physical clock。
樂觀鎖的 all-or-nothing 陷阱 — 非重疊更新也會撞版本
- 樂觀鎖是 all-or-nothing:只要兩個並行交易基於相同 entity version,即使更新的是不重疊的屬性子集,第二個更新仍會失敗。
- 高並行寫入下這會造成大量交易回滾。
- 一個解法是拆分 entity:把常一起讀、卻各自被更新的欄位拆成多個 sub-entity,各持獨立
version,代價是讀取要 JOIN。 - 另一路是 versionless 的
@OptimisticLocking:OptimisticLockType.DIRTY只拿被修改的屬性當 WHERE 條件,達成 per-attribute 防護,但需搭配@DynamicUpdate。
選型取捨 — 衝突多寡與交易長度決定路線
- 衝突少、讀多寫少:樂觀鎖幾乎零阻塞,偶爾撞衝突再重試,整體吞吐好。
- 衝突多、重試貴:悲觀鎖更穩——樂觀法在高競爭下會不停回滾做白工。
- 樂觀鎖特別適合長對話交易(application-level transaction,跨多個 request 的多請求流程),因為它不需要在請求之間一直持有資料庫鎖。
- 悲觀鎖行為可預測(阻塞而非重試),但長時間持鎖會拖垮排在後面的交易。
與隔離級別的關係 — 樂觀鎖仍有時間窗口
LockModeType.OPTIMISTIC(別名READ)在提交時檢查 version,即使 entity 沒被改,用來確保讀取一致性。- 但在 Read Committed 下,version check 與 commit 之間存在時間窗口,其他交易可能趁隙修改,仍可能不一致。
- 要完全堵住,需提高到 Repeatable Read 或更高,或改用
OPTIMISTIC_FORCE_INCREMENT(提交前主動遞增 version)。 - 提醒:在多領導者或無領導者複製下,鎖與 compare-and-set 都不適用(沒有單一最新副本),得靠衝突解決策略。
🔑 Takeaways
- lost update = 讀-改-寫互相覆蓋;四條通用解是原子寫入、顯式鎖定、compare-and-set、自動偵測重試。
- 悲觀鎖用
PESSIMISTIC_WRITE/FOR UPDATE先鎖再改,靠阻塞避衝突,代價是死鎖與擴展性;別忘了 lock timeout 與SKIP LOCKED。 - 樂觀鎖用
@Version提交時比對,變了就丟OptimisticLockException讓應用重試;適合衝突少與跨多請求的長對話交易,但有 all-or-nothing 陷阱。 - 選型看衝突多寡:衝突少選樂觀、衝突多且重試貴選悲觀;而樂觀鎖的 version check 在 Read Committed 下仍有時間窗口,須靠更高隔離級別或 force increment 補上。
No notes yet — jot your takeaways or Q&A here.