
圖片來源:ChatGPT 生成
長期寫後端程式,應該都會有類似的經驗,系統大多數時間行為都是對的,突然回報了某個 bug,還非常難重現,偶而才出現一次,程式碼追了半天,看不出任何問題,最後從 log 發現,是兩個幾乎同時的 request 以不預期的順序執行,導致最終狀態是錯的,也就是 race condition 沒處理好。
Race Condition
一般來說,所有操作資料庫的程式都可能會遇上 race condition,只是最終結果不一定是 bug,或是說不會造成嚴重的後果。例如:某個資料表代表某日的公告,當使用者 A 和使用者 B 同時修改同一日的公告,最理想的狀況是同時保留 A 與 B 的修改,最差的結果是只留下 A 或 B 的修改,那最差的結果算是 bug 嗎?很難說。但如果是某個帳戶的餘額,若有兩個提領的操作,沒處理好 race condition 的話,最嚴重是銀行多付錢,餘額還是錯的,這時就是嚴重的 bug 了。
過去在處理訂單等對金錢比較敏感的系統時,大多會使用鎖,像是用 Redis 的鎖或是用資料庫高強度的交易隔離等級來確保執行順序,這類的鎖都是悲觀鎖,確保同時只有一個請求能執行,但如果鎖的範圍不洽當,雖然可以確保系統的正確性,卻犧牲了系統的吞吐量,對於水平擴展來說是個限制。樂觀鎖
這次新系統的開發,都是採取樂觀鎖,這是取決在一個前提:新的系統即便是高併發,對於「同一筆」資料修改的「頻率」也不會很高。這種情況特別適合使用樂觀鎖。
樂觀鎖的實作很簡單,不過仍需要對 domain 中的 Entity 加入額外的欄位:version,算是還可以接受的小汙染。
如果不想加
version,最接近,能複用的欄位應該是updatedTime了,但資料庫儲存時間的資料格式可能是浮點數,在比較時結果不一定符合預期。
接著,在 Repository 的實作中加入對 version 的比對檢查,只有當資料庫該筆資料的版號依然是「預期中的版號」時才更新,並將 version 加 1,不然就視作錯誤。
在修改 entity 時,不需要去更動 version。
這時回到服務層,當兩個編輯同一個公告的請求 A 與 B 近來,若請求的公告存在,在第 6 行都會得到同個版本的公告,假設版本是 6,此時,如果是 A 先完成儲存的動作,當 B 執行儲存時,會因為版本已經跳到 7 了,儲存失敗。此時,有多種選擇,一個是單純重試,但結果可能是把 A 的編輯覆蓋掉,或是前端重新取得公告,由 B 決定怎麼處理,然後再送出請求。
進階應用
上述的例子,只是一個簡單的 Entity,但樂觀鎖也可以用在確保複雜的結構完整性,例如:有個 Entity A,在商業邏輯中最多可以新增兩個 Entity B,這樣的商業邏輯可以很簡單地寫在 Entity A 中 (第 8 至第 13 行),也可以用簡單的單元測試確保邏輯正確。
假設同時有兩個請求 X 與 Y,試著對一個已經有一個 child 的 entity A 加入第二個 child 時,當 X 成功完成請求,版本會加 1,此時另一個請求 Y 會因為版本不同而失敗。這可以讓商業邏輯的組成,更容易以物件導向的方式去設計,而不是程序導向的方式 (先檢查已經加到 a 的 b 筆數有幾筆,再進行把 b 加到 a 的動作)。
注意事項
樂觀鎖用起來確實很簡單,但也是有幾點要注意,不然仍可能造成問題:
- 所有的「更新」動作都要檢查 version,因此用 Repository 封裝資料庫的操作,然後只透過 Repository 操作資料庫會是最方便的。
- 建議仍用 transaction 把資料庫操作包起來,雖然不需要 SELECT FOR UPDATE 或是設定隔離層級,但如果要更新複雜的資料結構,當 version 不對時拋出例外後,roll back 能確保不會留下髒資料。
小結
新系統在使用樂觀鎖後,個人是相當滿意,確保正確性是一回事,最讓我滿意的是,整個系統中,除了 Entity 加了 version 這個特殊欄位,沒有任何一個地方要加入鎖的概念,不像過去要在很多地方要寫「取得某個鎖」然後進行動作,讓程式的邏輯好讀很多。
不過,樂觀鎖並不是沒有缺點,在高併發的情況下,假設 n 個請求修改同一筆資料,只會有一個成功,其餘 n - 1 個都會失敗,即便 n 個請求修改同一筆資料的不同屬性。因此,在高併發的情況下,悲觀鎖可能是比較好的解法,要視需求做出不同的選擇。













