01

為什麼我們需要交易?

先看資料世界有多混亂,再看「交易」怎麼用一句承諾把混亂收乾

資料世界的「嚴酷現實」:很多事都會出錯

第 7 章一開場,作者沒有先講漂亮的理論,而是先把「真實世界會出事的清單」整份攤開給你看。為什麼要先講壞消息?因為只有先體會過混亂,你才會明白後面要學的 交易(Transaction) 為什麼是個救星。

書中列出六種典型的 部分失敗 情境,我們把它拆開看:

💥
軟硬體故障

資料庫程式或機器隨時可能掛掉——甚至就在寫到一半的時候。

🧨
應用程式崩潰

你的 App 可能在「一連串操作做到一半」時當掉,該做的事只做了前幾步。

📡
網路中斷

網路斷線讓 App 連不上資料庫、或讓資料庫節點彼此斷聯,不知道對方到底成功了沒。

✍️
多人同時寫入

好幾個 client 同時寫,互相覆蓋彼此的修改,有人的更新莫名消失。

🧩
讀到半成品資料

client 讀到「只更新到一半」、根本不合理的資料,看到自相矛盾的畫面。

🎲
競態條件

多個 client 的執行順序交錯,產生意想不到的 bug,偶發又極難重現。

🍳
尖峰時段的早餐店

一份總匯三明治要「烤吐司 → 煎蛋 → 夾火腿 → 加生菜 → 包裝」,這一連串動作就像資料庫裡的一連串讀寫。最可怕的不是整份三明治掉地上(全部失敗),而是「夾了火腿、還沒包裝,你就被叫去接電話」——這份半成品卡在中間,沒人知道該丟掉還是繼續做。這正是部分失敗。

直接讀原文:交易到底承諾了什麼

這一段是全章的地基句——先讀原文,感受作者怎麼用最精簡的英文,把「交易」這個概念一句話講清楚。

原文 · DDIA Ch.7 In the harsh reality of data systems, many things can go wrong. In order to be reliable, a system has to deal with these faults and ensure that they don't cause catastrophic failure of the entire system. For decades, transactions have been the mechanism of choice for simplifying these issues. A transaction is a way for an application to group several reads and writes together into a logical unit. Either the entire transaction succeeds (commit) or it fails (abort, rollback). If it fails, the application can safely retry.
白話翻譯

在資料系統嚴酷的現實裡,很多事情都可能出錯。

為了做到可靠,系統必須處理這些故障,確保它們不會演變成整個系統的災難性失效。

數十年來,交易一直是簡化這些難題的首選機制。

交易是應用程式的一種手法:把好幾個讀取與寫入綁在一起,當成一個邏輯單位來執行。

要嘛整筆交易成功(提交 commit),要嘛失敗(中止 abort、回滾 rollback)。失敗時,應用程式可以安心重試。

💡
關鍵金句

交易不是大自然的定律,它是被「刻意發明」出來的,目的只有一個——簡化應用程式的程式設計模型。資料庫幫你扛下那些惱人的錯誤情境,這些承諾就叫 安全保證(safety guarantees)

先幫你分類:資料世界的種種意外

把上一屏的六種意外,收斂成書中真正討論的重點類別——這幾張卡片,之後每次遇到「系統又出怪事了」,都可以拿出來對照。

🖥️
硬體故障

資料庫軟體或機器隨時可能故障,甚至就在寫入操作進行到一半的時候。

🧨
應用程式當機

App 可能在一連串操作做到一半時崩潰,留下沒做完的半成品狀態。

📡
網路中斷

網路忽然斷線,切斷應用程式與資料庫、或資料庫節點之間的聯繫,誰也不知道對方成功了沒。

✍️
並行寫入互相覆蓋

好幾個 client 同時寫入同一份資料,後寫的把先寫的蓋掉,有人的更新莫名其妙消失。

🎲
競態條件

多個 client 的操作彼此交錯,結果隨執行先後順序而變,產生偶發、難以重現的怪 bug。

😮‍💨
如果每種意外都要自己扛

實作容錯機制是大量的工作——需要對「所有可能出錯的事」做非常仔細的思考,還要做大量測試確保解法真的有效。如果每一種錯誤都要在應用程式碼裡自己處理,程式會複雜到難以維護。這正是這一節想讓你「親身感受到的痛」。

跑一遍看看:同一次轉帳,有沒有交易差在哪

應用程式要做一連串寫入(例如轉帳:A 扣款、B 加款)。按「下一步」,先看「沒有交易保護」中途當機會多慘,接著看「有交易保護」時資料庫怎麼幫你善後。

⚙️
應用程式
💥
中途當機/斷線
🗄️
資料庫
按「下一步」開始這趟旅程
🔁
同一場事故,兩種下場

當機的時間點一模一樣,差別只在有沒有把兩步寫入包進一筆交易。沒有交易:資料庫留下「A 少了、B 沒收到」的爛尾帳。有交易:資料庫自動撤銷已做的部分,回到轉帳前的乾淨狀態——應用程式因此敢放心重試。

兩個極端迷思,作者都不買單

2000 年代末 NoSQL 資料庫爆紅,很多新世代資料庫乾脆拋棄交易,或把「交易」重新定義成弱很多的保證。江湖上於是出現兩派極端說法:

🏎️
迷思一:交易是擴展性天敵

「想做大規模系統?那就一定得拋棄交易,否則別想要好效能和高可用性。」——把交易和擴展性描繪成水火不容的死對頭。

🛡️
迷思二:正經應用必備品

「處理有價值的資料,就一定要有交易保證,否則不專業。」——把交易包裝成沒有它就會出事的萬靈丹。

🏍️
安全帽的比喻

交易就像騎機車戴安全帽——安全帽提供真實的保護(安全保證),也帶來真實的代價(悶熱、視野受限,對應效能與擴展開銷)。賽車手派喊「戴帽絕對騎不快」,安全狂派喊「不戴就是不負責任」,兩派都把片面成本或好處放大成絕對真理。作者說:兩種說法都是純粹的誇大其詞(pure hyperbole)。真相是——就像其他任何技術設計選擇一樣,交易有它的優點,也有它的限制。

小試身手

資料世界的意外清單、還有交易的「全有或全無」承諾,來檢查一下有沒有抓到重點。

電商系統執行「扣庫存」與「建立訂單」兩步,在扣完庫存、但還沒建立訂單時就因硬體故障當機。這最直接體現了哪個問題?
關於「交易是擴展性天敵」與「交易是正經應用必備品」這兩種說法,書中的立場最接近下列何者?
🔤
下一站:ACID

「交易」聽起來像萬靈丹,但它到底承諾了什麼、又沒承諾什麼?拆開 A-C-I-D 四個字母,你會發現有一個字母的意思跟你想的完全不一樣。往下捲。

02

ACID 四大保證的真面目

同一張「鮮榨認證」貼紙,貼在四家不同的店——先拆開字母,才知道你買到的到底是什麼

「ACID compliant」聽起來很安心,但它其實沒告訴你什麼

資料庫要展示自己「靠得住」,最常搬出來的招牌就是 ACID。 這四個字母是 1983 年 Theo Härder 與 Andreas Reuter 提出的,目的是替「資料庫的容錯機制」建立一套精確術語。聽起來很嚴謹,對吧?

但書裡直接潑了一盆冷水:一家資料庫的 ACID 實作,不等於另一家的實作。「高層次的概念」本身是健全的,問題出在細節——尤其是 隔離性(Isolation) 的含義,各家解讀天差地別。結果就是,當一個系統說自己「ACID compliant」時,你其實搞不清楚它到底保證了什麼——ACID 很遺憾地大多變成了一個行銷詞彙。

🧋
手搖飲店門口的「鮮榨認證」

想像四家手搖飲店都貼著同一張閃亮標章:「ACID 鮮榨認證」。A 店每杯獨立搖杯絕不串味;B 店只是換了根吸管就敢貼。貼紙一樣,品質天差地別——這正是「一家資料庫的 ACID 實作不等於另一家」的真實寫照。看到這張貼紙,別高興得太早,要追問:你這家店的「鮮榨」到底是怎麼做的?

那不符合 ACID 標準的系統呢?它們有時被叫做 BASE ——聽起來更玄,但書中直言,BASE 唯一說得通的定義,幾乎就是「反正不是 ACID」,幾乎可以指任何你想要的東西。

🔑
追問,而不是照單全收

看到「ACID compliant」或「serializable」這類字眼時,把它當成一連串追問的起點:隔離等級到底是哪一級?持久性的定義是寫進單機磁碟,還是要複製到 N 個節點?書中舉例,Oracle 有個叫「serializable」的隔離等級,實際上只做到較弱的 snapshot isolation。標籤從來不是答案。

直接讀原文:A、I、D 才是資料庫的,C 呢?

這一段是整章最關鍵的反轉。書裡把四個字母拆開,你會發現三個是資料庫的責任,剩下那個字母,其實另有主人。

原文 · DDIA Ch.7 However, in practice, one database's implementation of ACID does not equal another's implementation. Rather, ACID atomicity describes what happens if a client wants to make several writes, but a fault occurs after some of the writes have been processed. However, this idea of consistency depends on the application's notion of invariants, and it's the application's responsibility to define its transactions correctly so that they preserve consistency. Atomicity, isolation, and durability are properties of the database, whereas consistency (in the ACID sense) is a property of the application. Durability is the promise that once a transaction has committed successfully, any data it has written will not be forgotten, even if there is a hardware fault or the database crashes.
白話翻譯

但實務上,一家資料庫對 ACID 的實作,並不等於另一家的實作。

ACID 的原子性講的是:當一個 client 想做好幾筆寫入,卻在做到一半時發生故障,會發生什麼事。

但一致性這個概念,取決於應用程式自己定義的不變量——是應用程式的責任,去把交易寫對,讓它們維持一致性。

原子性、隔離性、持久性是資料庫的屬性;而 ACID 意義下的一致性,是應用程式的屬性。

持久性是一個承諾:一旦交易成功提交,它寫入的任何資料,就不會因為硬體故障或資料庫當機而被遺忘。

⚠️
C 其實是最容易被誤解的一個

ACID 裡的一致性(Consistency)不是資料庫能單方面保證的東西——它是應用程式的責任。資料庫只能幫你檢查少數幾種特定不變量(像外鍵約束、唯一性約束),至於「什麼資料才算合法」這種業務規則,是你的程式該負責定義並維持的。連資料庫學者 Joe Hellerstein 都打趣說,C 是「為了讓縮寫唸得出來才硬塞進去的」——嚴格說,C 並不真正屬於 ACID。

點點看:拆開四個字母,各自的真面目

別再把 ACID 當成一整塊招牌背下來。點下面四張卡片,看每個字母在「實務上」真正代表什麼——跟你直覺想的可能不太一樣。

🧩 A · Atomicity
⚖️ C · Consistency
🔒 I · Isolation
💾 D · Durability
A · Atomicity(原子性)真面目:跟『並行』完全無關(那是 I 的事)。它講的是:一連串寫入做到一半故障了怎麼辦——資料庫要能安全地 abort(中止)這筆交易,把已經做的寫入全部撤銷,讓應用程式可以放心重試。書裡說,叫它 abortability(可中止性)其實更貼切:重點不是『不可分割』,而是『中途失敗要能安全放棄』。

值得注意的是, 可中止性(abortability) 這個詞,其實比原子性更能點出這個保證的本質——因為多執行緒程式裡的「atomic」講的是別人看不到中間狀態,那其實對應到 ACID 的 隔離性, 跟原子性是兩件不同的事。

💾
持久性沒有終點,只有層層加保險

寫進磁碟能擋機器掛掉;複寫到多節點能擋單機故障;異地備份能擋硬碟全毀。但如果整批硬碟與備份「同時」出事(書裡叫 相關故障(correlated fault) ),資料庫也無能為力。連斷電時的 SSD,有時都會違反自己號稱的保證。結論:持久性沒有單一銀彈,只有一層層疊加的風險降低手段。

A 和 I 真正的舞台:一個物件,還是好幾個?

原子性和隔離性的定義,其實都預設了一件事:你想同時修改「好幾個」物件(rows、documents、records)。這種交易就叫 多物件交易

1
為什麼會需要改好幾個物件

經典例子:email app 想顯示「未讀訊息數」,若每次都用 COUNT(*) 太慢,就額外存一個計數器欄位——這是 去正規化。 現在「信件」和「計數器」變成兩個物件,新信來要 +1、讀了要 -1,必須同步更新。

2
少了原子性與隔離性會怎樣

信插入了,計數器卻沒更新——使用者看到信箱有新信、計數卻還是 0,這是一次 dirty read。沒有原子性,錯誤處理會超級複雜:你不知道哪些改動生效了;沒有隔離性,會冒出各種併發問題。

3
單一物件也需要 A 和 I

寫一份 20KB 的 JSON 到資料庫,網路斷在一半,資料庫該存那截斷的 10KB 嗎?斷電時新舊值黏在一起嗎?別人讀到寫一半的值嗎?儲存引擎幾乎都會在「單一物件」層級保證原子性與隔離性——用 log 做故障復原、用鎖限制單次只有一個 thread 存取。

🔢
單物件的聰明招式:increment 與 compare-and-set

很多資料庫提供 incrementcompare-and-set 這類單物件原子操作,能避免手寫危險的 read-modify-write 循環,也能防止併發下的 lost update。但它們不是一般意義下的交易——就算有些廠商把它們稱作「lightweight transaction」甚至「ACID」,交易真正指的是把「多個物件」的操作綁成一個單位,這件事它們做不到。

動手配配看:這是「單一物件」還是「多物件」操作?

把下面幾個操作,拖到它該歸類的分類。配完按「對答案」。

對一份完整 JSON document 內的單一欄位做一次性更新
用內建 increment 把一個計數器安全地加 1
插入一封新信,同時把對應的未讀計數器加 1
插入多筆互相以外鍵參照的紀錄,需保證參照即時正確
改一個值時,同步更新它對應的次要索引
單一物件操作——儲存引擎在單機層級就能保證原子性與隔離性,不需要多物件交易
拖到這裡
多物件操作——牽涉兩個以上物件,必須靠 BEGIN…COMMIT 綁成一個交易,才能避免半成品狀態
拖到這裡
📦
NoSQL 的 multi-put 不等於交易

很多非關聯式資料庫沒有把多筆操作「分組」的機制。就算它提供 multi-put(一次更新好幾個 key),也不代表有交易語意——指令可能對某些 key 成功、某些 key 失敗,讓資料庫停在部分更新的狀態。想要真正的全有全無,得先確認它是否具備交易語意,不能只看操作的名字。

小試身手

ACID 四個字母的真面目、還有多物件交易的道理,來檢查一下有沒有記牢。

為什麼有人說 ACID 裡的字母 C「其實不太該待在裡面」?
某 app 額外存了「未讀訊息數」欄位,每來一封新信要插入信件並把計數器 +1。若不用交易把這兩步綁在一起,最可能出現什麼問題?
🔒
下一站:弱隔離等級

多個交易同時發生時,資料庫通常不會給你最強的保護——那些「看起來沒事、其實暗藏危機」的並行陷阱,才是這一章真正硬的部分。往下捲,看看 I 這個字母背後藏著多少層次。

03

弱隔離等級(上):讀已提交與快照隔離

為什麼「多人同時動同一筆資料」這麼難搞——先看兩種最基本的防護網怎麼張開

並行 bug:只在「時機不巧」才現身的幽靈

先講一個原則:如果兩個交易碰的不是同一筆資料,它們可以放心平行跑,彼此互不相干。 並行(concurrency) 真正會出事,只有兩種情況:一個交易在讀某筆資料,卻被另一個交易同時改掉;或是兩個交易同時想改同一筆資料。這時就出現了 競態條件(race condition)

1
只在時機不巧時觸發

bug 只有在交易「剛好」用某種倒霉順序交錯時才會現身,平常跑一萬次都正常,測試很難撞見它。

2
極難重現

這種時機問題可能非常罕見地發生,你想複製它來除錯,卻怎麼也喬不出當時的時機。

3
極難推理

在大型應用裡,你根本不知道還有哪些程式碼也在碰同一筆資料——任何資料都可能在任何時刻被意外改掉。

最經典的例子:兩個 client 同時對一個值是 42 的計數器做「讀取 → 加 1 → 寫回」。理應變成 44,但如果時機不巧——A 讀到 42、B 也讀到 42(A 還沒寫回)、A 寫回 43、B 也用過時的 42+1 寫回 43,蓋掉了 A——結果只有 43,一次遞增憑空消失。這叫 更新遺失(lost update)

📝
兩個室友的共用記帳本

想像你和室友共用一本貼在冰箱上的支出帳本,寫著「本月共支出 42 元」。你們各自買了東西要加 1 元,動作都是「看一眼數字 → 心裡加 1 → 寫上新數字」。如果剛好同時動手:你瞄到 42,室友也瞄到 42(你還沒寫回去),你寫上 43,室友拿著腦中的「42 加 1」也寫上 43,把你的 43 蓋掉了。帳本停在 43,但你們其實花了兩筆——有一筆人間蒸發。平常一前一後不會出事,只有「剛好同時」這個罕見時機才會出錯,事後翻帳本也看不出剛剛發生了什麼。

💸
「用 ACID 資料庫就好」是個迷思

弱交易隔離造成的並行 bug 不只是理論問題,真實世界曾造成鉅額金錢損失、引來財務稽核調查、導致客戶資料毀損。「處理金融資料就用 ACID 資料庫」這句話沒抓到重點:即使是大家公認 ACID 的主流關聯式資料庫,預設往往也用較弱的 弱隔離等級(weak isolation), 只能防住部分競態條件,不是全部。與其盲目依賴工具,我們需要對「存在哪些並行問題、以及如何防範」建立好理解。

直接讀原文:弱隔離等級的登場理由

書上先把「隔離」的理想講清楚,再老實承認:現實中很少人真的付得起最強隔離的代價。

原文 · DDIA Ch.7 If two transactions don't touch the same data, they can safely be run in parallel, because neither depends on the other. Concurrency issues (race conditions) only come into play when one transaction reads data that is concurrently modified by another transaction, or when two transactions try to simultaneously modify the same data. Concurrency bugs are hard to find by testing, because such bugs are only triggered when you get unlucky with the timing. Serializable isolation means that the database guarantees that transactions have the same effect as if they ran serially — one at a time, without any concurrency. Serializable isolation has a performance cost, and many databases don't want to pay that price. It's therefore common for systems to use weaker levels of isolation, which protect against some concurrency issues, but not all.
白話翻譯

如果兩個交易碰的不是同一筆資料,它們可以安心平行跑,因為誰也不依賴誰。

並行問題(競態條件)只有在一個交易讀到被另一個交易同時修改的資料、或兩個交易同時想改同一筆資料時,才會登場。

並行 bug 很難靠測試抓到,因為這種 bug 只有在你「運氣不好、時機不巧」時才會被觸發。

可序列化隔離的意思是:資料庫保證交易的效果,就跟它們一個接一個依序執行、完全沒有並行一樣。

可序列化隔離要付效能代價,很多資料庫不想付這個錢。所以系統普遍改用較弱的隔離等級——能防住一部分並行問題,但不是全部。

🔑
一句話記住這整節的骨架

本節要拆的 Read Committed 與 Snapshot Isolation,都是「較弱、較便宜」的隔離等級——它們各自擋住一部分並行問題,但都不是萬靈丹。看懂它們各自的邊界,才是真本事。

跑一遍時間軸:T1 還沒提交,T2 想讀、想寫,會怎樣?

兩筆交易 T1、T2 交錯動同一筆資料。按「下一步」,先看沒有防護時會出什麼問題,再看 Read Committed(讀已提交) 怎麼把這兩種問題擋下來。

🔵
交易 T1
🗄️
資料庫某一行
🟠
交易 T2
按「下一步」開始這趟時間軸
🚗
髒寫的災難現場:二手車網站

書中的經典例子——Alice 和 Bob 同時想買同一台車。買車需要兩筆寫入:更新 listings 標記買家、更新 invoices 寄發票。如果沒有防髒寫,可能出現 Bob 贏得了 listings 的更新(車登記給 Bob),Alice 卻贏得了 invoices 的更新(發票寄給 Alice)——車給 Bob、發票寄 Alice。Read Committed 的做法通常是用行級鎖(row-level lock):誰要改某物件就先拿到那物件的鎖,握到自己 commit 或 abort,其他想寫同一物件的交易只能排隊等,避免這種寫入被攪混的下場。

Read Committed 擋得住什麼,擋不住什麼?

Read Committed 只給你兩個保證:讀的時候只看得到已提交的資料(防 髒讀 dirty read), 寫的時候只覆寫已提交的資料(防 髒寫 dirty write)。 就這兩條。它不是萬靈丹——回到本節開頭那個計數器 42 的例子:兩個交易各自把計數器讀出、加 1、寫回,第二次寫回時第一筆交易早已 commit,所以覆寫的是「已提交值」——不是髒寫,Read Committed 樂意放行。結果兩次 +1 卻只加到 43,這就是更新遺失(lost update),要靠後面小節的原子操作或鎖來解。

🛡️
擋得住:髒讀與髒寫

你看不到別人「還沒寫完」的東西,也不會踩到別人「還沒寫完」的東西。這是 Read Committed 唯二的承諾,也是它成為多數資料庫(PostgreSQL、Oracle 11g、SQL Server 2012 等)預設等級的原因。

🕳️
擋不住:更新遺失

讀-改-寫循環中,第二次寫回發生在第一筆已 commit 之後,不算髒寫,Read Committed 完全不管。計數器、餘額這類「讀了再改」的資料要格外小心。

🌀
擋不住:讀偏斜(下一段細講)

同一筆交易前後讀到「不同時間點」的已提交資料,拼起來卻不一致——每個值單看都是真的,合起來卻很荒謬。Read Committed 認為這完全合法。

100 元怎麼憑空消失的?認識讀偏斜與快照隔離

Alice 在銀行有 1,000 元存款,分散在兩個帳戶,各 500 元。系統正在把 100 元從帳戶 1 轉到帳戶 2——這其實是兩筆寫入:先扣帳戶 1(變成 400),再加帳戶 2(變成 600)。如果 Alice 運氣不好,剛好在這兩步「中間」查餘額,她可能讀到帳戶 1 已經扣完(400)、帳戶 2 還沒加上(500),加起來只有 900 元,100 元像是憑空蒸發了。

錢其實一塊都沒少,Alice 只是在錯誤的瞬間看到了不一致的中間狀態。這種現象叫 讀偏斜(read skew), 又叫不可重複讀(nonrepeatable read)。在 Read Committed 等級下,這完全合法——因為 Alice 讀到的每個數字,在她讀的那一刻確實都是已提交的真實資料。

💾
短暫不一致,有時會變成永久災難

Alice 重新整理頁面就沒事了,但兩種情境無法容忍:備份(backups)——大型資料庫備份可能耗時數小時,若備份的某些部分是舊版、某些是新版,還原後「消失的錢」就變成永久的不一致;分析查詢與完整性檢查——掃描大量資料時若在不同時間點觀察資料庫的不同部分,會得出毫無意義的結果。

解方是 快照隔離(snapshot isolation)—— 核心思想一句話:每一筆交易都從資料庫的一致快照讀取,看到的是「交易開始那一刻」所有已提交的資料。之後即使別人改了資料,這筆交易仍只看到那個時間點凍結的舊資料。Alice 的查詢交易只會看到「轉帳前」或「轉帳後」的完整狀態,永遠不會撞見那個尷尬的 900 元中間態。

🏛️
博物館的「定格導覽」

Read Committed 像邊走邊看的現場:你走到帳戶 1 展間,策展人剛換上新展品(400);走到帳戶 2 展間,策展人還沒換到那裡(仍是 500)——兩件展品屬於不同瞬間,拼起來就矛盾。快照隔離則是「進場時拍一張全景定格照」:你一進館,館方就給你一副特製眼鏡,裡面是進場那一刻的全館畫面,之後不管展品怎麼換,你眼鏡裡永遠是那一刻的版本。逛遍全館,看到的都是同一時間點——總額永遠正確。

MVCC:博物館怎麼做到「從不丟掉舊展品」

快照隔離的實作關鍵祕密:資料庫從不真的覆蓋舊資料。這套機制叫 MVCC(多版本並行控制)

1
更新=刪除+新建,不是覆蓋

交易要更新某一行時,不是把舊值抹掉換新值,而是把新版擺到旁邊:舊列標記 deleted_by(哪個交易讓它下架),新列標記 created_by(哪個交易建立它),兩個版本並排保存。

2
用 txid 判斷誰能看見哪個版本

每筆交易靠 交易 ID(txid) 判斷可見性:建立此版本的交易必須在「我開始時」已經提交,而且這個版本沒被一個「我看得到」的刪除蓋掉,才算可見。

3
讀者永不阻擋寫者,寫者永不阻擋讀者

戴眼鏡看定格照的遊客(讀取)完全不會妨礙策展人換展(寫入);策展人只是並排擺新品,也不會逼遊客停下等候——因為舊版一直保留著。等到沒有任何交易還需要某個舊版本,垃圾回收(PostgreSQL 裡叫 VACUUM)才會把它真正清掉。

名字會騙人:同一杯「中杯」在不同店裡容量不同

各家資料庫對快照隔離的命名很混亂:PostgreSQL 與 MySQL 叫它 REPEATABLE READ,Oracle 叫它 SERIALIZABLE(但其實比真正的可序列化弱),SQL Server 才誠實地叫 SNAPSHOT。根源是 SQL 標準基於 1975 年的定義,當時快照隔離還沒被發明,標準裡只有長得很像的 repeatable read,各家便借用這個名字宣稱「符合標準」。教訓:看到隔離等級的名字,別急著望文生義——要去查它實際擋掉哪些異常。

小試身手

Read Committed 和快照隔離各自的邊界在哪裡?來兩題檢查一下。

兩個交易各自把值為 42 的計數器讀出、加 1、寫回,結果只變成 43 而非 44。為什麼 Read Committed 擋不住這個問題?
Alice 的兩個帳戶各 500 元,轉帳把 100 元從帳戶 1 轉到帳戶 2 的途中,她查詢看到帳戶 1 是 400、帳戶 2 是 500,總額顯示少了 100 元。這個現象叫什麼?
🕵️
下一站:更隱晦的並行陷阱

快照隔離擋掉了髒讀髒寫,但還有兩種異常連很多資深工程師都會踩——丟失更新與寫入偏斜。往下捲,看看它們怎麼在你以為「已經夠安全」的地方悄悄搞事。

04

弱隔離等級(下):丟失更新、寫入偏斜與幻讀

同一列被蓋掉是小案;沒人蓋誰的資料、卻整體歪掉——這才是真正難防的並行 bug

先從最直覺的案例開始:計數器怎麼「加了兩次卻只加了一次」

資料庫裡有個計數器 value = 42,兩個交易都想幫它加 1。資料庫沒有內建的加法指令,程式只能自己做三步:出目前值、在記憶體裡成新值、再回去——這叫 讀-改-寫循環。 一個人做沒事,兩個交易「同時」做,就會出事。

1
兩個交易都讀到 42

交易 A、交易 B 幾乎同時查詢,兩邊都拿到同一個舊值:42。

2
各自心算 42 + 1 = 43

兩邊各自在自己的記憶體裡算出「新值應該是 43」。

3
先後寫回 43

A 寫回 43,B 也寫回 43。後寫的那筆把前一筆蓋掉(clobber)——最終結果是 43,不是該有的 44。

這就是 丟失更新(lost update)。 它常出現在遞增計數器/帳戶餘額、修改 JSON 文件裡的一個欄位、或兩人同時編輯同一個 wiki 頁面全文送回——本質都是讀-改-寫。

⚠️
別跟「髒寫」搞混

這裡第二次寫入,發生在第一個交易「已經提交之後」——不是覆蓋未提交的值,所以不是髒寫(dirty write),read committed 也擋不住它。它錯在另一個地方:兩個交易都基於同一個「已經過時」的快照值在做計算。

共用記帳本上的一元

茶水間有本「咖啡基金」記帳本,寫著 42 元。小明、小華都想投 1 元:各自看一眼(讀 42)、心算加 1(改成 43)、寫上新數字(寫 43)。兩人幾乎同時做,結果帳本上先後都寫成 43——明明收了 2 元,卻只記了 1 次加總,1 元憑空消失。

直接讀原文:書怎麼定義丟失更新

這一段是丟失更新問題最原汁原味的定義,順便列出它常出現的三種情境。

原文 · DDIA Ch.7 The lost update problem can occur if an application reads some value from the database, modifies it, and writes back the modified value (a read-modify-write cycle). If two transactions do this concurrently, one of the modifications can be lost, because the second write does not include the first modification. We sometimes say that the later write clobbers the earlier write. Incrementing a counter or updating an account balance requires reading the current value, calculating the new value, and writing back the updated value. Two users editing a wiki page at the same time, where each user saves their changes by sending the entire page contents to the server, overwriting whatever is currently in the database.
白話翻譯

丟失更新問題會發生在:應用程式從資料庫讀出某個值、修改它、再把修改後的值寫回去(讀-改-寫循環)。

如果兩個交易並行做這件事,其中一個修改可能會丟失,因為第二次寫入沒有包含第一次的修改。

我們有時會說,後面的寫入「蓋掉」了前面的寫入。

遞增計數器或更新帳戶餘額,都需要先讀目前值、算出新值,再寫回更新後的值。

兩個使用者同時編輯同一個 wiki 頁面,各自把整頁內容送回伺服器儲存——結果會直接覆蓋掉資料庫裡目前的內容。

🔧
四招防丟失更新,優先順序很清楚

原子寫入(如 UPDATE counters SET value = value + 1)最省事,能用就優先用;規則太複雜就上 明確鎖定(FOR UPDATE); 有些資料庫能 自動偵測丟失更新並重試(PostgreSQL、Oracle、SQL Server 支援,但 MySQL/InnoDB 的 repeatable read 不支援);沒有交易可用時,還有 compare-and-set——寫入時附帶「前提是現在還是舊值」的條件。

招牌案例:兩位值班醫生,各自的決定都合法,合起來卻違規

丟失更新至少還「蓋到了同一格資料」,看得出問題在哪。書中接下來這個案例更陰險——兩個交易改的是不同的物件,資料庫完全不覺得它們互相衝突。按「下一步」,看 Alice 和 Bob 怎麼一起搞砸值班表。

👩‍⚕️
醫生 Alice
🗄️
值班紀錄
👨‍⚕️
醫生 Bob
按「下一步」開始

這個現象叫 寫入偏斜(write skew)。 它既不是髒寫、也不是丟失更新——因為 Alice 和 Bob 更新的是兩個不同的物件(各自的值班紀錄)。問題不在任何一步「寫錯」,而是「檢查條件」與「根據條件寫入」之間出現了空隙:兩人都基於同一份即將過期的 前提(premise)做判斷,而那個前提在他們做決定的同時,已經被對方的動作悄悄推翻了。

🚉
寫入偏斜是丟失更新的「一般化版本」

兩個交易讀同一組資料、更新「某些」物件——如果那些被更新的物件剛好是同一個,就退化成丟失更新(或髒寫);如果是不同物件,就是更一般的寫入偏斜。如果 Alice 和 Bob 是先後操作,第二個人查值班紀錄時就會看到「只剩 1 人」而被擋下——問題完全來自「同時」發生。

寫入偏斜的固定劇本,還有能防的招式

醫生值班、會議室預訂、多人遊戲棋子、帳號註冊、帳戶提款——書中列的例子表面不同,骨子裡都是同一套三步驟劇本:

1
SELECT 檢查前提

查一下某個條件是否成立:還有幾位醫生值班?這個時段房間有沒有被訂?帳戶裡還有錢嗎?

2
依結果做決定

應用程式根據第 1 步查到的結果,決定要繼續動作、還是回報錯誤中止。

3
寫入並 commit

決定繼續就寫入資料庫——而這筆寫入,恰好會改變第 1 步查詢原本的結果。

醫生值班這個例子還算好處理:第 3 步要改的列,正好就是第 1 步查出來的列,所以能在第 1 步就用 SELECT ... FOR UPDATE 把那幾列鎖住,逼另一個交易排隊等待。書中在無法切到可序列化隔離時,就是建議用這招當第二選擇:

CODE
BEGIN TRANSACTION;
SELECT * FROM doctors
WHERE on_call = true AND shift_id = 1234 FOR UPDATE;
UPDATE doctors SET on_call = false
WHERE name = 'Alice' AND shift_id = 1234;
COMMIT;
白話翻譯

先鎖住這個班次「目前所有值班中」的醫生紀錄——只要有人想同時讀這幾列,就得先排隊。

拿到鎖之後,再把 Alice 的紀錄改成「不值班」。

因為 Bob 也想讀同一批列做同樣的檢查,他必須等 Alice 這筆交易 commit 完才能繼續——不會再兩邊同時通過檢查。

🔒
FOR UPDATE 有一個致命前提

它只能鎖「查詢回傳的既有列」。醫生值班之所以能鎖,是因為要改的列本來就在查詢結果裡。但如果檢查的是「這個時段房間沒有被訂」「這個使用者名稱還沒被註冊」——查詢回傳零列,FOR UPDATE 無物可鎖。這正是下一屏要講的幽靈:幻讀(phantom)

幻讀:連鎖都掛不上去的敵人

幻讀的定義,就是:一個交易裡的寫入,改變了另一個交易某個搜尋查詢會匹配到的那組列。

原文 · DDIA Ch.7 A SELECT query checks whether some requirement is satisfied by searching for rows that match some search condition. The effect of this write changes the precondition of the decision — if you were to repeat the SELECT query after committing the write, you would get a different result. They check for the absence of rows matching some search condition, and the write adds a row matching the same condition. If the query doesn't return any rows, SELECT FOR UPDATE can't attach locks to anything. This effect, where a write in one transaction changes the result of a search query in another transaction, is called a phantom. If the problem of phantoms is that there is no object to which we can attach the locks, perhaps we can artificially introduce a lock object into the database.
白話翻譯

一個 SELECT 查詢,透過搜尋符合某條件的列,來檢查某個需求是否滿足。

這筆寫入的效果,改變了第 2 步做決定所依賴的前提——如果你在這筆寫入 commit 之後重跑同一個 SELECT,會得到不同的結果。

這些情境檢查的是「符合某條件的列不存在」,而寫入卻新增了一列符合同一條件的列。如果第 1 步的查詢沒有回傳任何列,SELECT FOR UPDATE 就沒有東西可以鎖。

這種「一個交易的寫入,改變了另一個交易搜尋查詢的結果」的效應,就叫做幻讀(phantom)。

如果幻讀的問題是「沒有物件可以掛鎖」,那我們或許可以人工在資料庫裡塞進一個專門用來鎖的物件?

會議室預訂就是幻讀的經典場景:兩個交易各自查「這個時段有沒有人訂」,都看到「沒有」,於是都插入了一筆新預訂——衝突來自一列當時根本還不存在、隨後才被插入列(phantom)。 解法叫 物化衝突(materializing conflicts)—— 先把「房間 × 時段」所有組合都預先建成一張表(純粹當鎖用,不存預訂內容),要訂房前先把對應的列鎖起來,幻讀就變成了實體列上的鎖衝突。

📚
圖書館的空格位

小美、阿強都想把書放上「A 區第 3 格」,兩人都看了一眼、都看到「空的」,於是都放書上去——同一格擠進兩本書。管理員後來想到一招:先把每個格位都做好一張牌子掛牆上,想放書就得先把牌子拿到手(鎖住牌子)才能檢查並放書。牌子本身不記書,純粹是拿來搶佔的鎖。

🚧
物化衝突是「最後手段」,不是首選

書中明講:物化衝突很難設計對、又容易出錯,還讓並行控制機制醜陋地滲入應用程式的資料模型。多數情況下,能用可序列化隔離解決,就優先用它;只有無計可施時才動用物化衝突。

動手配配看:三種異常,該用哪招防?

把下面三種並行異常,拖到它最對症的防範招式。配完按「對答案」。

丟失更新(Lost Update):兩交易讀寫同一個計數器,後者的寫入蓋掉前者
寫入偏斜(Write Skew):兩位醫生各自更新自己的值班紀錄,合起來卻沒人值班
幻讀(Phantom):兩筆會議室預訂各自檢查「沒人訂」都通過,結果同房同時段被訂兩次
能用資料庫內建的原子寫入指令表達的更動,優先讓資料庫一次做完讀-改-寫(例如 SET value = value + 1),或用明確加鎖逼交易排隊
拖到這裡
要改的列本來就在查詢結果裡,可以在檢查那一步就用 SELECT ... FOR UPDATE 顯式鎖定,逼後到的交易等待
拖到這裡
檢查的是「符合條件的列不存在」,查詢回傳零列、無物可鎖——只有索引鎖住整段搜尋範圍,或直接上可序列化隔離,才能真正防住
拖到這裡
🧭
三種異常的分界線

丟失更新:兩交易改同一個物件。寫入偏斜:兩交易改不同物件,卻共同破壞跨物件的前提。幻讀:連「不同物件」都還不存在,查詢回傳零列,鎖根本無處可掛,只能靠人工造鎖物件或整段隔離升級來擋。

小試身手

丟失更新、寫入偏斜、幻讀,這三個名詞背後的判斷力,來檢查一下有沒有抓對。

Alice 和 Bob 的交易為什麼能雙雙通過「至少兩位醫生值班」的檢查,最終卻造成沒人值班?
會議室預訂系統先 SELECT COUNT(*) 檢查有無衝突預訂,若為 0 就 INSERT 新預訂。為什麼在快照隔離下,這仍可能造成同一房間同一時段被重複預訂?
🎯
下一站:可序列化

如果每一種異常都要單獨補洞——原子操作、明確鎖定、物化衝突各顯神通——遲早會漏東漏西。有沒有一種隔離等級,直接保證「不管怎麼並行,結果都跟某種依序執行一樣」?往下捲,我們去見識可序列化隔離(serializable isolation)。

05

Serializability 可序列化:最強的保證

先看承諾本身,再拆解兩階段鎖定怎麼硬擋所有並行災難——順便釐清一組長得很像、卻完全不同的名詞

前面擋了一半,這次要把話說死

一路看下來,我們已經見過 dirty readlost updatewrite skewphantom——一堆會讓人半夜爬起來查 log 的並行災難。較弱的隔離等級,像 read committed、snapshot isolation,都只擋掉其中一部分,剩下的還是會漏。

更麻煩的是:隔離等級的名稱本身就很混亂(不同資料庫的「repeatable read」意義完全不同),你也很難判斷自己的程式在某個隔離等級下到底安不安全——尤其並行問題是 不確定性(nondeterministic) 的,運氣不好、時序剛好對上才會爆,幾乎無法穩定測試出來。

從 1970 年代開始,研究者給的答案一直很直接:用 serializability(可序列化) 就好。

🔒
承諾到底是什麼

可序列化保證:即使多個交易實際上是並行執行的,最終結果也保證和「一個接一個、完全沒有並行」地依序跑出來一模一樣。換句話說——如果每個交易單獨跑時是正確的,那麼它們並行跑時也保證正確。你不用再去煩惱「這段程式在這個隔離等級下安不安全」這種燒腦問題,因為 race condition 被整套擋掉了。

那為什麼不是大家都在用?因為它有代價——最強的保證通常最貴。所以重點從來不是「該不該用」,而是「怎麼實作才不會太慢」。接下來我們會先快速認識三種主流做法,再把重點放在其中最常見的一種:兩階段鎖定

直接讀原文:兩階段鎖定的核心規則

這一段是全書講兩階段鎖定最關鍵的定義句——鎖怎麼分兩種模式、怎麼互相卡住,一句一句對照著看。

原文 · DDIA Ch.7 Note that while two-phase locking (2PL) sounds very similar to two-phase commit (2PC), they are completely different things. Several transactions are allowed to concurrently read the same object as long as nobody is writing to it. But as soon as anyone wants to write (modify or delete) an object, exclusive access is required. If transaction A has read an object and transaction B wants to write to that object, B must wait until A commits or aborts before it can continue. In 2PL, writers don't just block other writers; they also block readers and vice versa. Snapshot isolation has the mantra readers never block writers, and writers never block readers, which captures this key difference between snapshot isolation and two-phase locking.
白話翻譯

注意:兩階段鎖定(2PL)聽起來跟兩階段提交(2PC)很像,但它們是完全不同的兩件事。

只要沒有人在寫,多筆交易可以同時讀同一個物件。但只要有人想寫(修改或刪除)某個物件,就需要獨占存取。

如果交易 A 已經讀了某個物件,交易 B 想寫那個物件,B 就得等到 A commit 或 abort 之後才能繼續。

在 2PL 裡,寫者不只會擋住其他寫者,還會擋住讀者,反過來也一樣。

Snapshot isolation 的口號是「讀者永不擋寫者,寫者永不擋讀者」——這句話正好點出它跟兩階段鎖定的關鍵差異。

⚠️
2PL 不是 2PC,請不要再搞混了

兩階段鎖定(Two-Phase Locking,2PL)兩階段提交(Two-Phase Commit,2PC)名字幾乎一樣,中文縮寫也都愛叫「兩階段」,但它們是完全不同的兩件事:2PL 管的是並行控制——同一個資料庫裡,多筆交易同時跑該怎麼互相禮讓,好達成可序列化;2PC 管的是分散式交易的原子性——一筆交易橫跨多個節點時,怎麼確保大家「全部一起 commit,或全部一起 abort」,這件事本書留到討論分散式系統時才細講。這是本節刻意提醒的常見混淆,下面我們還會再回來對照一次。

動畫演給你看:一次鎖升級是怎麼被卡住的

交易 T1 想先讀一列資料、再改它;交易 T2 這時已經持有衝突的鎖。按「下一步」,看 T1 怎麼從「共享鎖」走到「被鎖管理器擋下來」,直到 T2 結束才被放行。

🔵
交易 T1
🔒
鎖管理器
🟠
交易 T2
按「下一步」開始
📚
圖書館比喻:只有一冊的參考書

把每個資料物件想成圖書館裡「只有一冊」的熱門參考書:很多人可以圍著桌子一起翻看(共享鎖),但只要有人想在書上做註記,就得把整本書借走獨占(獨占鎖)——此時別人連翻都不能翻。而且圖書館有個怪規矩:這趟造訪借過的書要一路抱著,直到辦完所有事、走到櫃台才一次全部歸還,這正是「兩階段」的由來。

兩階段鎖定的三條運作規則

把上面的動畫拆成規則寫下來,就是這三條——資料庫靠它們讓「寫擋讀、讀擋寫」,反過來擋掉幾乎所有並行災難。

想讀 → 先拿共享鎖

多筆交易可以同時持有同一物件的共享鎖一起讀;但只要該物件已經有人持有獨占鎖,就得排隊等。

想寫 → 先拿獨占鎖

不管物件上已有共享鎖還是獨占鎖,只要有任何既存的鎖,這筆交易就得等到全部釋放才能繼續。

取得的鎖,撐到交易結束才放

第一階段(執行中)只負責「取得」鎖,第二階段(commit 或 abort 時)才一次性「釋放」全部鎖——這就是「兩階段」的名字來源。

💡
代價:死鎖、還有效能

因為鎖用得又多又嚴,很容易發生 死鎖(deadlock)——資料庫會自動偵測、中止其中一筆交易讓另一筆能繼續,被中止的那筆得由應用程式重試整個交易。這也是為什麼兩階段鎖定的吞吐量與延遲,往往比較弱的隔離等級差上一截:它是用效能換來全面擋掉 race condition 的保證。

小試身手

可序列化的承諾、還有兩階段鎖定的運作方式,來檢查一下有沒有記牢。

下列哪一句最精準地描述 serializable isolation 對「並行執行」的承諾?
關於兩階段鎖定(2PL)與 snapshot isolation 在鎖行為上的差異,下列哪句正確?
🎲
下一站:樂觀的另一條路

2PL 靠加鎖硬擋一切,代價是效能——讀寫互擋、死鎖頻繁。但如果衝突其實很少見,能不能先讓大家樂觀地做,事後再檢查有沒有真的撞車?往下捲,看看可序列化快照隔離怎麼給出這個答案。

06

樂觀的賭注:SSI 與全章總結

先放行、提交才驗收——可序列化快照隔離怎麼用「絆線」取代「鎖」,以及整章隔離等級的最終盤點

兩種看待衝突的人生觀

資料庫要保證 可序列化, 但要怎麼達成?書裡把做法分成兩種根本不同的「個性」。

悲觀並行控制

「如果有任何事可能出錯,就先停下來等,等到安全了再動作。」代表作是兩階段鎖定(2PL):碰資料前先拿鎖,別人拿著你就乖乖等——像多執行緒程式裡的互斥鎖,一次只准一個人進場。更極端的序列執行,等於讓每個交易對整個資料庫上一把獨佔鎖,靠「交易跑超快」來彌補這種悲觀。

樂觀並行控制

「先讓大家動手做,賭它多半不會出事;真要提交時,再檢查有沒有踩到別人。」代表作正是本章主角 可序列化快照隔離(SSI): 遇到危險不擋,交易照跑;提交時才檢查隔離性有沒有被破壞,沒問題就放行,有問題就中止(abort)並要求重試(retry)。只有真能以可序列化方式執行的交易,才被允許提交。

🔑
共用會議室的兩種訂法

悲觀派:想用會議室先去總務領鑰匙,鑰匙在別人手上就站門口乾等——絕不撞期,但就算對方只是進去拿個東西(讀取),你也得等他出來,讀者卡住寫者、寫者卡住讀者。樂觀派:想用就直接進去用,等要正式登記進系統(commit)時,才回頭比對行事曆有沒有人也宣告佔用;沒撞到就登記成功,撞到了這場作廢、改時間重來。會議室常空著(低競爭)時,樂觀派幾乎不用等;但會議室爆紅、人人搶同一時段(高競爭)時,樂觀派會不斷撞期重訂,反而比排隊領鑰匙還累。

直接讀原文:悲觀 vs 樂觀並行控制

這一段是全書對兩種並行控制哲學最精準的定義,逐句對照白話。

原文 · DDIA Ch.7 Two-phase locking is a so-called pessimistic concurrency control mechanism: it is based on the principle that if anything might possibly go wrong, it's better to wait until the situation is safe again before doing anything. By contrast, serializable snapshot isolation is an optimistic concurrency control technique. Optimistic in this context means that instead of blocking if something potentially dangerous happens, transactions continue anyway, in the hope that everything will turn out all right. When a transaction wants to commit, the database checks whether anything bad happened; if so, the transaction is aborted and has to be retried. Only transactions that executed serializably are allowed to commit.
白話翻譯

兩階段鎖定是所謂的悲觀並行控制機制:它的原則是——只要有任何事可能出錯,最好先等到情況安全了才動手。

相對地,可序列化快照隔離是一種樂觀並行控制技術。

這裡的「樂觀」意思是:遇到潛在危險的事,不阻塞,交易照樣繼續跑下去,賭一把「應該都會沒事」。

當交易要提交時,資料庫才檢查有沒有出什麼問題;如果有,這個交易就會被中止,還得重試一次。

只有真的以可序列化方式執行完的交易,才被允許提交。

💡
SSI 跟以前的樂觀技術差在哪

SSI 之所以叫「Snapshot」+「Serializable」,是因為它蓋在快照隔離之上:交易的所有讀取都來自同一個一致的資料庫快照,這是它跟早期樂觀並行控制技術最大的不同。在這個基礎上,SSI 再加一套演算法,專門偵測寫入之間的序列化衝突,決定誰該被中止。

跑一遍看看:SSI 的樂觀流程

下面把 SSI 處理一筆交易的過程拆成幾步。按「下一步」,看交易怎麼從「樂觀地跑」走到「提交時才驗收」。

🏃
交易(樂觀執行)
📸
一致快照
🔍
提交時檢查
前提成立:提交成功
🔁
前提過時:中止重試
按「下一步」開始
🧵
SSI 的「鎖」更像一條絆線

2PL 用的是真正的鎖:你碰到別人鎖住的資料就得等。SSI 用的更像一條 絆線(tripwire)—— 交易照常跑,資料庫默默記錄「誰讀了什麼、誰寫了什麼」;只有在某交易要提交時,才檢查它的前提是否已被破壞,破壞了就中止它。這就是為什麼讀者不會擋寫者,寫者也不會擋讀者。

要抓的兩種「過時」,方向剛好相反

資料庫要問的只有一句話:「查詢結果有沒有可能已經變了?」答案分成兩種情境,剛好對應「修改在我讀之前」跟「修改在我讀之後」。

🕰️
情況一:讀到過時的 MVCC 版本

別人的寫入其實發生在「我讀之前」,只是當時還沒提交,所以依 MVCC 可見性規則被我忽略了。等我要 commit 時,資料庫回頭檢查:那些被我忽略的寫入,後來提交了嗎?提交了,我的前提就過時了。

✍️
情況二:寫入影響了先前的讀取

這次反過來——我讀完之後,別人才動手改了我讀過的那批資料。資料庫在索引項上記下「誰讀過這裡」,等別人寫入時就去查,通知我「你讀過的資料可能過時了」。誰先 commit 誰沒事,後到的那個就得中止。

🏥
值班醫生的例子,一步步走一遍

規則是「任何時段至少要有一位醫生 on-call」。Alice(交易 42)與 Bob(交易 43)同時查到 shift 1234 還有 2 位 on-call,各自覺得「我請假後還有 1 位,安全」,於是都把自己改成 off-call。資料庫在 shift_id = 1234 這個索引項上記下「42 讀過、43 讀過」;當 43 寫入時,去查發現 42 讀過,於是通知 42「你的讀取可能過時」,反之亦然——這個動作像取得寫鎖,但不阻塞,只當絆線。42 先提交,成功,因為此刻 43 的衝突寫入還沒生效;等 43 要提交時,發現來自 42 的衝突寫入已經提交了,43 必須中止。若沒有 SSI,兩人都成功,值班醫生就會變成 0 位——這正是寫入偏斜(write skew)

SSI 快在哪、又該在什麼情境下選它

SSI 的效能表現,可以濃縮成三個特性:

1
不阻塞(no blocking)

讀者不擋寫者、寫者不擋讀者,讀寫之間沒有鎖的爭用,查詢延遲更可預測。唯讀查詢可以直接在一致快照上跑,完全不需要任何鎖——對讀取繁重的工作負載非常有吸引力。

2
可分散式擴展(scalable)

和序列執行不同,SSI 不受單一 CPU 核心吞吐量的限制。FoundationDB 把「偵測序列化衝突」這件事分散到多台機器上,即使資料被切分到多台機器,交易仍能跨多個分區讀寫,同時維持可序列化。

3
偏愛短的讀寫交易

中止率深深影響整體效能——讀寫交易拖得越久,越可能撞上別人被迫中止。但好消息是:長時間的唯讀交易通常沒問題,因為它不寫資料,不會製造衝突。

那什麼時候該選 SSI?書中把關鍵濃縮成兩句:高競爭時表現差——很多交易搶同一批物件,大量中止重試,若系統已接近最大吞吐量,重試帶來的額外負載會讓效能雪上加霜;低競爭又有備援容量時表現更好——樂觀法往往勝過悲觀法。想降低競爭,還能改用可交換的原子操作,例如多個交易都要對同一計數器 +1,順序無所謂,這些並行遞增就能互不衝突地全部套用。

⚙️
一個工程權衡:追蹤粒度

資料庫要記錄每筆交易讀寫了哪些資料——記得細,能精準判斷該中止誰,但記帳開銷大;記得粗,速度快、開銷小,但可能中止比實際必要更多的交易。PostgreSQL 甚至用一套理論:有時某交易讀到被覆寫的舊值,只要能證明最終結果仍可序列化,就不必中止,藉此減少不必要的中止。

全章總結:隔離等級的光譜

整章走完一輪,我們把所有隔離等級擺回同一條光譜上——等級越強,擋下的並行災難越多:

讀已提交 Read Committed

擋住髒讀、髒寫——你不會讀到或覆寫別人尚未提交的資料。但擋不住讀偏斜、丟失更新、寫入偏斜、幻讀,是最基本的底線。

快照隔離 Snapshot Isolation(MVCC)

再擋住讀偏斜(不可重複讀)與直接幻讀——交易看到的是同一個一致快照。但寫入偏斜這種「讀後做決定、前提卻被推翻」的異常,快照隔離依然防不住。

可序列化 Serializable

三種實作路線:序列執行(單核循序,簡單但受單核吞吐量綁死)、兩階段鎖定 2PL(悲觀上鎖,數十年標準解法,但鎖等待讓延遲不穩)、SSI(樂觀檢查,讀寫互不擋、可跨機器擴展,但長交易會拉高中止率)。三者都能擋下寫入偏斜與全部幻讀,是唯一能防住寫入偏斜的等級。

⚖️
隔離等級越強,代價也越高——SSI 想兩者兼得

隔離等級越強,防住的並行異常越多,但效能代價也越高:讀已提交幾乎沒有額外成本;快照隔離要維護多版本;可序列化往往意味著鎖等待或中止重試。SSI 想在這條光譜的頂端,同時兼顧「強保證」與「好效能」——樂觀時期幾乎沒有鎖的代價,讀寫互不阻塞;只有在真的偵測到衝突時,才付出中止重試的代價。這正是為什麼書中認為,SSI 有機會成為將來可序列化隔離的新預設值。

來做最後兩題,檢查一下悲觀 vs 樂觀、以及 SSI 效能特性有沒有記牢。

下列哪一句最準確描述「悲觀」與「樂觀」並行控制在面對潛在衝突時的根本差異?
關於 SSI 相較於序列執行(serial execution)的可擴展性,下列敘述何者正確?
🌐
下一站:分散式系統最誠實的一面

下一站:把交易的世界搬到多台機器上會發生什麼事?第 8 章要直接面對分散式系統最誠實的一面——網路會不可靠、時鐘會騙人、任何時候都可能有一部分靜默故障。