為什麼我們需要交易?
先看資料世界有多混亂,再看「交易」怎麼用一句承諾把混亂收乾
資料世界的「嚴酷現實」:很多事都會出錯
第 7 章一開場,作者沒有先講漂亮的理論,而是先把「真實世界會出事的清單」整份攤開給你看。為什麼要先講壞消息?因為只有先體會過混亂,你才會明白後面要學的 交易(Transaction) 為什麼是個救星。
書中列出六種典型的 部分失敗 情境,我們把它拆開看:
資料庫程式或機器隨時可能掛掉——甚至就在寫到一半的時候。
你的 App 可能在「一連串操作做到一半」時當掉,該做的事只做了前幾步。
網路斷線讓 App 連不上資料庫、或讓資料庫節點彼此斷聯,不知道對方到底成功了沒。
好幾個 client 同時寫,互相覆蓋彼此的修改,有人的更新莫名消失。
client 讀到「只更新到一半」、根本不合理的資料,看到自相矛盾的畫面。
多個 client 的執行順序交錯,產生意想不到的 bug,偶發又極難重現。
一份總匯三明治要「烤吐司 → 煎蛋 → 夾火腿 → 加生菜 → 包裝」,這一連串動作就像資料庫裡的一連串讀寫。最可怕的不是整份三明治掉地上(全部失敗),而是「夾了火腿、還沒包裝,你就被叫去接電話」——這份半成品卡在中間,沒人知道該丟掉還是繼續做。這正是部分失敗。
直接讀原文:交易到底承諾了什麼
這一段是全章的地基句——先讀原文,感受作者怎麼用最精簡的英文,把「交易」這個概念一句話講清楚。
在資料系統嚴酷的現實裡,很多事情都可能出錯。
為了做到可靠,系統必須處理這些故障,確保它們不會演變成整個系統的災難性失效。
數十年來,交易一直是簡化這些難題的首選機制。
交易是應用程式的一種手法:把好幾個讀取與寫入綁在一起,當成一個邏輯單位來執行。
要嘛整筆交易成功(提交 commit),要嘛失敗(中止 abort、回滾 rollback)。失敗時,應用程式可以安心重試。
交易不是大自然的定律,它是被「刻意發明」出來的,目的只有一個——簡化應用程式的程式設計模型。資料庫幫你扛下那些惱人的錯誤情境,這些承諾就叫 安全保證(safety guarantees)。
先幫你分類:資料世界的種種意外
把上一屏的六種意外,收斂成書中真正討論的重點類別——這幾張卡片,之後每次遇到「系統又出怪事了」,都可以拿出來對照。
資料庫軟體或機器隨時可能故障,甚至就在寫入操作進行到一半的時候。
App 可能在一連串操作做到一半時崩潰,留下沒做完的半成品狀態。
網路忽然斷線,切斷應用程式與資料庫、或資料庫節點之間的聯繫,誰也不知道對方成功了沒。
好幾個 client 同時寫入同一份資料,後寫的把先寫的蓋掉,有人的更新莫名其妙消失。
多個 client 的操作彼此交錯,結果隨執行先後順序而變,產生偶發、難以重現的怪 bug。
實作容錯機制是大量的工作——需要對「所有可能出錯的事」做非常仔細的思考,還要做大量測試確保解法真的有效。如果每一種錯誤都要在應用程式碼裡自己處理,程式會複雜到難以維護。這正是這一節想讓你「親身感受到的痛」。
跑一遍看看:同一次轉帳,有沒有交易差在哪
應用程式要做一連串寫入(例如轉帳:A 扣款、B 加款)。按「下一步」,先看「沒有交易保護」中途當機會多慘,接著看「有交易保護」時資料庫怎麼幫你善後。
當機的時間點一模一樣,差別只在有沒有把兩步寫入包進一筆交易。沒有交易:資料庫留下「A 少了、B 沒收到」的爛尾帳。有交易:資料庫自動撤銷已做的部分,回到轉帳前的乾淨狀態——應用程式因此敢放心重試。
兩個極端迷思,作者都不買單
2000 年代末 NoSQL 資料庫爆紅,很多新世代資料庫乾脆拋棄交易,或把「交易」重新定義成弱很多的保證。江湖上於是出現兩派極端說法:
「想做大規模系統?那就一定得拋棄交易,否則別想要好效能和高可用性。」——把交易和擴展性描繪成水火不容的死對頭。
「處理有價值的資料,就一定要有交易保證,否則不專業。」——把交易包裝成沒有它就會出事的萬靈丹。
交易就像騎機車戴安全帽——安全帽提供真實的保護(安全保證),也帶來真實的代價(悶熱、視野受限,對應效能與擴展開銷)。賽車手派喊「戴帽絕對騎不快」,安全狂派喊「不戴就是不負責任」,兩派都把片面成本或好處放大成絕對真理。作者說:兩種說法都是純粹的誇大其詞(pure hyperbole)。真相是——就像其他任何技術設計選擇一樣,交易有它的優點,也有它的限制。
小試身手
資料世界的意外清單、還有交易的「全有或全無」承諾,來檢查一下有沒有抓到重點。
「交易」聽起來像萬靈丹,但它到底承諾了什麼、又沒承諾什麼?拆開 A-C-I-D 四個字母,你會發現有一個字母的意思跟你想的完全不一樣。往下捲。
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 呢?
這一段是整章最關鍵的反轉。書裡把四個字母拆開,你會發現三個是資料庫的責任,剩下那個字母,其實另有主人。
但實務上,一家資料庫對 ACID 的實作,並不等於另一家的實作。
ACID 的原子性講的是:當一個 client 想做好幾筆寫入,卻在做到一半時發生故障,會發生什麼事。
但一致性這個概念,取決於應用程式自己定義的不變量——是應用程式的責任,去把交易寫對,讓它們維持一致性。
原子性、隔離性、持久性是資料庫的屬性;而 ACID 意義下的一致性,是應用程式的屬性。
持久性是一個承諾:一旦交易成功提交,它寫入的任何資料,就不會因為硬體故障或資料庫當機而被遺忘。
ACID 裡的一致性(Consistency)不是資料庫能單方面保證的東西——它是應用程式的責任。資料庫只能幫你檢查少數幾種特定不變量(像外鍵約束、唯一性約束),至於「什麼資料才算合法」這種業務規則,是你的程式該負責定義並維持的。連資料庫學者 Joe Hellerstein 都打趣說,C 是「為了讓縮寫唸得出來才硬塞進去的」——嚴格說,C 並不真正屬於 ACID。
點點看:拆開四個字母,各自的真面目
別再把 ACID 當成一整塊招牌背下來。點下面四張卡片,看每個字母在「實務上」真正代表什麼——跟你直覺想的可能不太一樣。
值得注意的是, 可中止性(abortability) 這個詞,其實比原子性更能點出這個保證的本質——因為多執行緒程式裡的「atomic」講的是別人看不到中間狀態,那其實對應到 ACID 的 隔離性, 跟原子性是兩件不同的事。
寫進磁碟能擋機器掛掉;複寫到多節點能擋單機故障;異地備份能擋硬碟全毀。但如果整批硬碟與備份「同時」出事(書裡叫 相關故障(correlated fault) ),資料庫也無能為力。連斷電時的 SSD,有時都會違反自己號稱的保證。結論:持久性沒有單一銀彈,只有一層層疊加的風險降低手段。
A 和 I 真正的舞台:一個物件,還是好幾個?
原子性和隔離性的定義,其實都預設了一件事:你想同時修改「好幾個」物件(rows、documents、records)。這種交易就叫 多物件交易。
經典例子:email app 想顯示「未讀訊息數」,若每次都用 COUNT(*) 太慢,就額外存一個計數器欄位——這是
去正規化。
現在「信件」和「計數器」變成兩個物件,新信來要 +1、讀了要 -1,必須同步更新。
信插入了,計數器卻沒更新——使用者看到信箱有新信、計數卻還是 0,這是一次 dirty read。沒有原子性,錯誤處理會超級複雜:你不知道哪些改動生效了;沒有隔離性,會冒出各種併發問題。
寫一份 20KB 的 JSON 到資料庫,網路斷在一半,資料庫該存那截斷的 10KB 嗎?斷電時新舊值黏在一起嗎?別人讀到寫一半的值嗎?儲存引擎幾乎都會在「單一物件」層級保證原子性與隔離性——用 log 做故障復原、用鎖限制單次只有一個 thread 存取。
很多資料庫提供 increment 和 compare-and-set 這類單物件原子操作,能避免手寫危險的 read-modify-write 循環,也能防止併發下的 lost update。但它們不是一般意義下的交易——就算有些廠商把它們稱作「lightweight transaction」甚至「ACID」,交易真正指的是把「多個物件」的操作綁成一個單位,這件事它們做不到。
動手配配看:這是「單一物件」還是「多物件」操作?
把下面幾個操作,拖到它該歸類的分類。配完按「對答案」。
很多非關聯式資料庫沒有把多筆操作「分組」的機制。就算它提供 multi-put(一次更新好幾個 key),也不代表有交易語意——指令可能對某些 key 成功、某些 key 失敗,讓資料庫停在部分更新的狀態。想要真正的全有全無,得先確認它是否具備交易語意,不能只看操作的名字。
小試身手
ACID 四個字母的真面目、還有多物件交易的道理,來檢查一下有沒有記牢。
多個交易同時發生時,資料庫通常不會給你最強的保護——那些「看起來沒事、其實暗藏危機」的並行陷阱,才是這一章真正硬的部分。往下捲,看看 I 這個字母背後藏著多少層次。
弱隔離等級(上):讀已提交與快照隔離
為什麼「多人同時動同一筆資料」這麼難搞——先看兩種最基本的防護網怎麼張開
並行 bug:只在「時機不巧」才現身的幽靈
先講一個原則:如果兩個交易碰的不是同一筆資料,它們可以放心平行跑,彼此互不相干。 並行(concurrency) 真正會出事,只有兩種情況:一個交易在讀某筆資料,卻被另一個交易同時改掉;或是兩個交易同時想改同一筆資料。這時就出現了 競態條件(race condition)。
bug 只有在交易「剛好」用某種倒霉順序交錯時才會現身,平常跑一萬次都正常,測試很難撞見它。
這種時機問題可能非常罕見地發生,你想複製它來除錯,卻怎麼也喬不出當時的時機。
在大型應用裡,你根本不知道還有哪些程式碼也在碰同一筆資料——任何資料都可能在任何時刻被意外改掉。
最經典的例子:兩個 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,但你們其實花了兩筆——有一筆人間蒸發。平常一前一後不會出事,只有「剛好同時」這個罕見時機才會出錯,事後翻帳本也看不出剛剛發生了什麼。
弱交易隔離造成的並行 bug 不只是理論問題,真實世界曾造成鉅額金錢損失、引來財務稽核調查、導致客戶資料毀損。「處理金融資料就用 ACID 資料庫」這句話沒抓到重點:即使是大家公認 ACID 的主流關聯式資料庫,預設往往也用較弱的 弱隔離等級(weak isolation), 只能防住部分競態條件,不是全部。與其盲目依賴工具,我們需要對「存在哪些並行問題、以及如何防範」建立好理解。
直接讀原文:弱隔離等級的登場理由
書上先把「隔離」的理想講清楚,再老實承認:現實中很少人真的付得起最強隔離的代價。
如果兩個交易碰的不是同一筆資料,它們可以安心平行跑,因為誰也不依賴誰。
並行問題(競態條件)只有在一個交易讀到被另一個交易同時修改的資料、或兩個交易同時想改同一筆資料時,才會登場。
並行 bug 很難靠測試抓到,因為這種 bug 只有在你「運氣不好、時機不巧」時才會被觸發。
可序列化隔離的意思是:資料庫保證交易的效果,就跟它們一個接一個依序執行、完全沒有並行一樣。
可序列化隔離要付效能代價,很多資料庫不想付這個錢。所以系統普遍改用較弱的隔離等級——能防住一部分並行問題,但不是全部。
本節要拆的 Read Committed 與 Snapshot Isolation,都是「較弱、較便宜」的隔離等級——它們各自擋住一部分並行問題,但都不是萬靈丹。看懂它們各自的邊界,才是真本事。
跑一遍時間軸:T1 還沒提交,T2 想讀、想寫,會怎樣?
兩筆交易 T1、T2 交錯動同一筆資料。按「下一步」,先看沒有防護時會出什麼問題,再看 Read Committed(讀已提交) 怎麼把這兩種問題擋下來。
書中的經典例子——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(多版本並行控制)。
交易要更新某一行時,不是把舊值抹掉換新值,而是把新版擺到旁邊:舊列標記 deleted_by(哪個交易讓它下架),新列標記 created_by(哪個交易建立它),兩個版本並排保存。
每筆交易靠 交易 ID(txid) 判斷可見性:建立此版本的交易必須在「我開始時」已經提交,而且這個版本沒被一個「我看得到」的刪除蓋掉,才算可見。
戴眼鏡看定格照的遊客(讀取)完全不會妨礙策展人換展(寫入);策展人只是並排擺新品,也不會逼遊客停下等候——因為舊版一直保留著。等到沒有任何交易還需要某個舊版本,垃圾回收(PostgreSQL 裡叫 VACUUM)才會把它真正清掉。
各家資料庫對快照隔離的命名很混亂:PostgreSQL 與 MySQL 叫它 REPEATABLE READ,Oracle 叫它 SERIALIZABLE(但其實比真正的可序列化弱),SQL Server 才誠實地叫 SNAPSHOT。根源是 SQL 標準基於 1975 年的定義,當時快照隔離還沒被發明,標準裡只有長得很像的 repeatable read,各家便借用這個名字宣稱「符合標準」。教訓:看到隔離等級的名字,別急著望文生義——要去查它實際擋掉哪些異常。
小試身手
Read Committed 和快照隔離各自的邊界在哪裡?來兩題檢查一下。
快照隔離擋掉了髒讀髒寫,但還有兩種異常連很多資深工程師都會踩——丟失更新與寫入偏斜。往下捲,看看它們怎麼在你以為「已經夠安全」的地方悄悄搞事。
弱隔離等級(下):丟失更新、寫入偏斜與幻讀
同一列被蓋掉是小案;沒人蓋誰的資料、卻整體歪掉——這才是真正難防的並行 bug
先從最直覺的案例開始:計數器怎麼「加了兩次卻只加了一次」
資料庫裡有個計數器 value = 42,兩個交易都想幫它加 1。資料庫沒有內建的加法指令,程式只能自己做三步:讀出目前值、在記憶體裡改成新值、再寫回去——這叫
讀-改-寫循環。
一個人做沒事,兩個交易「同時」做,就會出事。
交易 A、交易 B 幾乎同時查詢,兩邊都拿到同一個舊值:42。
兩邊各自在自己的記憶體裡算出「新值應該是 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 元憑空消失。
直接讀原文:書怎麼定義丟失更新
這一段是丟失更新問題最原汁原味的定義,順便列出它常出現的三種情境。
丟失更新問題會發生在:應用程式從資料庫讀出某個值、修改它、再把修改後的值寫回去(讀-改-寫循環)。
如果兩個交易並行做這件事,其中一個修改可能會丟失,因為第二次寫入沒有包含第一次的修改。
我們有時會說,後面的寫入「蓋掉」了前面的寫入。
遞增計數器或更新帳戶餘額,都需要先讀目前值、算出新值,再寫回更新後的值。
兩個使用者同時編輯同一個 wiki 頁面,各自把整頁內容送回伺服器儲存——結果會直接覆蓋掉資料庫裡目前的內容。
原子寫入(如 UPDATE counters SET value = value + 1)最省事,能用就優先用;規則太複雜就上
明確鎖定(FOR UPDATE);
有些資料庫能
自動偵測丟失更新並重試(PostgreSQL、Oracle、SQL Server 支援,但 MySQL/InnoDB 的 repeatable read 不支援);沒有交易可用時,還有
compare-and-set——寫入時附帶「前提是現在還是舊值」的條件。
招牌案例:兩位值班醫生,各自的決定都合法,合起來卻違規
丟失更新至少還「蓋到了同一格資料」,看得出問題在哪。書中接下來這個案例更陰險——兩個交易改的是不同的物件,資料庫完全不覺得它們互相衝突。按「下一步」,看 Alice 和 Bob 怎麼一起搞砸值班表。
這個現象叫 寫入偏斜(write skew)。 它既不是髒寫、也不是丟失更新——因為 Alice 和 Bob 更新的是兩個不同的物件(各自的值班紀錄)。問題不在任何一步「寫錯」,而是「檢查條件」與「根據條件寫入」之間出現了空隙:兩人都基於同一份即將過期的 前提(premise)做判斷,而那個前提在他們做決定的同時,已經被對方的動作悄悄推翻了。
兩個交易讀同一組資料、更新「某些」物件——如果那些被更新的物件剛好是同一個,就退化成丟失更新(或髒寫);如果是不同物件,就是更一般的寫入偏斜。如果 Alice 和 Bob 是先後操作,第二個人查值班紀錄時就會看到「只剩 1 人」而被擋下——問題完全來自「同時」發生。
寫入偏斜的固定劇本,還有能防的招式
醫生值班、會議室預訂、多人遊戲棋子、帳號註冊、帳戶提款——書中列的例子表面不同,骨子裡都是同一套三步驟劇本:
查一下某個條件是否成立:還有幾位醫生值班?這個時段房間有沒有被訂?帳戶裡還有錢嗎?
應用程式根據第 1 步查到的結果,決定要繼續動作、還是回報錯誤中止。
決定繼續就寫入資料庫——而這筆寫入,恰好會改變第 1 步查詢原本的結果。
醫生值班這個例子還算好處理:第 3 步要改的列,正好就是第 1 步查出來的列,所以能在第 1 步就用 SELECT ... FOR UPDATE 把那幾列鎖住,逼另一個交易排隊等待。書中在無法切到可序列化隔離時,就是建議用這招當第二選擇:
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)。
幻讀:連鎖都掛不上去的敵人
幻讀的定義,就是:一個交易裡的寫入,改變了另一個交易某個搜尋查詢會匹配到的那組列。
一個 SELECT 查詢,透過搜尋符合某條件的列,來檢查某個需求是否滿足。
這筆寫入的效果,改變了第 2 步做決定所依賴的前提——如果你在這筆寫入 commit 之後重跑同一個 SELECT,會得到不同的結果。
這些情境檢查的是「符合某條件的列不存在」,而寫入卻新增了一列符合同一條件的列。如果第 1 步的查詢沒有回傳任何列,SELECT FOR UPDATE 就沒有東西可以鎖。
這種「一個交易的寫入,改變了另一個交易搜尋查詢的結果」的效應,就叫做幻讀(phantom)。
如果幻讀的問題是「沒有物件可以掛鎖」,那我們或許可以人工在資料庫裡塞進一個專門用來鎖的物件?
會議室預訂就是幻讀的經典場景:兩個交易各自查「這個時段有沒有人訂」,都看到「沒有」,於是都插入了一筆新預訂——衝突來自一列當時根本還不存在、隨後才被插入的 列(phantom)。 解法叫 物化衝突(materializing conflicts)—— 先把「房間 × 時段」所有組合都預先建成一張表(純粹當鎖用,不存預訂內容),要訂房前先把對應的列鎖起來,幻讀就變成了實體列上的鎖衝突。
小美、阿強都想把書放上「A 區第 3 格」,兩人都看了一眼、都看到「空的」,於是都放書上去——同一格擠進兩本書。管理員後來想到一招:先把每個格位都做好一張牌子掛牆上,想放書就得先把牌子拿到手(鎖住牌子)才能檢查並放書。牌子本身不記書,純粹是拿來搶佔的鎖。
書中明講:物化衝突很難設計對、又容易出錯,還讓並行控制機制醜陋地滲入應用程式的資料模型。多數情況下,能用可序列化隔離解決,就優先用它;只有無計可施時才動用物化衝突。
動手配配看:三種異常,該用哪招防?
把下面三種並行異常,拖到它最對症的防範招式。配完按「對答案」。
SET value = value + 1),或用明確加鎖逼交易排隊SELECT ... FOR UPDATE 顯式鎖定,逼後到的交易等待丟失更新:兩交易改同一個物件。寫入偏斜:兩交易改不同物件,卻共同破壞跨物件的前提。幻讀:連「不同物件」都還不存在,查詢回傳零列,鎖根本無處可掛,只能靠人工造鎖物件或整段隔離升級來擋。
小試身手
丟失更新、寫入偏斜、幻讀,這三個名詞背後的判斷力,來檢查一下有沒有抓對。
如果每一種異常都要單獨補洞——原子操作、明確鎖定、物化衝突各顯神通——遲早會漏東漏西。有沒有一種隔離等級,直接保證「不管怎麼並行,結果都跟某種依序執行一樣」?往下捲,我們去見識可序列化隔離(serializable isolation)。
Serializability 可序列化:最強的保證
先看承諾本身,再拆解兩階段鎖定怎麼硬擋所有並行災難——順便釐清一組長得很像、卻完全不同的名詞
前面擋了一半,這次要把話說死
一路看下來,我們已經見過 dirty read、 lost update、 write skew、 phantom——一堆會讓人半夜爬起來查 log 的並行災難。較弱的隔離等級,像 read committed、snapshot isolation,都只擋掉其中一部分,剩下的還是會漏。
更麻煩的是:隔離等級的名稱本身就很混亂(不同資料庫的「repeatable read」意義完全不同),你也很難判斷自己的程式在某個隔離等級下到底安不安全——尤其並行問題是 不確定性(nondeterministic) 的,運氣不好、時序剛好對上才會爆,幾乎無法穩定測試出來。
從 1970 年代開始,研究者給的答案一直很直接:用 serializability(可序列化) 就好。
可序列化保證:即使多個交易實際上是並行執行的,最終結果也保證和「一個接一個、完全沒有並行」地依序跑出來一模一樣。換句話說——如果每個交易單獨跑時是正確的,那麼它們並行跑時也保證正確。你不用再去煩惱「這段程式在這個隔離等級下安不安全」這種燒腦問題,因為 race condition 被整套擋掉了。
那為什麼不是大家都在用?因為它有代價——最強的保證通常最貴。所以重點從來不是「該不該用」,而是「怎麼實作才不會太慢」。接下來我們會先快速認識三種主流做法,再把重點放在其中最常見的一種:兩階段鎖定。
直接讀原文:兩階段鎖定的核心規則
這一段是全書講兩階段鎖定最關鍵的定義句——鎖怎麼分兩種模式、怎麼互相卡住,一句一句對照著看。
注意:兩階段鎖定(2PL)聽起來跟兩階段提交(2PC)很像,但它們是完全不同的兩件事。
只要沒有人在寫,多筆交易可以同時讀同一個物件。但只要有人想寫(修改或刪除)某個物件,就需要獨占存取。
如果交易 A 已經讀了某個物件,交易 B 想寫那個物件,B 就得等到 A commit 或 abort 之後才能繼續。
在 2PL 裡,寫者不只會擋住其他寫者,還會擋住讀者,反過來也一樣。
Snapshot isolation 的口號是「讀者永不擋寫者,寫者永不擋讀者」——這句話正好點出它跟兩階段鎖定的關鍵差異。
兩階段鎖定(Two-Phase Locking,2PL)跟兩階段提交(Two-Phase Commit,2PC)名字幾乎一樣,中文縮寫也都愛叫「兩階段」,但它們是完全不同的兩件事:2PL 管的是並行控制——同一個資料庫裡,多筆交易同時跑該怎麼互相禮讓,好達成可序列化;2PC 管的是分散式交易的原子性——一筆交易橫跨多個節點時,怎麼確保大家「全部一起 commit,或全部一起 abort」,這件事本書留到討論分散式系統時才細講。這是本節刻意提醒的常見混淆,下面我們還會再回來對照一次。
動畫演給你看:一次鎖升級是怎麼被卡住的
交易 T1 想先讀一列資料、再改它;交易 T2 這時已經持有衝突的鎖。按「下一步」,看 T1 怎麼從「共享鎖」走到「被鎖管理器擋下來」,直到 T2 結束才被放行。
把每個資料物件想成圖書館裡「只有一冊」的熱門參考書:很多人可以圍著桌子一起翻看(共享鎖),但只要有人想在書上做註記,就得把整本書借走獨占(獨占鎖)——此時別人連翻都不能翻。而且圖書館有個怪規矩:這趟造訪借過的書要一路抱著,直到辦完所有事、走到櫃台才一次全部歸還,這正是「兩階段」的由來。
兩階段鎖定的三條運作規則
把上面的動畫拆成規則寫下來,就是這三條——資料庫靠它們讓「寫擋讀、讀擋寫」,反過來擋掉幾乎所有並行災難。
多筆交易可以同時持有同一物件的共享鎖一起讀;但只要該物件已經有人持有獨占鎖,就得排隊等。
不管物件上已有共享鎖還是獨占鎖,只要有任何既存的鎖,這筆交易就得等到全部釋放才能繼續。
第一階段(執行中)只負責「取得」鎖,第二階段(commit 或 abort 時)才一次性「釋放」全部鎖——這就是「兩階段」的名字來源。
因為鎖用得又多又嚴,很容易發生 死鎖(deadlock)——資料庫會自動偵測、中止其中一筆交易讓另一筆能繼續,被中止的那筆得由應用程式重試整個交易。這也是為什麼兩階段鎖定的吞吐量與延遲,往往比較弱的隔離等級差上一截:它是用效能換來全面擋掉 race condition 的保證。
小試身手
可序列化的承諾、還有兩階段鎖定的運作方式,來檢查一下有沒有記牢。
2PL 靠加鎖硬擋一切,代價是效能——讀寫互擋、死鎖頻繁。但如果衝突其實很少見,能不能先讓大家樂觀地做,事後再檢查有沒有真的撞車?往下捲,看看可序列化快照隔離怎麼給出這個答案。
樂觀的賭注:SSI 與全章總結
先放行、提交才驗收——可序列化快照隔離怎麼用「絆線」取代「鎖」,以及整章隔離等級的最終盤點
兩種看待衝突的人生觀
資料庫要保證 可序列化, 但要怎麼達成?書裡把做法分成兩種根本不同的「個性」。
「如果有任何事可能出錯,就先停下來等,等到安全了再動作。」代表作是兩階段鎖定(2PL):碰資料前先拿鎖,別人拿著你就乖乖等——像多執行緒程式裡的互斥鎖,一次只准一個人進場。更極端的序列執行,等於讓每個交易對整個資料庫上一把獨佔鎖,靠「交易跑超快」來彌補這種悲觀。
「先讓大家動手做,賭它多半不會出事;真要提交時,再檢查有沒有踩到別人。」代表作正是本章主角 可序列化快照隔離(SSI): 遇到危險不擋,交易照跑;提交時才檢查隔離性有沒有被破壞,沒問題就放行,有問題就中止(abort)並要求重試(retry)。只有真能以可序列化方式執行的交易,才被允許提交。
悲觀派:想用會議室先去總務領鑰匙,鑰匙在別人手上就站門口乾等——絕不撞期,但就算對方只是進去拿個東西(讀取),你也得等他出來,讀者卡住寫者、寫者卡住讀者。樂觀派:想用就直接進去用,等要正式登記進系統(commit)時,才回頭比對行事曆有沒有人也宣告佔用;沒撞到就登記成功,撞到了這場作廢、改時間重來。會議室常空著(低競爭)時,樂觀派幾乎不用等;但會議室爆紅、人人搶同一時段(高競爭)時,樂觀派會不斷撞期重訂,反而比排隊領鑰匙還累。
直接讀原文:悲觀 vs 樂觀並行控制
這一段是全書對兩種並行控制哲學最精準的定義,逐句對照白話。
兩階段鎖定是所謂的悲觀並行控制機制:它的原則是——只要有任何事可能出錯,最好先等到情況安全了才動手。
相對地,可序列化快照隔離是一種樂觀並行控制技術。
這裡的「樂觀」意思是:遇到潛在危險的事,不阻塞,交易照樣繼續跑下去,賭一把「應該都會沒事」。
當交易要提交時,資料庫才檢查有沒有出什麼問題;如果有,這個交易就會被中止,還得重試一次。
只有真的以可序列化方式執行完的交易,才被允許提交。
SSI 之所以叫「Snapshot」+「Serializable」,是因為它蓋在快照隔離之上:交易的所有讀取都來自同一個一致的資料庫快照,這是它跟早期樂觀並行控制技術最大的不同。在這個基礎上,SSI 再加一套演算法,專門偵測寫入之間的序列化衝突,決定誰該被中止。
跑一遍看看:SSI 的樂觀流程
下面把 SSI 處理一筆交易的過程拆成幾步。按「下一步」,看交易怎麼從「樂觀地跑」走到「提交時才驗收」。
2PL 用的是真正的鎖:你碰到別人鎖住的資料就得等。SSI 用的更像一條 絆線(tripwire)—— 交易照常跑,資料庫默默記錄「誰讀了什麼、誰寫了什麼」;只有在某交易要提交時,才檢查它的前提是否已被破壞,破壞了就中止它。這就是為什麼讀者不會擋寫者,寫者也不會擋讀者。
要抓的兩種「過時」,方向剛好相反
資料庫要問的只有一句話:「查詢結果有沒有可能已經變了?」答案分成兩種情境,剛好對應「修改在我讀之前」跟「修改在我讀之後」。
別人的寫入其實發生在「我讀之前」,只是當時還沒提交,所以依 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 的效能表現,可以濃縮成三個特性:
讀者不擋寫者、寫者不擋讀者,讀寫之間沒有鎖的爭用,查詢延遲更可預測。唯讀查詢可以直接在一致快照上跑,完全不需要任何鎖——對讀取繁重的工作負載非常有吸引力。
和序列執行不同,SSI 不受單一 CPU 核心吞吐量的限制。FoundationDB 把「偵測序列化衝突」這件事分散到多台機器上,即使資料被切分到多台機器,交易仍能跨多個分區讀寫,同時維持可序列化。
中止率深深影響整體效能——讀寫交易拖得越久,越可能撞上別人被迫中止。但好消息是:長時間的唯讀交易通常沒問題,因為它不寫資料,不會製造衝突。
那什麼時候該選 SSI?書中把關鍵濃縮成兩句:高競爭時表現差——很多交易搶同一批物件,大量中止重試,若系統已接近最大吞吐量,重試帶來的額外負載會讓效能雪上加霜;低競爭又有備援容量時表現更好——樂觀法往往勝過悲觀法。想降低競爭,還能改用可交換的原子操作,例如多個交易都要對同一計數器 +1,順序無所謂,這些並行遞增就能互不衝突地全部套用。
資料庫要記錄每筆交易讀寫了哪些資料——記得細,能精準判斷該中止誰,但記帳開銷大;記得粗,速度快、開銷小,但可能中止比實際必要更多的交易。PostgreSQL 甚至用一套理論:有時某交易讀到被覆寫的舊值,只要能證明最終結果仍可序列化,就不必中止,藉此減少不必要的中止。
全章總結:隔離等級的光譜
整章走完一輪,我們把所有隔離等級擺回同一條光譜上——等級越強,擋下的並行災難越多:
擋住髒讀、髒寫——你不會讀到或覆寫別人尚未提交的資料。但擋不住讀偏斜、丟失更新、寫入偏斜、幻讀,是最基本的底線。
再擋住讀偏斜(不可重複讀)與直接幻讀——交易看到的是同一個一致快照。但寫入偏斜這種「讀後做決定、前提卻被推翻」的異常,快照隔離依然防不住。
三種實作路線:序列執行(單核循序,簡單但受單核吞吐量綁死)、兩階段鎖定 2PL(悲觀上鎖,數十年標準解法,但鎖等待讓延遲不穩)、SSI(樂觀檢查,讀寫互不擋、可跨機器擴展,但長交易會拉高中止率)。三者都能擋下寫入偏斜與全部幻讀,是唯一能防住寫入偏斜的等級。
隔離等級越強,防住的並行異常越多,但效能代價也越高:讀已提交幾乎沒有額外成本;快照隔離要維護多版本;可序列化往往意味著鎖等待或中止重試。SSI 想在這條光譜的頂端,同時兼顧「強保證」與「好效能」——樂觀時期幾乎沒有鎖的代價,讀寫互不阻塞;只有在真的偵測到衝突時,才付出中止重試的代價。這正是為什麼書中認為,SSI 有機會成為將來可序列化隔離的新預設值。
來做最後兩題,檢查一下悲觀 vs 樂觀、以及 SSI 效能特性有沒有記牢。
下一站:把交易的世界搬到多台機器上會發生什麼事?第 8 章要直接面對分散式系統最誠實的一面——網路會不可靠、時鐘會騙人、任何時候都可能有一部分靜默故障。