交易:快照隔離擋不住的 write skew,與可串行化的三條路
· tech
#distributed-systems#book-notes#transactions
📑 目錄
交易的地基我在 SQL 系列鋪過了:ACID 的重點是 I、髒讀/不可重複讀/幻讀三種怪事、四個隔離層級的光譜、MVCC 怎麼讀不擋寫——那些這篇不重複。DDIA Ch7 真正的加值在後半:兩個連「快照隔離」都擋不住的陰險傢伙(lost update 與 write skew),以及當你真的需要最強保證時,通往可串行化(serializable)的三條路。先說結論:多數人以為開了快照隔離就安全了——這章專門打破這個安全感。
Lost update:兩個「讀-改-寫」互相蓋掉
第一個傢伙還算好認:兩筆交易都做「讀出來 → 算一算 → 寫回去」,並行時彼此看不見對方——各自從 42 讀起、各自 +1、各自寫回 43,其中一次更新就這樣蒸發了。解法你其實都見過:用原子操作(UPDATE SET counter = counter + 1,把讀改寫壓成一步——Redis 的 INCR 同款)、顯式上鎖(SELECT ... FOR UPDATE)、或CAS 樂觀鎖(寫回時驗證值沒被動過——Redis 的 WATCH 同款)。lost update 有成熟的藥,真正難纏的是下一個。
Write skew:各自都對,合起來錯
Write skew 是這章的招牌,也是最違反直覺的一個。經典場景:醫院規定至少要有兩位醫生值班,現在 Alice 和 Bob 都想請假:
為什麼一般手段治不了它:兩筆交易寫的是不同的列,所以偵測「同列寫入衝突」的快照隔離抓不到;你想 FOR UPDATE 鎖,但該鎖的東西可能根本還不存在(檢查「沒有人訂這間會議室」——你要鎖的是「不存在的訂單」,這就是幻讀搞的鬼)。治本只有兩條:具體化衝突(把「抽象的條件」變成一列真實的資料去鎖,例如每個時段一列的會議室表),或者——升級到真正的可串行化。
可串行化的三條路
Serializable 的定義很純:執行結果等同於「所有交易一筆接一筆跑」。有趣的是,實作它的三條路,性格天差地遠:
反思
「先檢查、再動作」是並行世界的頭號紅旗
write skew 給我的最大禮物,是一個可掃描的程式碼氣味:任何「先 SELECT 檢查條件、通過後再寫入」的段落,在並行下都是嫌疑犯——因為檢查與動作之間,世界可能已經變了。查餘額再扣款、查庫存再下單、查 username 沒人用再註冊、查鎖是自己的再釋放——全是同一個 check-then-act 的形狀。現在我 review 到這種段落,反射動作就是三連問:這兩步是原子的嗎?不是的話,靠什麼保證中間沒人插進來?插進來會打破什麼不變量? 十次有八次,作者沒想過第三題。這個氣味偵測器,比背任何隔離層級的定義都值錢。
最強的隔離,來自最笨的方法——這件事很美
可串行化的三條路裡,我最愛的是第一條:乾脆不要並行。 幾十年來大家拚命發明更聰明的鎖、更精巧的驗證,結果 Redis 和 VoltDB 說:記憶體夠快了,我單執行緒一筆一筆跑,並行問題從定義上就不存在。這正是我在 Redis 單執行緒那篇讚嘆過的「用簡單換可預測」——而 DDIA 把它放進交易理論的脈絡後更清楚了:那不是取巧,是一條正統的 serializable 實作路線,前提條件(交易短、資料在記憶體、不等外部)寫得明明白白。它提醒我:當硬體或場景變了,「最笨的方法」要重新評估一次——很多聰明設計,只是在為已經消失的瓶頸付複雜度。
悲觀還是樂觀,問衝突率就對了
2PL vs SSI,說到底又是那道熟題:成本付在事前(先鎖住,人人排隊)還是事後(先跑完,撞到重試)? 判準乾淨得很——衝突率。搶同一筆熱資料的交易多,樂觀派會 abort 到懷疑人生,不如悲觀先鎖;衝突稀少,悲觀派的鎖就是白付的稅,樂觀幾乎免費。這跟 Redis 的 WATCH、跟 git 的 merge(樂觀:先各改各的,衝突才解)是同一道光譜。我後來把它用成一個通用決策框架:任何「協調」機制,先估衝突率,再決定買保險(悲觀)還是自負額(樂觀)。 下一章更狠——當交易要跨機器,連「鎖」和「驗證」本身都變得不可靠,那才是分散式真正的麻煩。