樂觀 vs 悲觀並發控制 (Optimistic vs Pessimistic Locking)

★★★★Draft

FromHighDDIA

📚 From the Books

樂觀 vs 悲觀並發控制 (Optimistic vs Pessimistic Locking)Transactions

這頁不談引擎內部,而是應用/ORM 層如何主動防止 lost update。同一個問題有兩條路線:悲觀鎖先上鎖擋住衝突,樂觀鎖不鎖、提交時才比對版本。差別只在一句話——你預期衝突多不多。

🧠 Intuition

TIP

lost update 出在「讀 → 改 → 寫」這個循環:兩個交易各自讀到同一個值、各自改、各自寫回,後者覆蓋前者,前者的修改就憑空消失。悲觀鎖的態度是「先鎖住這一列,別人等我」;樂觀鎖的態度是「大家先跑,我提交時檢查有沒有人動過,動過就重來」。

⚖️ 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.timeout hint、Hibernate 的 LockOptions.setTimeOut());NO_WAIT(timeout 0)會在鎖已被持有時立即失敗(FOR UPDATE NOWAIT)。
  • SKIP LOCKED(Hibernate LockMode.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 的 @OptimisticLockingOptimisticLockType.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

✍️ My Notes

No notes yet — jot your takeaways or Q&A here.

📖 Further Reading