8. 事務

有些作者聲稱,通用的兩階段提交代價太高,會帶來效能或可用性問題。我們認為,與其讓應用程式設計師始終為缺少事務而另想辦法,不如等事務被過度使用並形成瓶頸時,再由他們處理相應的效能問題。
James Corbett 等人,Spanner:Google 的全球分散式資料庫(2012)
在資料系統的殘酷現實中,許多事情都可能出錯:
- 資料庫軟體或硬體隨時可能發生故障(包括寫操作進行到一半時)。
- 應用程式可能在任意時刻崩潰(包括一系列操作的中間)。
- 網路中斷可能會意外切斷應用程式與資料庫之間,或資料庫節點彼此之間的連線。
- 多個客戶端可能會同時寫入資料庫,覆蓋彼此的更改。
- 客戶端可能讀取到無意義的資料,因為資料只更新了一部分。
- 客戶端之間的競態條件可能導致令人驚訝的錯誤。
要做到可靠,系統就必須處理這些故障,確保它們不會導致整個系統災難性地失效。然而,實現容錯機制的工作量很大:既要審慎考慮所有可能出錯的情況,又要經過大量測試,才能確保解決方案真正有效。
數十年來,事務一直是簡化這些問題的首選機制。應用程式透過事務將多個讀寫操作組合成一個邏輯單元。從概念上講,事務中的所有讀寫被當作一次操作執行:整個事務要麼成功(提交),要麼失敗(中止、回滾)。如果失敗,應用程式可以安全重試。有了事務,應用程式的錯誤處理就簡單多了,因為它不必擔心部分失效——即無論出於何種原因,有些操作成功、有些操作失敗。
如果你與事務打了多年交道,可能會覺得這一切理所當然,但事務並非自然法則。人們創造事務自有其目的:簡化訪問資料庫的應用程式設計模型。有了事務,應用程式便可以不去考慮某些潛在的錯誤場景和併發問題,因為資料庫會代為處理這些問題(我們稱之為安全保證)。
並非所有應用程式都需要事務;有時弱化事務保證,甚至徹底放棄事務也有好處(例如為了提高效能或可用性)。有些安全屬性也可以在沒有事務的情況下實現。另一方面,事務能夠避免許多麻煩:例如,郵局 Horizon 醜聞(參見“可靠性有多重要?”)背後的技術原因,很可能就是底層會計系統缺少 ACID 事務1。
怎樣判斷自己是否需要事務?要回答這個問題,首先必須確切理解事務能提供哪些安全保證,以及需要為此付出什麼代價。事務乍看簡單,其中卻有許多微妙而重要的細節。
本章將考察許多可能出錯的案例,並探討資料庫用來防範這些問題的演算法。我們尤其會深入併發控制領域,討論可能出現的各種競態條件,以及資料庫如何實現讀已提交、快照隔離和可序列化等隔離級別。
無論對單節點資料庫還是分散式資料庫,併發控制都很重要。在本章稍後的“分散式事務”一節中,我們將考察兩階段提交協議,以及在分散式事務中實現原子性所面臨的挑戰。
事務到底是什麼?
如今,幾乎所有關係型資料庫和一部分非關係資料庫都支援事務。它們大多沿襲 1975 年由第一個 SQL 資料庫 IBM System R 開創的風格2 3 4。儘管一些實現細節發生了變化,但 50 年來總體思路幾乎原封未動:MySQL、PostgreSQL、Oracle 和 SQL Server 等資料庫的事務支援與 System R 異乎尋常地相似。
2000 年代後期,非關係型(NoSQL)資料庫開始流行。它們試圖打破關係型資料庫的既有格局:不僅提供新的資料模型選擇(參見第 3 章),還預設內建複製(第 6 章)和分片(第 7 章)。事務成了這場運動的主要犧牲品:這一代資料庫中,許多徹底放棄了事務,或者重新定義了“事務”一詞,用它來描述一套遠弱於以往認識的保證。
圍繞 NoSQL 分散式資料庫的炒作催生了一種流行觀點:事務從根本上無法伸縮,任何大規模系統若想保持良好效能和高可用性,都必須放棄事務。近來事實已經證明這種觀點是錯誤的。CockroachDB5、TiDB6、Spanner7、FoundationDB8 和 YugabyteDB 等所謂的“NewSQL”資料庫表明,事務系統同樣可以擴充套件到很大的資料量和很高的吞吐量。這些系統將分片與共識協議(第 10 章)結合起來,從而在大規模場景下提供強 ACID 保證。
不過,這也不意味著每個系統都必須支援事務。和其他技術設計選擇一樣,事務既有優勢,也有侷限。要理解其中的權衡,就必須深入瞭解事務究竟能提供哪些保證——無論是在正常執行時,還是在各種極端但切實可能發生的情況下。
ACID 的含義
事務提供的安全保證,通常用眾所周知的首字母縮略詞 ACID 來描述,分別代表原子性(Atomicity)、一致性(Consistency)、隔離性(Isolation)和永續性(Durability)。Theo Härder 和 Andreas Reuter 於 1983 年提出了這一術語9,試圖為資料庫的容錯機制建立一套精確的表述。
然而在實踐中,不同資料庫對 ACID 的實現並不相同。例如,我們很快就會看到,隔離性的含義存在許多歧義10。總體思路固然合理,魔鬼卻藏在細節裡。如今,一個系統即便聲稱自己“符合 ACID”,你也很難知道它究竟提供哪些保證。遺憾的是,ACID 基本上已經淪為營銷術語。
(不符合 ACID 標準的系統有時稱為 BASE,即基本可用(Basically Available)、軟狀態(Soft State)和最終一致性(Eventual Consistency)11。這個定義比 ACID 還要模糊。BASE 唯一說得通的定義似乎就是“非 ACID”;換句話說,它幾乎可以任人解釋。)
下面深入瞭解原子性、一致性、隔離性和永續性的定義,以便進一步釐清事務的含義。
原子性
一般而言,原子是指不可再分的事物。這個詞在計算機的不同領域中含義相近,卻又有細微差別。例如在多執行緒程式設計中,如果一個執行緒執行原子操作,另一個執行緒就不可能看到該操作只完成一半的結果。系統只能處於操作之前或之後的狀態,而不會處於兩者之間。
相比之下,ACID 中的原子性與併發無關。它並不描述多個程序同時訪問相同資料時會發生什麼;那是字母 I 所代表的隔離性要處理的問題(參見“隔離性”)。
ACID 的原子性描述的是另一類情況:客戶端想執行多次寫入,卻在其中一些寫入處理完畢後遇到故障——例如程序崩潰、網路連線中斷、磁碟空間耗盡,或者違反了某項完整性約束。如果這些寫入被組合到一個原子事務中,而故障使事務無法完成(提交),那麼事務就會中止,資料庫必須丟棄或撤銷該事務此前完成的所有寫入。
如果沒有原子性,在多項變更執行到一半時發生錯誤,應用程式很難知道哪些變更已經生效、哪些尚未生效。它可以再試一次,卻可能把同一項變更執行兩遍,造成重複或錯誤的資料。原子性簡化了這個問題:如果事務已經中止,應用程式便能確定它沒有改變任何內容,因此可以安全重試。
遇到錯誤時中止事務,並丟棄該事務的所有寫入,正是 ACID 原子性的定義性特徵。或許稱為可中止性比原子性更貼切,但既然“原子性”是慣用說法,本書仍沿用這一術語。
一致性
一致性這個詞被嚴重濫用:
- 在第 6 章中,我們討論了副本一致性,以及非同步複製系統中的最終一致性問題(參見“複製延遲的問題”)。
- 資料庫的一致快照(例如用於備份)是整個資料庫在某一時刻的快照。更準確地說,它與先發生關係(happens-before relation)一致(參見“‘先發生’關係與併發”):如果快照包含某個時刻寫入的值,那麼它也會反映發生在這次寫入之前的所有寫入。
- 一致性雜湊是某些系統在再平衡時採用的一種分片方法(參見“一致性雜湊”)。
- 在 CAP 定理中(參見第 10 章),一致性一詞指的是線性一致性(參見“線性一致性”)。
- 在 ACID 的語境下,一致性指的是由具體應用定義的資料庫“良好狀態”。
遺憾的是,同一個詞竟有至少五種不同的含義。
ACID 一致性的基本思想是:關於資料的某些陳述(即不變式)必須始終成立。例如,在會計系統中,所有賬戶的貸記與借記必須始終相抵。如果事務開始時資料庫滿足這些不變式,事務中的所有寫入又能維持其有效性,就可以確信不變式始終成立。(不變式在事務執行期間可以暫時被打破,但到事務提交時必須重新得到滿足。)
如果希望由資料庫強制執行不變式,就需要把它們宣告為模式中的約束。外來鍵約束、唯一性約束或檢查約束(限制單行中可以出現的值)常用來表達特定型別的不變式。更複雜的一致性要求有時也可以用觸發器或物化檢視來表達12。
不過,資料庫通常提供的約束可能很難、甚至根本無法表達複雜的不變式。此時,應用程式就有責任正確定義事務,使其維持一致性。如果應用寫入了違反不變式的錯誤資料,卻沒有事先宣告這些不變式,資料庫也無從阻止。因此,ACID 中的 C 往往取決於應用程式如何使用資料庫,並非資料庫自身的屬性。
隔離性
大多數資料庫都會同時接受多個客戶端的訪問。如果各客戶端讀寫資料庫的不同部分,自然沒有問題;但如果它們訪問相同的資料庫記錄,就可能遇到併發問題(競態條件)。
圖 8-1給出了這類問題的一個簡單例子。假設兩個客戶端同時遞增資料庫中的一個計數器。每個客戶端都要讀取當前值,將其加 1,再把新值寫回去(假設資料庫沒有內建的遞增操作)。在圖 8-1中,計數器經過兩次遞增,本應從 42 變成 44,卻因競態條件最終只變成了 43。

ACID 意義上的隔離性是指併發執行的事務彼此隔離,不能相互干擾。經典資料庫教科書將隔離性形式化為可序列化:每個事務都可以假裝自己是整個資料庫中唯一正在執行的事務。資料庫保證,所有事務提交後的結果與它們序列執行(逐個執行)的結果相同,儘管實際上這些事務可能是併發執行的13。
然而,可序列化是有效能代價的。實踐中,許多資料庫採用弱於可序列化的隔離形式,也就是允許併發事務在有限範圍內相互干擾。一些流行資料庫(例如 Oracle)甚至沒有實現可序列化:Oracle 雖有一個名為“可序列化”的隔離級別,實際實現的卻是保證更弱的快照隔離10 14。因此,某些競態條件仍有可能發生。我們將在“弱隔離級別”中考察快照隔離和其他隔離形式。
永續性
資料庫系統的目的,是提供一個可以放心存放資料而無須擔心丟失的安全場所。永續性作出這樣的承諾:事務一旦成功提交,其寫入的任何資料都不會遺失,即使發生硬體故障或資料庫崩潰也不例外。
在單節點資料庫中,永續性通常意味著資料已經寫入硬碟或 SSD 等非易失性儲存。普通檔案寫入通常會先在記憶體中緩衝,稍後才送往磁碟,突然斷電時便可能丟失;因此,許多資料庫使用 fsync() 系統呼叫,確保資料確實已經落盤。資料庫通常還有預寫日誌或類似機制(參見“使 B 樹可靠”),以便在寫入中途崩潰後恢復。
在複製資料庫中,永續性可能意味著資料已經成功複製到一定數量的節點。資料庫必須等到這些寫入或複製操作完成,才能報告事務已成功提交。不過,正如“可靠性與容錯”所說,完美的永續性並不存在:如果所有硬碟和備份同時被毀,資料庫顯然也無能為力。
複製與永續性
歷史上,永續性意味著寫入歸檔磁帶;後來,它指寫入磁碟或 SSD;近來,這個概念又被擴充套件到複製。哪種實現更好?
事實是,沒有一種方案十全十美:
- 如果資料寫入磁碟後機器宕機,那麼即使資料沒有丟失,在修好機器或把磁碟轉移到另一臺機器之前也無法訪問。複製系統則可以繼續保持可用。
- 關聯故障——例如停電,或某個缺陷使所有節點在遇到特定輸入時崩潰——可能一次擊垮所有副本(參見“可靠性與容錯”),使僅存在記憶體中的資料全部丟失。因此,即使是複製資料庫,寫入磁碟仍然很重要。
- 在非同步複製系統中,領導者不可用時,最近的寫入可能丟失(參見“處理節點故障”)。
- 突然斷電時,SSD 尤其可能違背它本應提供的保證:甚至
fsync也不一定能正常發揮作用15。和其他軟體一樣,磁碟韌體也會有缺陷16 17;例如,某種缺陷會讓硬碟在恰好執行 32,768 小時後失效18。此外,fsync很難正確使用,就連 PostgreSQL 也曾錯誤使用它長達 20 多年19 20 21。 - 儲存引擎與檔案系統實現之間的微妙互動,可能引發難以追查的缺陷,使磁碟檔案在崩潰後損壞22 23。一個副本上的檔案系統錯誤有時還會蔓延到其他副本24。
- 磁碟上的資料可能在無人察覺的情況下逐漸損壞25。如果損壞已經持續了一段時間,副本和近期備份也可能同樣受損。此時只能嘗試從更早的歷史備份中恢復資料。
- 一項 SSD 研究發現,驅動器在投入使用後的頭四年裡,有 30% 到 80% 會出現至少一個壞塊,而且只有一部分壞塊能由韌體糾正26。機械硬碟的壞扇區率較低,但徹底失效的機率高於 SSD。
- 磨損嚴重(經歷了大量寫入/擦除週期)的 SSD 斷電後,可能在數週至數月內開始丟失資料,具體時間取決於溫度27。磨損程度較低的驅動器則沒有這麼嚴重28。
實踐中,沒有任何一種技術能提供絕對保證,只有寫入磁碟、複製到遠端機器和備份等降低風險的手段——它們可以、也應該組合使用。一如既往,面對任何理論上的“保證”,都應保持適度懷疑。
單物件與多物件操作
回顧一下,在 ACID 中,原子性和隔離性描述了客戶端在同一事務中進行多次寫入時,資料庫應當如何處理:
- 原子性
- 如果一系列寫入執行到一半時發生錯誤,事務就應中止,之前完成的寫入也應丟棄。換句話說,資料庫以“全有或全無”的保證,免去你對部分失效的擔憂。
- 隔離性
- 併發執行的事務不應相互干擾。例如,一個事務進行了多次寫入,另一個事務就應當要麼看到全部寫入,要麼一項也看不到,而不能只看到其中一部分。
這些定義假設你想同時修改多個物件(行、文件或記錄)。當多項資料需要保持同步時,通常就要用到這種多物件事務。圖 8-2展示了一個電子郵件應用的例子。要顯示使用者的未讀郵件數,可以執行如下查詢:
SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
不過,郵件數量很多時,這條查詢可能太慢,於是你決定把未讀郵件數另存到一個單獨的欄位中(這是一種反正規化,參見“正規化、反正規化與連線”)。這樣,每當新郵件到達時,就必須遞增未讀計數器;每當郵件被標為已讀時,也必須遞減該計數器。
在圖 8-2中,使用者 2 遇到了異常:郵箱列表中已經出現一封未讀郵件,未讀計數卻仍為零,因為計數器尚未遞增。(如果電子郵件應用中的錯誤計數無關緊要,不妨把未讀計數換成客戶賬戶餘額,把郵件換成支付事務。)隔離性可以防止這個問題:它保證使用者 2 要麼同時看到新插入的郵件和更新後的計數,要麼兩者都看不到,而不會看到不一致的中間狀態。
圖 8-3說明了為何需要原子性:如果事務進行到一半時發生錯誤,郵箱內容和未讀計數可能失去同步。在原子事務中,如果計數器更新失敗,事務就會中止,已經插入的郵件也會回滾。

多物件事務需要某種方式來確定哪些讀寫操作屬於同一個事務。在關係型資料庫中,這通常以客戶端到資料庫伺服器的 TCP 連線為依據:同一條連線上,BEGIN TRANSACTION 與 COMMIT 語句之間的所有操作都屬於同一個事務。如果 TCP 連線中斷,事務就必須中止。
另一方面,許多非關係型資料庫並沒有這種組合操作的機制。即便提供了多物件 API(例如鍵值儲存可能提供一次更新多個鍵的 multi-put 操作),也不一定具備事務語義:命令可能只更新了其中一些鍵,另一些卻失敗了,使資料庫停留在部分更新的狀態。
單物件寫入
原子性和隔離性也適用於單個物件的變更。例如,假設你正在向資料庫寫入一份 20 KB 的 JSON 文件:
- 如果前 10 KB 傳送完畢後網路連線中斷,資料庫會不會存下一段無法解析的 10 KB JSON 殘片?
- 如果資料庫覆蓋磁碟上的舊值時突然斷電,最後會不會把新舊值拼接在一起?
- 如果另一個客戶端在寫入過程中讀取該文件,會不會看到只更新了一部分的值?
這些問題會讓人無所適從。因此,幾乎所有儲存引擎都力求在單個節點的單個物件(例如鍵值對)層面提供原子性和隔離性。原子性可以透過日誌和崩潰恢復來實現(參見“使 B 樹可靠”),隔離性則可以透過為每個物件加鎖來實現(同一時刻只允許一個執行緒訪問物件)。
有些資料庫還提供更複雜的原子操作,例如遞增操作,從而不必執行圖 8-1中的讀取—修改—寫入迴圈。同樣常見的還有條件寫入:只有當該值未被其他人併發修改時,寫入才會發生(參見“條件寫入(比較並設定)”)。它類似於共享記憶體併發中的比較並設定或比較並交換(CAS)操作。
Note
嚴格來說,原子遞增中的“原子”採用的是多執行緒程式設計中的含義。在 ACID 的語境下,它其實應稱為隔離遞增或可序列化遞增,但通常並不這樣叫。
這些單物件操作很有用,因為它們能避免多個客戶端同時寫入同一物件時發生丟失更新(參見“防止丟失更新”)。但它們並不是通常意義上的事務。例如,Cassandra 和 ScyllaDB 的“輕量級事務”功能,以及 Aerospike 的“強一致性”模式,都能在單個物件上提供線性一致的讀取和條件寫入(參見“線性一致性”),卻不為多個物件之間的操作提供保證。
多物件事務的需求
我們真的需要多物件事務嗎?能否只靠鍵值資料模型和單物件操作來實現任何應用程式?
有些用例只需插入、更新或刪除單個物件就足夠了。但在許多其他場景中,必須協調對多個不同物件的寫入:
- 在關係資料模型中,一個表中的行經常透過外來鍵引用另一個表中的行。與之類似,在圖資料模型中,一個頂點會透過邊指向其他頂點。多物件事務可以確保這些引用始終有效:插入若干相互引用的記錄時,外來鍵必須正確且保持最新,否則資料便失去意義。
- 在文件資料模型中,需要一同更新的欄位通常位於同一份文件內,而文件被視為單個物件,因此更新一份文件無須多物件事務。不過,缺乏連線功能的文件資料庫也鼓勵反正規化(參見“何時使用哪種模型”)。當反正規化的資訊需要更新時——例如圖 8-2中的情形——就必須一次更新多份文件。事務在這裡很有用,可以防止反正規化資料彼此失去同步。
- 在具有二級索引的資料庫中(純鍵值儲存以外的資料庫幾乎都屬於此列),每次修改值時也要更新索引。從事務的角度看,這些索引是不同的資料庫物件。例如,沒有事務隔離時,一條記錄可能出現在某個索引中,卻沒有出現在另一個索引中,因為第二個索引尚未更新(參見“分片與二級索引”)。
這類應用即使沒有事務也能實現。不過,沒有原子性,錯誤處理就會複雜得多;缺少隔離性,又可能引發併發問題。我們將在“弱隔離級別”中討論這些問題,並在“衍生資料與分散式事務”中探討替代方案。
處理錯誤和中止
事務的一個關鍵特性是:遇到錯誤時,可以中止並安全重試。ACID 資料庫遵循這樣的原則:只要原子性、隔離性或永續性保證有遭到破壞的風險,資料庫寧可徹底放棄事務,也不會讓它停留在半完成狀態。
不過,並非所有系統都遵循這一原則。尤其是採用無主複製的資料儲存(參見“無主複製”),更多是以“盡力而為”的方式工作。概括來說就是:“資料庫能做多少便做多少;如果遇到錯誤,也不會撤銷已經完成的操作。”因此,從錯誤中恢復成了應用程式的責任。
錯誤不可避免,許多軟體開發者卻寧願只考慮一切順利的正常路徑,不願面對錯誤處理的複雜細節。例如,Rails 的 ActiveRecord 和 Django 等流行的物件關係對映(ORM)框架都不會重試已中止的事務——錯誤通常會以異常的形式沿呼叫棧向上傳播,於是使用者輸入被丟棄,只收到一條錯誤訊息。這很可惜,因為中止的意義恰恰在於允許安全重試。
儘管重試已中止的事務是一種簡單有效的錯誤處理機制,卻並非完美無缺:
- 事務可能實際上已經成功,只是在伺服器向客戶端確認提交成功時網路中斷,導致客戶端等待超時。此時重試會讓事務執行兩遍,除非應用層另有去重機制。
- 如果錯誤源於過載或併發事務之間的嚴重爭用,重試只會雪上加霜。要避免這種反饋迴圈,可以限制重試次數、採用指數退避,並將過載相關錯誤與其他錯誤區別處理(參見“當過載系統無法恢復時”)。
- 只有瞬態錯誤(例如死鎖、違反隔離、臨時網路中斷或故障切換)才值得重試;遇到永久性錯誤(例如違反約束)時,重試毫無意義。
- 如果事務還會在資料庫之外產生副作用,那麼即使事務中止,副作用也可能已經發生。例如,事務每重試一次就再發一封郵件,顯然不是你想要的結果。如果要確保幾個不同的系統共同提交或共同中止,兩階段提交可以提供幫助(參見“兩階段提交(2PC)”)。
- 如果客戶端程序在重試期間崩潰,它本想寫入資料庫的資料便會丟失。
弱隔離級別
如果兩個事務不訪問相同的資料,或者兩者都是隻讀事務,它們就可以安全地並行執行,因為彼此並無依賴。只有當一個事務讀取的資料正被另一個事務併發修改,或者兩個事務試圖同時修改相同資料時,才會出現併發問題(競態條件)。
併發缺陷很難透過測試發現,因為它們只有在時序碰巧不利時才會觸發。這種時序問題可能極少發生,通常也難以重現。併發行為本身同樣難以推理,尤其是在大型應用中,你未必知道還有哪些程式碼正在訪問資料庫。即使每次只有一個使用者,應用開發也已經不容易;面對許多併發使用者則更為困難,因為任何資料都可能隨時發生意料之外的變化。
正因如此,資料庫長期以來一直試圖透過事務隔離嚮應用開發者隱藏併發問題。理論上,隔離讓你可以假裝併發根本不存在:可序列化隔離意味著,資料庫保證事務產生的效果與序列執行(即逐個執行、毫無併發)相同。
遺憾的是,實踐中的隔離並沒有這麼簡單。可序列化隔離有效能代價,許多資料庫不願為此買單10。因此,系統普遍採用較弱的隔離級別,只防範一部分而非全部併發問題。這些隔離級別更難理解,也可能引發微妙的錯誤,卻仍在實踐中廣泛使用29。
弱事務隔離導致的併發缺陷絕非紙上談兵:它們曾造成鉅額資金損失30 31 32,招致財務審計人員介入調查33,也曾破壞客戶資料34。這類問題曝光後,人們常會說:“處理財務資料就該使用 ACID 資料庫!”但這沒有抓住要害。即使是許多通常被視為“ACID”的流行關係型資料庫,也採用弱隔離,因而未必能防止這些問題。
Note
順帶一提,銀行體系很大一部分依賴透過安全 FTP 交換的文字檔案35。在這種環境中,審計跟蹤和某些由人參與的反欺詐措施,其實比 ACID 屬性更重要。
這些例子還凸顯了一個要點:即使併發問題在正常執行中很少發生,也必須考慮攻擊者向 API 刻意傳送一波高度併發的請求,專門利用併發缺陷的可能性30。因此,要構建可靠且安全的應用,就必須系統性地防範這類缺陷。
本節將考察實踐中使用的幾種弱隔離級別(即非可序列化隔離),詳細討論哪些競態條件可能發生、哪些不會發生,以便你判斷哪一種適合自己的應用。隨後,我們再深入討論可序列化(參見“可序列化”)。這裡將透過例子作非形式化的說明;嚴格定義及其性質分析可在學術文獻中找到36 37 38 39。
讀已提交
最基本的事務隔離級別是讀已提交,它提供兩項保證:
- 從資料庫讀取時,只能看到已經提交的資料(沒有髒讀)。
- 向資料庫寫入時,只能覆蓋已經提交的資料(沒有髒寫)。
有些資料庫還支援更弱的讀未提交隔離級別。它能防止髒寫,卻不能防止髒讀。下面詳細討論這兩項保證。
沒有髒讀
設想一個事務已經向資料庫寫入資料,卻尚未提交或中止。另一個事務能否看到這些未提交的資料?如果能,這就是髒讀3。
執行在讀已提交隔離級別下的事務必須防止髒讀。這意味著,一個事務的所有寫入都要等到該事務提交時,才會同時對其他事務可見。圖 8-4中,使用者 1 已將 x 設為 3,但由於其事務尚未提交,使用者 2 的 get x 仍返回舊值 2。

防止髒讀有以下幾個好處:
- 如果事務要更新多行,髒讀會讓另一個事務只看到其中一部分更新。例如在圖 8-2中,使用者看到了新到的未讀郵件,卻沒有看到更新後的計數器。這就是對郵件的髒讀。資料庫呈現出部分更新的狀態,不僅令人困惑,還可能誘使其他事務作出錯誤決定。
- 如果事務中止,其所有寫入都需要回滾(如圖 8-3)。允許髒讀,就意味著事務可能讀到後來被回滾、從未真正提交進資料庫的資料。凡是讀取過未提交資料的事務也必須一併中止,從而造成所謂的級聯中止。
沒有髒寫
如果兩個事務併發更新資料庫中的同一行,會發生什麼?寫入次序雖然無法預知,但我們通常認為後一次寫入會覆蓋前一次寫入。
可是,如果前一次寫入屬於尚未提交的事務,後一次寫入覆蓋了這個未提交值,又會怎樣?這就是髒寫36。執行在讀已提交隔離級別下的事務必須防止髒寫;通常的做法是推遲第二次寫入,直到執行第一次寫入的事務提交或中止。
防止髒寫可以避免某些併發問題:
- 如果事務更新多行,髒寫可能產生非常糟糕的結果。圖 8-5展示了一家二手車交易網站:Aaliyah 和 Bryce 同時購買同一輛車。買車涉及兩次資料庫寫入:網站上的商品資訊要更新為買家的姓名,銷售發票也要開給買家。在圖 8-5所示的情況下,車輛最終賣給了 Bryce(他對
listings表的更新勝出),發票卻開給了 Aaliyah(她對invoices表的更新勝出)。讀已提交可以防止這種事故。 - 不過,讀已提交不能防止圖 8-1中兩個計數器遞增操作之間的競態條件。這裡的第二次寫入發生在第一個事務提交之後,所以並非髒寫。結果仍然有誤,只是原因不同;“防止丟失更新”將介紹如何安全地執行這類計數器遞增操作。

實現讀已提交
讀已提交是一種非常流行的隔離級別,也是 Oracle Database、PostgreSQL、SQL Server 等許多資料庫的預設設定10。
資料庫防止髒寫最常見的辦法是使用行級鎖:事務想修改某一行(或文件等其他物件)時,必須先取得該行的鎖,並一直持有到事務提交或中止。任何一行在同一時刻只能由一個事務持鎖;另一個事務若想寫入同一行,就必須等前一個事務提交或中止後,才能取得鎖並繼續執行。在讀已提交模式(或更強的隔離級別)下,資料庫會自動完成這些加鎖操作。
那麼怎樣防止髒讀?一種辦法仍是使用同一把鎖:要求任何想讀取某行的事務都短暫取得該鎖,讀完後立即釋放。這樣,當該行包含未提交的髒值時就無法讀取,因為執行寫入的事務仍持有這把鎖。
然而,要求讀操作加鎖在實踐中效果不佳。一個長期執行的寫事務,可能迫使許多隻讀事務一直等到它結束。這不僅損害只讀事務的響應時間,也不利於可運維性:應用某個部分一旦變慢,等待鎖便可能引發連鎖反應,波及完全不相干的其他部分。
儘管如此,仍有一些資料庫使用鎖來防止髒讀,例如 IBM Db2,以及將 read_committed_snapshot=off 的 Microsoft SQL Server29。
更常見的防髒讀辦法如圖 8-4所示:對於每一行寫入,資料庫同時保留舊的已提交值,以及當前持有寫鎖的事務寫入的新值。該事務進行期間,其他事務讀取這一行時只會得到舊值;等新值提交後,才改為讀取新值(詳見“多版本併發控制(MVCC)”)。
快照隔離與可重複讀
乍看之下,人們很容易以為讀已提交已經具備了事務所需的一切:它允許中止(原子性所必需),避免讀取事務未完成的結果,也防止併發寫入相互混雜。的確,這些功能很有用,提供的保證也比完全沒有事務的系統強得多。
然而,在這個隔離級別下,併發缺陷仍可能以許多方式出現。圖 8-6就展示了讀已提交可能遇到的一個問題。

假設 Aaliyah 在銀行有 1,000 美元存款,分別存在兩個賬戶中,每個賬戶 500 美元。現在有一筆事務從其中一個賬戶向另一個賬戶轉賬 100 美元。如果她偏偏在轉賬事務處理的同時檢視賬戶餘額,可能會先看到一個賬戶尚未收到轉入款項時的餘額(500 美元),再看到另一個賬戶已經轉出款項後的餘額(400 美元)。在 Aaliyah 看來,兩個賬戶總共只剩 900 美元——彷彿有 100 美元憑空消失了。
這種異常稱為讀偏差,是不可重複讀的一種:如果 Aaliyah 在轉賬事務結束後再次讀取賬戶 1 的餘額,會得到 600 美元,與上一次查詢看到的值不同。讀已提交隔離允許讀偏差,因為 Aaliyah 讀到各賬戶餘額的那一刻,它們確實都已經提交。
Note
遺憾的是,偏差(skew)一詞有多重含義:前文曾用它指存在熱點的不均衡工作負載(參見“傾斜的工作負載與緩解熱點”),這裡指的則是時序異常。
對 Aaliyah 來說,這個問題不會持續太久:幾秒鐘後重新整理網上銀行頁面,她多半就能看到一致的賬戶餘額。但有些場景無法容忍哪怕暫時的不一致:
- 備份
- 備份需要複製整個資料庫,大型資料庫可能要花上幾個小時。備份期間,資料庫仍在不斷接受寫入。結果,備份的一部分可能包含較舊版本的資料,另一部分卻包含較新版本。一旦從這樣的備份恢復,原本暫時的不一致(例如憑空消失的錢)就會固化下來。
- 分析查詢和完整性檢查
- 有時需要執行掃描資料庫大部分內容的查詢。這類查詢在分析場景中很常見(參見“分析型與事務型系統”),也可能用於定期執行完整性檢查,確認一切正常(即監測資料損壞)。如果查詢在不同時間點觀察資料庫的不同部分,返回的結果很可能毫無意義。
快照隔離36是解決這個問題最常用的辦法。其核心思想是,每個事務都從資料庫的一致快照讀取資料——也就是說,事務只看到它開始時資料庫中已經提交的全部資料。即使其他事務後來修改了資料,它看到的仍是那個特定時刻的舊資料。
快照隔離對於備份、分析等長期執行的只讀查詢是一大福音。如果查詢所處理的資料在執行期間不斷變化,查詢結果就很難解釋;如果事務看到的是凍結在某個時刻的資料庫一致快照,理解起來就容易多了。
快照隔離是一項常見功能:PostgreSQL、採用 InnoDB 儲存引擎的 MySQL、Oracle、SQL Server 等資料庫都支援它的某種變體,儘管各系統的具體行為有所不同29 40 41。Oracle、TiDB 和 Aurora DSQL 等資料庫甚至把快照隔離作為自身最高的隔離級別。
多版本併發控制(MVCC)
和讀已提交一樣,快照隔離通常用寫鎖來防止髒寫(參見“實現讀已提交”)。因此,一個寫事務可以阻塞另一個寫入同一行的事務。不過,讀取無須取得任何鎖。從效能角度看,快照隔離的一項關鍵原則是:讀不阻塞寫,寫也不阻塞讀。這樣,資料庫可以一邊在一致快照上執行長期讀查詢,一邊正常處理寫入,二者不會爭用鎖。
為實現快照隔離,資料庫把圖 8-4中的防髒讀機制推廣開來。資料庫不再只為每行保留兩個版本(已提交版本,以及覆蓋它但尚未提交的新版本),而是可能需要保留多個不同的已提交版本,因為正在執行的不同事務可能要觀察資料庫在不同時刻的狀態。由於同一行的多個版本並存,這項技術稱為多版本併發控制(MVCC)。
圖 8-7展示了 PostgreSQL 如何基於 MVCC 實現快照隔離40 42 43(其他實現與之類似)。事務啟動時會獲得一個唯一且單調遞增的事務 ID(txid)。事務寫入資料庫的任何資料,都會以寫入者的事務 ID 標記。(嚴格來說,PostgreSQL 的事務 ID 是 32 位整數,大約經過 40 億個事務就會溢位;vacuum 程序負責清理,確保溢位不會影響資料。)

表中每一行都有一個 inserted_by 欄位,儲存將該行插入表中的事務 ID;還有一個初始為空的 deleted_by 欄位。事務刪除某行時,並不會立即將其從資料庫中移除,而只是把 deleted_by 設為請求刪除的事務 ID,以此標記刪除。等到確定再也沒有事務能訪問這些已刪除資料時,資料庫的垃圾收集程序才會移除帶刪除標記的行並釋放空間。
更新在內部會轉換成一次刪除和一次插入44。例如,在圖 8-7中,事務 13 從賬戶 2 扣除 100 美元,把餘額從 500 美元改為 400 美元。此時 accounts 表實際上包含賬戶 2 的兩行:餘額 500 美元的行由事務 13 標記為刪除,餘額 400 美元的行則由事務 13 插入。
一行的所有版本都存放在同一個資料庫堆中(參見“在索引中儲存值”),不論寫入它們的事務是否已經提交。同一行的各個版本組成連結串列,可以從最新版本連到最舊版本,也可以反向連線,使查詢能在內部遍歷該行的所有版本45 46。
觀察一致快照的可見性規則
事務讀取資料庫時,系統根據事務 ID 判斷哪些行版本可見、哪些不可見。只要審慎定義可見性規則,資料庫就能嚮應用程式呈現一致快照。其工作方式大致如下43:
- 每個事務開始時,資料庫列出當時仍在進行(尚未提交或中止)的所有其他事務。即使這些事務後來提交,其寫入也一律忽略。這樣便能看到不受其他事務隨後提交影響的一致快照。
- 事務 ID 較晚的事務(即在當前事務開始之後啟動,因而不在上述進行中事務列表裡)所做的寫入一律忽略,無論它們是否已經提交。
- 已中止事務的寫入一律忽略,無論中止發生在何時。這樣一來,事務中止後無須立刻從儲存中刪除它寫入的行,因為可見性規則會將它們過濾掉,垃圾收集程序可以稍後再清理。
- 其餘寫入均對應用程式的查詢可見。
這些規則既適用於插入行,也適用於刪除行。在圖 8-7中,事務 12 讀取賬戶 2 時看到的餘額是 500 美元:刪除 500 美元餘額的是事務 13,而根據規則 2,事務 12 看不到事務 13 執行的刪除;同理,插入的 400 美元餘額也尚不可見。
換句話說,只有同時滿足以下兩個條件,一行才可見:
- 讀事務開始時,插入該行的事務已經提交。
- 該行沒有標記為刪除;或者雖已標記為刪除,但請求刪除的事務在讀事務開始時尚未提交。
長期執行的事務可能一直使用同一個快照,繼續讀取在其他事務看來早已被覆蓋或刪除的值。資料庫從不原地更新值,而是在每次變更時插入新版本,因而只需很小的開銷就能提供一致快照。
索引與快照隔離
多版本資料庫中的索引如何工作?最常見的做法是,每個索引項指向與其匹配的某個行版本(最舊或最新版本)。每個行版本可以引用下一個更舊或更新的版本。使用索引的查詢必須沿行版本遍歷,找到既對當前事務可見、值又符合查詢條件的那一行。垃圾收集移除不再對任何事務可見的舊行版本時,也可以一併刪除相應的索引項。
許多實現細節都會影響多版本併發控制的效能45 46。例如,如果同一行的不同版本可以容納在同一個頁面中,PostgreSQL 會透過最佳化避免更新索引40。另一些資料庫則不儲存修改後行的完整副本,只儲存版本間的差異以節省空間。
CouchDB、Datomic 和 LMDB 採用另一種方法。它們同樣使用 B 樹(參見“B 樹”),但採用不可變(寫時複製)的變體:更新時不覆蓋樹頁面,而是為每個被修改的頁面建立新副本。從其父頁面一直到樹根,也都會複製並更新,以指向新版子頁面。未受寫入影響的頁面無須複製,可由新樹繼續共享47。
採用不可變 B 樹時,每個寫事務(或一批事務)都會建立一個新的 B 樹根;某個特定的樹根,就是它建立時資料庫的一致快照。由於後續寫入無法修改現有 B 樹,只能建立新的樹根,因此無須根據事務 ID 過濾行。這種方法同樣需要後臺程序執行壓縮和垃圾收集。
快照隔離、可重複讀和命名混淆
MVCC 是資料庫常用的實現技術,也經常用來實現快照隔離。不過,不同資料庫有時會用不同術語指代同一件事:例如,快照隔離在 PostgreSQL 中稱為“可重複讀”,在 Oracle 中稱為“可序列化”29。反過來,不同系統也會用同一個術語表示不同事物:PostgreSQL 的“可重複讀”指快照隔離,MySQL 的“可重複讀”卻是一個一致性弱於快照隔離的 MVCC 實現41。
之所以會出現這種命名混亂,是因為 SQL 標準沒有快照隔離的概念。該標準建立在 System R 於 1975 年定義的隔離級別之上3,而那時快照隔離尚未問世。標準定義的是表面上與快照隔離相似的“可重複讀”。PostgreSQL 的快照隔離符合這項標準要求,於是將它稱為“可重複讀”,從而可以宣稱符合標準。
遺憾的是,SQL 標準對隔離級別的定義存在缺陷:含糊、不精確,也沒有做到標準理應具備的實現無關性36。好幾種資料庫都聲稱實現了可重複讀,實際提供的保證卻相去甚遠,儘管它名義上已經標準化29。研究文獻給出了可重複讀的正式定義37 38,但大多數實現都不符合這個定義。更添混亂的是,IBM Db2 還用“可重複讀”來指可序列化10。
結果,誰也說不清“可重複讀”究竟是什麼意思。
防止丟失更新
到目前為止,讀已提交和快照隔離主要保證的是:存在併發寫入時,只讀事務能夠看到什麼。我們基本沒有討論兩個事務併發寫入的問題,只講過一種特定的寫—寫衝突——髒寫(參見“沒有髒寫”)。
併發寫事務之間還可能出現其他幾類值得關注的衝突,其中最著名的便是丟失更新。圖 8-1以兩個併發遞增計數器的事務為例,展示了這個問題。
應用程式從資料庫讀取一個值,修改後再寫回去,這個過程稱為讀取—修改—寫入迴圈,可能發生丟失更新。如果兩個事務併發執行這樣的迴圈,其中一項修改可能丟失,因為後一次寫入沒有包含前一個事務的修改。(有時也說後一次寫入抹掉了前一次寫入。)這種模式會出現在許多場景中:
- 遞增計數器或更新賬戶餘額(讀取當前值、計算新值,再把新值寫回);
- 區域性修改複雜值,例如向 JSON 文件內的列表新增元素(解析文件、作出修改,再把修改後的文件寫回);
- 兩個使用者同時編輯一個 wiki 頁面,各自把整頁內容傳送給伺服器來儲存修改,從而覆蓋資料庫中的現有內容。
這個問題非常普遍,因此人們提出了多種解決方案48。
原子寫操作
許多資料庫提供原子更新操作,使應用程式不必自行實現讀取—修改—寫入迴圈。如果業務邏輯可以用這些操作表達,它們通常是最佳方案。例如,下面這條語句在大多數關係型資料庫中都能安全地併發執行:
UPDATE counters SET value = value + 1 WHERE key = 'foo';類似地,MongoDB 等文件資料庫提供原子操作,供應用區域性修改 JSON 文件;Redis 則提供修改優先佇列等資料結構的原子操作。並非所有寫入都容易表示成原子操作——例如,更新 wiki 頁面涉及任意文字編輯,可以用“CRDT 與操作變換”介紹的演算法處理——但只要能夠使用,原子操作通常就是最佳選擇。
原子操作通常這樣實現:讀取物件時取得排他鎖,使其他事務在更新完成之前無法讀取該物件。另一種辦法則是強制所有原子操作都在單個執行緒上執行。
遺憾的是,物件關係對映(ORM)框架很容易讓人無意中寫出不安全的讀取—修改—寫入迴圈,而沒有使用資料庫提供的原子操作49 50 51。由此產生的缺陷往往十分隱蔽,很難透過測試發現。
顯式鎖定
如果資料庫內建的原子操作無法滿足需求,還可以由應用程式顯式鎖定即將更新的物件,再執行讀取—修改—寫入迴圈。其他事務若試圖併發更新或鎖定同一物件,就必須等前一個迴圈完成。
以多人遊戲為例,幾個玩家都可以移動同一個棋子。這時原子操作可能還不夠,因為應用還要確保走法符合遊戲規則,而其中一些邏輯很難合理地寫成資料庫查詢。此時可以加鎖,防止兩個玩家同時移動同一個棋子,如示例 8-1所示。
示例 8-1. 顯式鎖定行以防止丟失更新
BEGIN TRANSACTION;
SELECT * FROM figures
WHERE name = 'robot' AND game_id = 222
FOR UPDATE; ❶
-- 檢查移動是否有效,然後更新
-- 前一個 SELECT 返回的棋子的位置。
UPDATE figures SET position = 'c4' WHERE id = 1234;
COMMIT;❶:FOR UPDATE 子句表示資料庫應當鎖定這條查詢返回的所有行。
這個辦法行得通,但必須仔細推敲應用邏輯才能用對。程式碼中很容易漏掉某處必要的加鎖,進而引入競態條件。
此外,鎖定多個物件會帶來死鎖風險:兩個或更多事務彼此等待對方釋放鎖。許多資料庫會自動檢測死鎖,並中止其中一個事務,讓系統得以繼續推進。應用程式只需重試這個被中止的事務即可。
自動檢測丟失更新
原子操作和鎖透過強制讀取—修改—寫入迴圈依次執行來防止丟失更新。另一種思路是允許這些迴圈並行執行;事務管理器一旦檢測到丟失更新,便中止相應事務,迫使它重試整個迴圈。
這種方法的一項優勢是,資料庫可以結合快照隔離高效完成檢查。事實上,PostgreSQL 的可重複讀、Oracle 的可序列化和 SQL Server 的快照隔離,都會自動檢測丟失更新並中止發生衝突的事務。MySQL/InnoDB 的可重複讀則不會檢測丟失更新29 41。一些作者認為36 38,資料庫只有能夠防止丟失更新,才算提供快照隔離;按照這個定義,MySQL 並沒有提供快照隔離。
自動檢測丟失更新是一項很實用的功能,因為應用程式碼無須使用任何特殊資料庫功能。開發者可能忘記加鎖或使用原子操作而引入缺陷,自動檢測則不容易遺漏。不過,應用程式仍須負責重試被中止的事務。
條件寫入(比較並設定)
不提供事務的資料庫有時會提供條件寫入操作(前文“單物件寫入”已經提到):只有當值自上次讀取後未發生變化,才允許更新,從而防止丟失更新。如果當前值與先前讀到的值不符,更新便不生效,應用必須重試讀取—修改—寫入迴圈。它相當於資料庫版本的原子比較並設定或比較並交換(CAS)指令,許多 CPU 都支援此類指令。
例如,為防止兩個使用者同時更新同一個 wiki 頁面,可以嘗試如下寫法:只有當頁面內容在使用者開始編輯後沒有變化時,更新才會發生。
-- 這可能安全也可能不安全,取決於資料庫實現
UPDATE wiki_pages SET content = 'new content'
WHERE id = 1234 AND content = 'old content';如果內容已經變化,不再與 'old content' 匹配,更新就不會生效;因此需要檢查更新結果,必要時重試。也可以不比較完整內容,而是使用一個每次更新都遞增的版本號列,只在當前版本號未變化時應用更新。這種方法有時稱為樂觀鎖定52。
如果另一個事務併發修改了 content,按照 MVCC 可見性規則,新內容可能並不可見(參見“觀察一致快照的可見性規則”)。許多 MVCC 實現會針對這種場景作出例外:其他事務寫入的值即便在快照中不可見,計算 UPDATE 和 DELETE 查詢的 WHERE 子句時仍然可見。
衝突解決與複製
在複製資料庫中(參見第 6 章),防止丟失更新又多了一個維度:資料副本分佈在多個節點上,並可能在不同節點被併發修改,因此還需採取額外措施。
鎖和條件寫入操作都假定存在唯一的最新資料副本。但採用多主複製或無主複製的資料庫,通常允許多項寫入併發發生,再非同步複製到其他節點,因而無法保證存在唯一的最新副本。所以,基於鎖或條件寫入的技術並不適用於這種場景。(“線性一致性”將更深入地討論這個問題。)
這類複製資料庫通常採用另一種辦法,正如“處理寫入衝突”所述:允許併發寫入產生同一個值的多個衝突版本(也稱為兄弟值),再由應用程式碼或特殊資料結構事後解決衝突併合並版本。
如果更新是可交換的——即在不同副本上按不同順序應用,仍會得到相同結果——那麼合併衝突值便可防止丟失更新。遞增計數器、向集合新增元素都是可交換操作。這正是“CRDT 與操作變換”介紹的 CRDT 背後的思想。不過,條件寫入等操作無法變成可交換操作。
另一方面,最後寫入者勝(LWW)這種衝突解決辦法很容易丟失更新,詳見“最後寫入者勝(丟棄併發寫入)”。遺憾的是,許多複製資料庫都預設採用 LWW。
寫偏差與幻讀
前面介紹了髒寫和丟失更新:不同事務併發寫入相同物件時,可能出現這兩種競態條件。要避免資料損壞,就必須防止它們發生——可以由資料庫自動防範,也可以手動使用鎖、原子寫操作等保護措施。
然而,併發寫入之間的潛在競態條件遠不止這些。本節將考察一些更為微妙的衝突。
先來看一個例子:你正在編寫一款供醫生管理醫院值班安排的應用。醫院通常希望任何時候都有幾位醫生值班,但底線是至少必須有一位。醫生可以放棄自己的班次(例如本人也生病了),前提是同一班次至少還有一位同事繼續值班53 54。
假設某個班次只有 Aaliyah 和 Bryce 兩位值班醫生。兩人都身體不適,決定請假,偏偏又幾乎同時點選了退出值班的按鈕。接下來發生的事情如圖 8-8所示。

每個事務都先檢查當前是否至少有兩位醫生值班;如果是,應用就認為其中一位可以安全退出。由於資料庫採用快照隔離,兩次檢查都返回 2,兩個事務於是都進入下一階段。Aaliyah 更新自己的記錄,退出值班;Bryce 也作出同樣的更新。兩個事務都成功提交,結果卻沒有任何醫生值班,違反了“至少一位醫生值班”的要求。
寫偏差的特徵
這種異常稱為寫偏差36。它既不是髒寫,也不是丟失更新,因為兩個事務更新的是不同物件——分別是 Aaliyah 和 Bryce 的值班記錄。這裡的衝突不那麼明顯,卻確實是一種競態條件:如果兩個事務依次執行,第二位醫生就無法退出值班。只有併發執行事務,才會出現這種異常行為。
可以把寫偏差看作丟失更新的推廣:兩個事務讀取相同的一組物件,隨後各自更新其中一些物件(不同事務可以更新不同物件),就可能發生寫偏差。若不同事務碰巧更新同一個物件,則會表現為髒寫或丟失更新,具體取決於時序。
防止丟失更新的辦法有很多,處理寫偏差時可選手段卻少得多:
單物件原子操作無濟於事,因為這裡涉及多個物件。
一些快照隔離實現能夠自動檢測丟失更新,但遺憾的是,這對寫偏差也無濟於事。PostgreSQL 的可重複讀、MySQL/InnoDB 的可重複讀、Oracle 的可序列化以及 SQL Server 的快照隔離,都不會自動檢測寫偏差29。要自動防止寫偏差,必須採用真正的可序列化隔離(參見“可序列化”)。
有些資料庫允許配置由資料庫強制執行的約束,例如唯一性約束、外來鍵約束或對特定值的限制。但要表達“至少有一位醫生值班”,就需要涉及多個物件的約束。大多數資料庫沒有內建支援這類約束,不過可以嘗試用觸發器或物化檢視來實現,正如“一致性”所述12。
如果無法使用可序列化隔離,這裡的次優選擇大概是顯式鎖定事務所依賴的行。在醫生值班的例子中,可以這樣寫:
BEGIN TRANSACTION; SELECT * FROM doctors WHERE on_call = true AND shift_id = 1234 FOR UPDATE; ❶ UPDATE doctors SET on_call = false WHERE name = 'Aaliyah' AND shift_id = 1234; COMMIT;
❶:與之前一樣,FOR UPDATE 要求資料庫鎖定這條查詢返回的所有行。
寫偏差的更多例子
寫偏差乍看似乎很深奧,一旦意識到它的存在,就會發現它可能出現在許多場景中。再看幾個例子:
- 會議室預訂系統
- 假設你要保證同一間會議室在同一時段不能被重複預訂55。有人發起預訂時,先檢查是否存在衝突(即同一房間是否有時間範圍重疊的預訂);若沒有,再建立會議(參見示例 8-2)。
示例 8-2. 會議室預訂系統試圖避免重複預訂(在快照隔離下並不安全)
BEGIN TRANSACTION; -- 檢查是否有任何現有預訂與中午 12 點到 1 點的時間段重疊 SELECT COUNT(*) FROM bookings WHERE room_id = 123 AND end_time > '2025-01-01 12:00' AND start_time < '2025-01-01 13:00'; -- 如果前一個查詢返回零: INSERT INTO bookings (room_id, start_time, end_time, user_id) VALUES (123, '2025-01-01 12:00', '2025-01-01 13:00', 666); COMMIT;遺憾的是,快照隔離無法阻止另一個使用者併發插入衝突的會議。要保證安排不會衝突,還是得采用可序列化隔離。
- 多人遊戲
- 在示例 8-1中,我們用鎖防止丟失更新,也就是確保兩名玩家不能同時移動同一個棋子。但這把鎖無法阻止玩家把兩個不同棋子移動到棋盤上的同一位置,也無法阻止其他違反規則的走法。視具體規則而定,有時可以使用唯一性約束;否則就很容易發生寫偏差。
- 宣告使用者名稱
- 在要求使用者名稱唯一的網站上,兩個使用者可能同時嘗試用同一個名字建立賬戶。可以用事務先檢查使用者名稱是否已被佔用,如果沒有,再用它建立賬戶。但和前面的例子一樣,這在快照隔離下並不安全。好在唯一性約束就能輕鬆解決這個問題:第二個嘗試註冊該使用者名稱的事務會因違反約束而中止。
- 防止重複消費
- 允許使用者消費金錢或積分的服務,必須確保使用者的支出不超過餘額。一種實現方式是先在使用者賬戶中插入暫定支出項,再列出賬戶中的所有專案,檢查合計餘額是否仍為正數。發生寫偏差時,兩個支出項可能被併發插入,合在一起使餘額變成負數,兩個事務卻都沒有察覺對方。
導致寫偏差的幻讀
這些例子都遵循相似的模式:
SELECT查詢搜尋符合某項條件的行,以檢查是否滿足要求(至少有兩位醫生值班、該會議室在相應時段沒有預訂、棋盤上的目標位置沒有其他棋子、使用者名稱尚未佔用、賬戶仍有餘額)。- 應用程式碼根據第一次查詢的結果決定下一步:繼續執行,或者向用戶報告錯誤並中止。
- 如果決定繼續,應用便向資料庫寫入(
INSERT、UPDATE或DELETE),然後提交事務。
這次寫入改變了步驟 2 作出決定時所依據的前提。換句話說,寫入提交後再次執行步驟 1 的 SELECT,結果就會不同,因為符合搜尋條件的行集合已經改變(少了一位值班醫生、會議室在該時段已有預訂、棋盤上的目標位置已被剛移入的棋子佔據、使用者名稱已被佔用,或賬戶餘額已經減少)。
這些步驟也可以採用不同順序。例如,可以先寫入,再執行 SELECT 查詢,最後根據查詢結果決定中止還是提交。
在醫生值班的例子中,步驟 3 修改的是步驟 1 返回的某一行,因此可以在步驟 1 中鎖定這些行(SELECT FOR UPDATE),使事務安全並避免寫偏差。但其餘四個例子不同:它們檢查的是不存在符合某項條件的行,隨後的寫入則新增了一行,使其符合相同條件。如果步驟 1 的查詢沒有返回任何行,SELECT FOR UPDATE 便無處加鎖56。
一個事務的寫入改變了另一個事務中搜索查詢的結果,這種現象稱為幻讀4。快照隔離可以避免只讀查詢中的幻讀,但在上述讀寫事務中,幻讀可能引發格外棘手的寫偏差。ORM 生成的 SQL 也很容易遇到寫偏差50 51。
物化衝突
如果幻讀的問題在於沒有物件可供加鎖,或許可以人為地在資料庫中引入一個鎖物件?
以會議室預訂為例,可以建立一張房間與時段表。表中每一行對應某個房間的某個特定時段(例如 15 分鐘),提前為未來六個月內所有可能的房間與時段組合建好行。
現在,建立預訂的事務可以鎖定(SELECT FOR UPDATE)表中與目標房間和時段對應的行。取得鎖後,再照常檢查重疊預訂並插入新預訂。請注意,這張附加表並不儲存預訂資訊——它純粹是一組鎖,用來防止同一房間、同一時間範圍內的預訂被併發修改。
這種方法稱為物化衝突:它把幻讀轉化為資料庫中一組具體行上的鎖衝突14。遺憾的是,怎樣物化衝突既難設計又容易出錯,而且讓併發控制機制滲入應用資料模型也很不美觀。因此,物化衝突只能在別無選擇時作為最後手段;大多數情況下,可序列化隔離要好得多。
可序列化
本章已經介紹了幾類容易出現競態條件的事務。讀已提交和快照隔離可以防止其中一部分,卻無法防止其他型別,寫偏差和幻讀尤其棘手。當前的處境著實令人沮喪:
- 隔離級別難以理解,不同資料庫的實現又不一致(例如“可重複讀”的含義相差很大)。
- 單看應用程式碼,很難判斷它在特定隔離級別下執行是否安全。大型應用尤其如此,因為你未必知道還有哪些操作可能併發發生。
- 沒有好用的工具可以幫助檢測競態條件。原則上靜態分析或許能派上用場33,研究成果卻尚未進入實際應用。併發問題通常具有非確定性,只有時序碰巧不利時才會出現,因此也很難透過測試發現。
這並不是新問題——自 20 世紀 70 年代引入弱隔離級別以來,情況一直如此3。研究人員給出的答案始終很簡單:使用可序列化隔離!
可序列化是最強的隔離級別。它保證即使事務實際並行執行,最終結果也與它們序列執行(逐個執行、毫無併發)相同。因此,只要事務單獨執行時行為正確,資料庫就能保證它們併發執行時仍然正確。換句話說,資料庫可以防止所有可能的競態條件。
既然可序列化遠勝於弱隔離帶來的混亂,為什麼大家不都使用它?要回答這個問題,就要看看可序列化有哪些實現方式,以及它們的效能如何。目前提供可序列化的大多數資料庫都採用以下三種技術之一,本章餘下內容將逐一探討:
- 嚴格按照序列順序執行事務(參見“實際序列執行”);
- 兩階段鎖定(參見“兩階段鎖定(2PL)”),幾十年來這是唯一可行的選擇;
- 可序列化快照隔離等樂觀併發控制技術(參見“可序列化快照隔離(SSI)”)。
實際序列執行
避免併發問題最簡單的辦法,就是徹底消除併發:在單個執行緒上按照序列順序,每次只執行一個事務。這樣便完全繞開了檢測和防止事務衝突的問題,而得到的隔離按定義就是可序列化的。
這個想法看似顯而易見,資料庫設計者卻直到 2000 年代才認定,用單執行緒迴圈執行事務切實可行57。此前 30 年間,多執行緒併發一直被視為取得良好效能的必要條件,究竟是什麼變化讓單執行緒執行成為可能?
兩項進展促成了這次重新思考:
- RAM 已經足夠便宜,許多用例都能把整個活躍資料集放進記憶體(參見“全記憶體儲存”)。事務所需的全部資料都在記憶體中時,執行速度遠快於等待從磁碟載入資料。
- 資料庫設計者意識到,OLTP 事務通常很短,只執行少量讀寫(參見“分析型與事務型系統”)。長期執行的分析查詢則通常只讀,可以在序列執行迴圈之外,基於一致快照執行(使用快照隔離)。
VoltDB/H-Store、Redis 和 Datomic 等系統採用了序列執行事務的方式58 59 60。為單執行緒執行而設計的系統有時反而比支援併發的系統性能更好,因為它省去了加鎖的協調開銷。不過,其吞吐量受限於單個 CPU 核。要充分利用這條執行緒,事務的組織方式必須不同於傳統形式。
將事務封裝在儲存過程中
資料庫誕生之初,人們設想事務可以涵蓋使用者活動的整個流程。例如,預訂機票要經過多個階段:搜尋航線、票價和空餘座位;確定行程;預訂每一段航班的座位;填寫乘客資訊;最後付款。資料庫設計者認為,如果把整個流程作為一個事務原子提交,會非常理想。
遺憾的是,人類作出決定和響應的速度很慢。如果資料庫事務要等待使用者輸入,資料庫就必須支援數量可能極其龐大的併發事務,而且其中大部分都處於空閒狀態。絕大多數資料庫無法高效做到這一點,因此幾乎所有 OLTP 應用都會避免在事務中互動等待使用者,以保持事務簡短。在 Web 應用中,這意味著事務要在同一個 HTTP 請求內提交,不能跨越多個請求;新的 HTTP 請求會啟動新的事務。
即使已經把人從關鍵路徑中移除,事務仍往往採用互動式客戶端/伺服器模式,逐條執行語句。應用發出查詢、讀取結果,再根據第一次查詢的結果決定是否發出下一條查詢,如此往復。查詢與結果不斷在應用程式碼所在的機器和資料庫伺服器之間來回傳遞。
這種互動式事務把大量時間耗在應用與資料庫之間的網路通訊上。如果資料庫禁止併發、每次只處理一個事務,吞吐量會低得可怕:資料庫大部分時間都在等待應用發出當前事務的下一條查詢。採用這種模式的資料庫必須併發處理多個事務,才能獲得合理性能。
因此,單執行緒序列處理事務的系統不允許互動式多語句事務。應用要麼只使用單語句事務,要麼提前把整個事務的程式碼作為儲存過程提交給資料庫61。
互動式事務與儲存過程的差別如圖 8-9所示。只要事務所需的全部資料都在記憶體中,儲存過程就能快速執行,無須等待任何網路或磁碟 I/O。

儲存過程的利弊
儲存過程早已存在於關係型資料庫中,並從 1999 年起成為 SQL 標準(SQL/PSM)的一部分。不過,它們因種種原因名聲不佳:
- 傳統上,每家資料庫廠商都有自己的儲存過程語言(Oracle 使用 PL/SQL,SQL Server 使用 T-SQL,PostgreSQL 使用 PL/pgSQL 等)。這些語言沒有跟上通用程式語言的發展,今天看來頗為醜陋陳舊,也缺少大多數程式語言擁有的庫生態。
- 在資料庫中執行的程式碼難以管理:與應用伺服器相比,它更難除錯,不便進行版本控制和部署,測試更麻煩,也難以接入採集指標的監控系統。
- 資料庫通常比應用伺服器對效能更加敏感,因為一個數據庫例項往往由許多應用伺服器共享。一個寫得很差的儲存過程(例如佔用大量記憶體或 CPU 時間),可能比應用伺服器上同樣糟糕的程式碼造成更大麻煩。
- 在允許租戶自行編寫儲存過程的多租戶系統中,讓不受信任的程式碼與資料庫核心執行在同一個程序裡,會帶來安全風險62。
不過,這些問題並非無法克服。現代儲存過程實現已經放棄 PL/SQL,轉而採用現有的通用程式語言:VoltDB 使用 Java 或 Groovy,Datomic 使用 Java 或 Clojure,Redis 使用 Lua,MongoDB 使用 JavaScript。
當應用邏輯不便嵌入其他位置時,儲存過程也很有用。例如,使用 GraphQL 的應用可能透過 GraphQL 代理直接開放資料庫。若代理不支援複雜的驗證邏輯,就可以用儲存過程把這類邏輯直接嵌入資料庫;如果資料庫不支援儲存過程,則只能在代理與資料庫之間另行部署驗證服務。
儲存過程配合記憶體資料,使所有事務都在單執行緒上執行成為可行方案。只要儲存過程無須等待 I/O,又能避開其他併發控制機制的開銷,單執行緒也可以達到相當可觀的吞吐量。
VoltDB 還利用儲存過程進行復制:它不把事務寫入從一個節點複製到另一個節點,而是在每個副本上執行同一個儲存過程。因此,VoltDB 要求儲存過程必須是確定性的,即在不同節點執行時產生相同結果。例如,事務若要使用當前日期和時間,就必須透過特殊的確定性 API 獲取(關於確定性操作的更多細節,參見“持久化執行與工作流”)。這種方法稱為狀態機複製,我們將在第 10 章再次討論它。
分片
序列執行所有事務大大簡化了併發控制,卻也把資料庫的事務吞吐量限制在單臺機器、單個 CPU 核的處理能力以內。只讀事務可以透過快照隔離在別處執行;但對寫入吞吐量很高的應用而言,單執行緒事務處理器可能成為嚴重瓶頸。
要擴充套件到多個 CPU 核和多個節點,可以像 VoltDB 那樣對資料分片(參見第 7 章)。如果能讓每個事務只讀寫單個分片內的資料,那麼每個分片都可以擁有自己的事務處理執行緒,彼此獨立執行。此時可以為每個 CPU 核分配一個分片,使事務吞吐量隨 CPU 核數線性增長59。
但凡事務需要訪問多個分片,資料庫就必須在所有涉及的分片之間進行協調。儲存過程要在這些分片上步調一致地執行,才能保證整個系統的可序列化。
跨分片事務有額外的協調開銷,因此遠慢於單分片事務。VoltDB 報告的跨分片寫入吞吐量約為每秒 1,000 次,比單分片吞吐量低幾個數量級,而且無法靠增加機器來提升61。近來的研究探索了讓多分片事務更具可伸縮性的辦法63。
事務能否限制在單個分片內,很大程度上取決於應用所用的資料結構。簡單的鍵值資料通常很容易分片;具有多個二級索引的資料則很可能需要大量跨分片協調(參見“分片與二級索引”)。
序列執行總結
在滿足特定約束的情況下,序列執行事務已經成為實現可序列化隔離的一種可行辦法:
- 每個事務都必須短小迅速,因為一個慢事務就足以拖住所有事務處理。
- 這種辦法最適合活躍資料集能夠裝進記憶體的場景。不常訪問的資料可以移到磁碟,但單執行緒事務一旦需要訪問這些資料,系統就會變得很慢。
- 寫入吞吐量必須足夠低,能由單個 CPU 核處理;否則,事務必須能夠分片執行,並且不需要跨分片協調。
- 跨分片事務雖然可行,其吞吐量卻很難擴充套件。
兩階段鎖定(2PL)
大約 30 年間,資料庫領域只有一種得到廣泛應用的可序列化演算法:兩階段鎖定(2PL)。它有時也稱為強嚴格兩階段鎖定(SS2PL),以區別於 2PL 的其他變體。
2PL 不是 2PC
兩階段鎖定(2PL)與兩階段提交(2PC)是完全不同的兩回事。2PL 提供可序列化隔離,2PC 則為分散式資料庫提供原子提交(參見“兩階段提交(2PC)”)。為免混淆,最好把它們當成彼此獨立的概念,不必理會名稱上這種不巧的相似。
前文已經看到,鎖通常用於防止髒寫(參見“沒有髒寫”):若兩個事務併發寫入同一物件,鎖會迫使第二個寫入者等待,直到第一個事務提交或中止後才能繼續。
兩階段鎖定與此類似,但加鎖要求嚴格得多。只要沒有寫入,多個事務可以併發讀取同一個物件;但只要有事務想寫入(修改或刪除)物件,就必須取得獨佔訪問權:
- 如果事務 A 已讀取某個物件,而事務 B 想寫入該物件,B 必須等 A 提交或中止後才能繼續。(這樣可以確保 B 不會在 A 不知情的情況下改變物件。)
- 如果事務 A 已寫入某個物件,而事務 B 想讀取該物件,B 必須等 A 提交或中止後才能繼續。(2PL 不允許像圖 8-4那樣讀取物件的舊版本。)
在 2PL 中,寫會阻塞其他寫,也會阻塞讀;反過來,讀同樣會阻塞寫。快照隔離則奉行“讀不阻塞寫,寫也不阻塞讀”(參見“多版本併發控制(MVCC)”),這正是快照隔離與兩階段鎖定的關鍵區別。另一方面,2PL 提供可序列化,能夠防止前面討論的所有競態條件,包括丟失更新和寫偏差。
兩階段鎖定的實現
MySQL(InnoDB)和 SQL Server 的可序列化隔離級別,以及 Db2 的可重複讀隔離級別,都採用 2PL29。
資料庫透過為每個物件設定一把鎖來阻塞讀寫操作。鎖可以處於共享模式或獨佔模式,這也稱為多讀者單寫者鎖。具體規則如下:
- 事務要讀取物件,必須先以共享模式取得鎖。多個事務可以同時持有共享鎖;但如果另一個事務已持有該物件的獨佔鎖,讀事務就必須等待。
- 事務要寫入物件,必須先以獨佔模式取得鎖。此時不允許其他事務以任何模式持鎖;只要物件上已有鎖,寫事務就必須等待。
- 事務先讀後寫時,可以把共享鎖升級為獨佔鎖。升級規則與直接取得獨佔鎖相同。
- 事務取得鎖後,必須一直持有到事務結束(提交或中止)。這正是“兩階段”名稱的由來:第一階段在事務執行期間獲取鎖,第二階段在事務結束時一次釋放所有鎖。
由於大量使用鎖,很容易出現事務 A 等待事務 B 釋放鎖、事務 B 又反過來等待事務 A 的情況,這稱為死鎖。資料庫會自動檢測事務之間的死鎖並中止其中一個,讓其他事務能夠繼續推進;被中止的事務則由應用程式重試。
兩階段鎖定的效能
兩階段鎖定最大的缺點,也是它自 20 世紀 70 年代以來始終沒有普及到所有資料庫的原因,就是效能:在兩階段鎖定下,事務吞吐量和查詢響應時間都明顯遜於弱隔離。
這部分源於獲取和釋放大量鎖的開銷,更主要的原因則是併發度降低。按照設計,只要兩個併發事務執行的操作有任何可能引發競態條件,其中一個就必須等另一個完成。
例如,一個事務需要讀取整張表,用於備份、分析查詢或完整性檢查(參見“快照隔離與可重複讀”),就必須為整張表取得共享鎖。讀事務首先要等待所有正在寫表的事務結束;隨後,在它讀取整張表期間(大表可能耗時很久),所有想寫入該表的事務又會被阻塞,直至這個大型只讀事務提交。實際上,資料庫會在很長一段時間內無法接受寫入。
因此,採用 2PL 的資料庫延遲可能相當不穩定;工作負載一旦存在爭用,高百分位延遲就可能非常糟糕(參見“描述效能”)。僅僅一個慢事務,或者一個訪問大量資料、取得許多鎖的事務,就可能讓系統其餘部分陷入停頓。
基於鎖的讀已提交也會發生死鎖,但在 2PL 可序列化隔離下,死鎖往往更為頻繁(具體取決於事務的訪問模式)。這又會帶來額外的效能問題:事務因死鎖而中止、重試時,必須從頭重做全部工作。若死鎖頻繁,浪費的計算量會相當可觀。
謂詞鎖
前面對鎖的描述略過了一個微妙卻重要的細節。“導致寫偏差的幻讀”介紹過幻讀:一個事務改變了另一個事務的搜尋查詢結果。提供可序列化隔離的資料庫必須防止幻讀。
在會議室預訂的例子中,如果一個事務已經搜尋某個房間在特定時段內的現有預訂(參見示例 8-2),另一個事務就不能併發插入或更新同一房間、同一時段的預訂。(併發預訂其他房間,或者預訂同一房間但互不影響的其他時段,則沒有問題。)
怎樣實現這一點?從概念上講,需要使用謂詞鎖4。它的工作方式類似前面介紹的共享鎖和獨佔鎖,卻不屬於某個特定物件(例如表中的一行),而是屬於所有符合某項搜尋條件的物件,例如:
SELECT * FROM bookings
WHERE room_id = 123 AND
end_time > '2025-01-01 12:00' AND
start_time < '2025-01-01 13:00';謂詞鎖限制訪問如下:
- 事務 A 想讀取符合某項條件的物件(如上面的
SELECT),必須先在查詢條件上取得共享模式的謂詞鎖。如果事務 B 已經對任何符合條件的物件持有獨佔鎖,A 就要等 B 釋放鎖後才能查詢。 - 事務 A 想插入、更新或刪除物件,必須先檢查舊值或新值是否符合任何已有謂詞鎖。如果發現事務 B 持有匹配的謂詞鎖,A 就要等 B 提交或中止後才能繼續。
關鍵在於,謂詞鎖甚至適用於資料庫中尚不存在、未來卻可能加入的物件(即幻讀)。兩階段鎖定若包含謂詞鎖,資料庫就能防止各種形式的寫偏差和其他競態條件,使隔離達到可序列化。
索引範圍鎖
遺憾的是,謂詞鎖效能不佳:活躍事務持有大量鎖時,檢查是否有匹配的鎖會非常耗時。因此,大多數採用 2PL 的資料庫實際實現的是索引範圍鎖(也稱為 next-key locking),即謂詞鎖的一種簡化近似54 64。
把謂詞放寬到匹配更大的物件集合,是一種安全的簡化。例如,要鎖定 123 號房間從中午 12 點到下午 1 點的預訂,可以近似為鎖定 123 號房間全天所有預訂;也可以近似為鎖定中午 12 點到下午 1 點所有房間的預訂。這樣做是安全的,因為任何符合原始謂詞的寫入,必然也符合近似後的條件。
會議室預訂資料庫很可能在 room_id 列上建有索引,或在 start_time 和 end_time 上建有索引(否則前述查詢面對大型資料庫會非常慢):
- 假設索引建在
room_id上,資料庫用它查詢 123 號房間的現有預訂。資料庫可以直接把共享鎖附加到這條索引項上,表示某個事務已搜尋過 123 號房間的預訂。 - 如果資料庫使用時間索引查詢現有預訂,則可以把共享鎖附加到索引中的一段值域上,表示某個事務已經搜尋過與 2025 年 1 月 1 日中午 12 點至下午 1 點重疊的預訂。
無論採用哪種索引,搜尋條件的近似都會附著在其中一個索引上。另一個事務若想插入、更新或刪除同一房間和/或重疊時段的預訂,就必須更新索引的同一部分,於是會碰到共享鎖,被迫等到鎖釋放。
這樣便能有效防止幻讀和寫偏差。索引範圍鎖不如謂詞鎖精確,可能會鎖住比維持可序列化嚴格所需更大範圍的物件;但它的開銷低得多,是一種很好的折中。
如果沒有合適的索引可供附加範圍鎖,資料庫可以退回到對整張表加共享鎖。這樣會阻塞所有其他事務寫表,效能很差,卻是安全的後備方案。
可序列化快照隔離(SSI)
到目前為止,資料庫併發控制的前景顯得頗為黯淡:一邊是效能不佳的兩階段鎖定,以及伸縮性不佳的序列執行;另一邊是效能雖好,卻容易出現丟失更新、寫偏差、幻讀等競態條件的弱隔離。可序列化隔離與良好效能從根本上就無法兼得嗎?
看來並非如此。一種名為可序列化快照隔離(SSI)的演算法能夠提供完整的可序列化,與快照隔離相比只付出很小的效能代價。SSI 相對較新,最早於 2008 年提出53 65。
如今,SSI 及類似演算法已經用於單節點資料庫(PostgreSQL 的可序列化隔離級別54、SQL Server 的記憶體 OLTP/Hekaton66 和 HyPer67)、分散式資料庫(CockroachDB5 和 FoundationDB8),以及 BadgerDB 等嵌入式儲存引擎。
悲觀併發控制與樂觀併發控制
兩階段鎖定屬於所謂的悲觀併發控制機制。它遵循這樣的原則:只要有任何出錯的可能(例如另一個事務持有鎖),就先等待,直到局面重新安全後再行動。這類似於多執行緒程式設計中用來保護資料結構的互斥。
從某種意義上說,序列執行把悲觀推到了極致:本質上,每個事務在執行期間都像是對整個資料庫(或一個數據庫分片)持有獨佔鎖。作為補償,每個事務都要儘快執行,使這把“鎖”只持有很短時間。
相比之下,可序列化快照隔離是一種樂觀併發控制技術。這裡的“樂觀”是指:即使出現潛在危險,也不阻塞事務,而是讓它繼續執行,寄望最終不會出問題。等事務準備提交時,資料庫再檢查是否發生了壞事(即隔離是否遭到破壞);若有,就中止並重試事務。只有執行結果可序列化的事務才能提交。
樂觀併發控制是個古老的思想68,其利弊也已爭論多年69。存在嚴重爭用(許多事務訪問相同物件)時,它表現很差,因為很大比例的事務都不得不中止。若系統已經接近最大吞吐量,重試事務帶來的額外負載還會進一步拖累效能。
不過,如果系統有充足的餘量,事務之間爭用又不嚴重,樂觀併發控制往往優於悲觀方案。可交換的原子操作還能降低爭用:例如,多個事務併發遞增計數器時,應用這些增量的順序無關緊要(只要同一事務沒有讀取該計數器),因此所有併發增量都可以執行而不會衝突。
顧名思義,SSI 建立在快照隔離之上:事務中的所有讀取都來自資料庫的一致快照(參見“快照隔離與可重複讀”)。在此基礎上,SSI 加入演算法來檢測讀寫之間的可序列化衝突,並決定應當中止哪些事務。
基於過時前提的決策
前面討論快照隔離中的寫偏差時(參見“寫偏差與幻讀”),我們反覆看到一種模式:事務從資料庫讀取資料、檢查查詢結果,再根據結果決定執行某項操作(向資料庫寫入)。然而在快照隔離下,等事務提交時,原查詢結果可能已經過時,因為資料在此期間可能被修改。
換句話說,事務根據某個前提行動——即事務開始時為真的事實,例如“當前有兩名醫生值班”。等到事務準備提交時,原始資料可能已經變化,原先的前提不再成立。
應用發出查詢(例如“當前有多少醫生值班?”)時,資料庫並不知道應用邏輯會如何使用查詢結果。為確保安全,資料庫必須假定:查詢結果(即前提)一有變化,該事務中的寫入就可能失效。也就是說,事務中的查詢與寫入之間可能存在因果依賴。為了提供可序列化隔離,資料庫必須檢測事務是否可能依據過時前提行事,並在這種情況下中止事務。
資料庫怎樣判斷查詢結果可能已經變化?需要考慮兩種情況:
- 檢測是否讀取了過時的 MVCC 物件版本(未提交的寫入發生在讀取之前);
- 檢測是否有寫入影響了先前的讀取(寫入發生在讀取之後)。
檢測陳舊的 MVCC 讀取
回想一下,快照隔離通常透過多版本併發控制實現(MVCC;參見“多版本併發控制(MVCC)”)。事務從 MVCC 資料庫的一致快照讀取時,會忽略其他事務在快照生成時尚未提交的所有寫入。
在圖 8-10中,事務 43 看到 Aaliyah 的 on_call = true,因為修改其值班狀態的事務 42 尚未提交。但等事務 43 準備提交時,事務 42 已經提交。這意味著,讀取一致快照時被忽略的寫入現在已經生效,事務 43 的前提不再成立。如果寫入者插入的是此前不存在的資料,情況還會更複雜(參見“導致寫偏差的幻讀”)。“檢測影響先前讀取的寫入”將介紹 SSI 如何檢測幻寫。

為防止這種異常,資料庫必須跟蹤事務何時因 MVCC 可見性規則而忽略了其他事務的寫入。等事務準備提交時,再檢查這些被忽略的寫入是否已經提交;若是,就必須中止該事務。
為什麼非要等到提交?為什麼不在發現過時讀取時立刻中止事務 43?如果事務 43 是隻讀事務,就沒有寫偏差風險,無須中止;而在它讀取時,資料庫還不知道它稍後是否會寫入。此外,事務 42 可能最終中止,也可能在事務 43 提交時仍未提交,於是這次讀取到頭來並未過時。透過避免不必要的中止,SSI 保留了快照隔離支援長期讀取一致快照的優點。
檢測影響先前讀取的寫入
第二種情況,是資料被讀取後又遭另一個事務修改,如圖 8-11所示。

討論兩階段鎖定時,我們介紹過索引範圍鎖(參見“索引範圍鎖”),資料庫可以藉此鎖定所有符合某項查詢條件的行,例如 WHERE shift_id = 1234。SSI 可以使用類似技術,區別在於 SSI 的鎖不會阻塞其他事務。
在圖 8-11中,事務 42 和 43 都查詢了班次 1234 的值班醫生。如果 shift_id 上有索引,資料庫就能利用索引項 1234 記錄事務 42 和 43 讀過這項資料。(沒有索引時,也可以在表級別跟蹤。)這些資訊只需保留一段時間:等事務結束(提交或中止),並且所有與之併發的事務也結束後,資料庫就可以忘掉它讀過哪些資料。
事務寫入資料庫時,必須在索引中查詢近期讀過受影響資料的其他事務。這個過程類似為受影響的鍵範圍取得寫鎖,但它不會一直阻塞到讀事務提交,而是像警戒線一樣:只負責通知相關事務,它們讀過的資料可能已經過時。
在圖 8-11中,事務 43 通知事務 42:它先前讀過的資料已經過時;事務 42 也反過來通知事務 43。事務 42 率先提交併獲得成功:雖然事務 43 的寫入影響了事務 42,但事務 43 尚未提交,所以寫入還未生效。等事務 43 準備提交時,事務 42 的衝突寫入已經提交,因此事務 43 必須中止。
可序列化快照隔離的效能
一如既往,許多工程細節會影響演算法的實際表現。例如,需要權衡以多細的粒度跟蹤事務讀寫。如果資料庫詳細記錄每個事務的活動,就能精確判斷哪些事務應當中止,但記賬開銷可能很大;較粗粒度的跟蹤更快,卻可能中止一些本來無須中止的事務。
有些情況下,事務讀到被另一事務覆蓋的資訊也沒有關係:結合其他事件,有時可以證明執行結果仍然可序列化。PostgreSQL 利用這一理論來減少不必要的中止14 54。
與兩階段鎖定相比,可序列化快照隔離的主要優勢是:事務無須因等待另一個事務持有的鎖而阻塞。與快照隔離一樣,寫不阻塞讀,讀也不阻塞寫。這項設計原則讓查詢延遲更加可預測,波動也更小。尤其是隻讀查詢可以無須任何鎖、直接執行在一致快照上,對讀取密集型工作負載很有吸引力。
與序列執行相比,可序列化快照隔離不受單個 CPU 核吞吐量的限制。例如,FoundationDB 把可序列化衝突檢測分散到多臺機器上,因而可以擴充套件到很高的吞吐量。即使資料分片存放在多臺機器上,事務仍可讀寫多個分片,同時保證可序列化隔離。
與非可序列化的快照隔離相比,檢查可序列化違規會帶來一些效能開銷。這項開銷究竟有多大,仍有爭議:有人認為可序列化檢查得不償失70;也有人認為如今可序列化效能已經足夠好,沒必要再採用較弱的快照隔離67。
中止率會顯著影響 SSI 的總體效能。一個長期讀寫資料的事務很可能遇到衝突而中止,因此 SSI 要求讀寫事務相當短小(長期執行的只讀事務沒有問題)。不過,與兩階段鎖定或序列執行相比,SSI 對慢事務沒有那麼敏感。
分散式事務
前幾節重點討論了實現隔離性(即 ACID 中的 I)所需的併發控制。前面介紹的演算法既適用於單節點資料庫,也適用於分散式資料庫。儘管併發控制演算法在伸縮時會遇到挑戰(例如為 SSI 執行分散式可序列化檢查),分散式併發控制的總體思路仍與單節點併發控制相似8。
轉向分散式事務後,一致性和永續性也沒有太大變化;但原子性需要格外小心。
對於只在單個數據庫節點上執行的事務,原子性通常由儲存引擎實現。客戶端請求資料庫節點提交時,資料庫先將事務寫入持久化(通常寫入預寫日誌;參見“使 B 樹可靠”),再向磁碟日誌追加一條提交記錄。如果資料庫在此過程中崩潰,節點重啟後便從日誌恢復事務:若提交記錄在崩潰前已經成功落盤,事務視為已提交;否則,該事務的所有寫入都會回滾。
因此在單節點上,事務能否提交,關鍵取決於資料持久化落盤的順序:先寫資料,再寫提交記錄22。決定事務提交還是中止的關鍵時刻,就是磁碟完成提交記錄寫入的一刻:在此之前,事務仍可能因崩潰而中止;在此之後,即使資料庫崩潰,事務也已經提交。換言之,是一個單獨的裝置——連線在特定節點上的某塊磁碟的控制器——使提交具備原子性。
可是,如果事務涉及多個節點呢?例如,分片資料庫中可能有多物件事務,也可能有全域性二級索引,其中索引項和主資料位於不同節點(參見“分片與二級索引”)。大多數“NoSQL”分散式資料儲存不支援這類分散式事務,但不少分散式關係型資料庫支援。
此時,僅僅向所有節點發送提交請求,讓每個節點獨立提交事務並不夠。如圖 8-12所示,提交很容易在一部分節點上成功,卻在另一部分節點上失敗:
- 某些節點可能檢測到約束違規或衝突,因而必須中止,其他節點卻能成功提交;
- 某些提交請求可能在網路中丟失,最終因超時而中止,其他請求卻順利抵達;
- 某些節點可能在提交記錄完全寫入前崩潰,恢復時回滾事務,其他節點卻成功提交。

如果有些節點提交、有些節點中止,節點之間便會出現不一致。而事務一旦在某個節點上提交,即便後來發現它在另一個節點上中止,也不能再撤回。因為資料提交後,在讀已提交或更強隔離下就會對其他事務可見。例如,在圖 8-12中,當用戶 1 發現數據庫 1 提交失敗時,使用者 2 已經在資料庫 2 讀到了同一事務寫入的資料。如果事後再中止使用者 1 的事務,就連使用者 2 的事務也必須撤銷,因為它所依據的資料被追溯宣佈為從未存在。
更好的辦法是確保參與事務的節點要麼全部提交,要麼全部中止,絕不允許兩種結果混雜。這就是所謂的原子提交問題。
兩階段提交(2PC)
兩階段提交是一種跨多個節點實現原子事務提交的演算法,也是分散式資料庫中的經典演算法13 71 72。有些資料庫在內部使用 2PC;它也以 XA 事務73的形式開放給應用程式(例如 Java 事務 API 就支援 XA),或者透過 WS-AtomicTransaction 用於 SOAP Web 服務74 75。
2PC 的基本流程如圖 8-13所示。單節點事務只需一次提交請求,2PC 則把提交或中止過程分成兩個階段,名稱也由此而來。

圖 8-13. 兩階段提交(2PC)的成功執行。
2PC 引入了一個單節點事務中通常沒有的元件:協調者(也稱為事務管理器)。協調者通常實現為一個庫,與發起事務的應用執行在同一程序中(例如嵌入 Java EE 容器);也可以作為獨立程序或服務執行。Narayana、JOTM、BTM 和 MSDTC 都屬於此類協調者。
使用 2PC 時,分散式事務照常開始:應用在多個數據庫節點上讀寫資料,這些節點稱為事務的參與者。應用準備提交時,協調者進入第一階段,向每個參與者傳送準備請求,詢問它是否能夠提交,並收集各參與者的響應:
- 如果所有參與者都回答“是”,表示已準備好提交,協調者就在第二階段發出提交請求,真正執行提交;
- 如果任何參與者回答“否”,協調者就在第二階段向所有節點發送中止請求。
這個過程有點像西方傳統婚禮:牧師分別詢問新娘和新郎是否願意與對方結婚,通常兩人都會回答“我願意”。收到雙方確認後,牧師宣佈二人結為夫妻——事務提交,這一喜訊也廣播給所有來賓。如果任何一方沒有回答“願意”,儀式就會中止76。
系統承諾
僅憑上面的簡短描述,或許還看不出為什麼兩階段提交能夠保證原子性,跨多個節點的一階段提交卻不能。畢竟在兩階段流程中,準備和提交請求同樣可能丟失。2PC 究竟有何不同?
要理解它為何有效,需要把過程進一步拆解:
- 應用要啟動分散式事務時,先向協調者申請一個全域性唯一的事務 ID。
- 應用在每個參與者上啟動一個單節點事務,併為其附上這個全域性事務 ID。所有讀寫都在這些單節點事務中完成。如果此階段出現任何問題(例如節點崩潰或請求超時),協調者或任何參與者都可以中止事務。
- 應用準備提交時,協調者向所有參與者傳送帶全域性事務 ID 的準備請求。任何一個請求失敗或超時,協調者都會向所有參與者傳送該事務 ID 的中止請求。
- 參與者收到準備請求後,必須確保自己在任何情況下都一定能提交事務。這既包括把全部事務資料寫入磁碟(崩潰、斷電或磁碟空間耗盡都不能成為以後拒絕提交的理由),也包括檢查衝突和約束違規。節點向協調者回答“是”,就等於承諾:只要收到要求,事務一定能夠無誤提交。換句話說,參與者尚未真正提交,卻已經放棄了自行中止事務的權利。
- 協調者收到所有準備請求的響應後,便對提交還是中止作出最終決定(只有所有參與者都投“是”才會提交)。協調者必須把決定寫入磁碟上的事務日誌,這樣即使隨後崩潰,恢復後也知道自己作過什麼決定。這一時刻稱為提交點。
- 協調者的決定一旦落盤,就向所有參與者傳送提交或中止請求。請求若失敗或超時,協調者必須不斷重試,直到成功為止。此時再無回頭路:如果決定提交,就必須執行到底,無論需要重試多少次。參與者若在此期間崩潰,恢復後也必須提交事務——既然它已經投了“是”,就不能反悔。
因此,協議中有兩個關鍵的“不歸點”:參與者投“是”時,承諾自己日後一定能夠提交(儘管協調者仍可決定中止);協調者一旦作出決定,該決定便不可撤銷。正是這套承諾保證了 2PC 的原子性。(單節點原子提交把這兩件事合併成一步:將提交記錄寫入事務日誌。)
回到婚禮的比喻:說出“我願意”之前,你或準配偶都可以用“絕對不行!”之類的回答中止事務;一旦說出“我願意”,就不能撤回。即使說完後昏了過去,沒有聽見牧師宣佈“你們現在結為夫妻”,也不會改變事務已經提交的事實。恢復意識後,可以向牧師查詢全域性事務 ID 的狀態,確認自己是否已經成婚;也可以等待牧師再次傳送提交請求——在你昏迷期間,他一直都在重試。
協調者失效
我們已經討論過 2PC 期間參與者或網路發生故障時會怎樣:任何準備請求失敗或超時,協調者都會中止事務;任何提交或中止請求失敗,協調者都會無限重試。但協調者自身崩潰時會發生什麼,就沒那麼清楚了。
如果協調者在傳送準備請求前失效,參與者可以安全中止事務。但參與者一旦收到準備請求並投出“是”,便不能再單方面中止,必須等待協調者告知事務究竟提交還是中止。如果協調者此時崩潰或網路發生故障,參與者只能等待。這種狀態下的事務稱為存疑或不確定事務。
圖 8-14展示了這種情況。在圖中的例子裡,協調者實際決定提交,資料庫 2 也收到了提交請求;但協調者還沒來得及把提交請求發給資料庫 1 就崩潰了,所以資料庫 1 不知道該提交還是中止。超時對此也無濟於事:資料庫 1 若在超時後自行中止,就會與已經提交的資料庫 2 不一致;自行提交同樣不安全,因為另一個參與者可能已經中止。

圖 8-14. 參與者投票“是”之後,協調者崩潰。資料庫 1 不知道該提交還是中止。
收不到協調者的訊息,參與者就無從知道應提交還是中止。原則上,參與者可以相互通訊,瞭解各自如何投票並達成某種協議,但這並不屬於 2PC 協議。
完成 2PC 的唯一辦法,就是等待協調者恢復。這正是協調者必須先把提交或中止決定寫入磁碟事務日誌,再向參與者傳送請求的原因。協調者恢復後,透過讀取事務日誌來確定所有存疑事務的狀態;協調者日誌中沒有提交記錄的事務一律中止。因此,2PC 的提交點歸根結底是協調者上的一次普通單節點原子提交。
三階段提交
由於 2PC 可能卡住並一直等待協調者恢復,兩階段提交也稱為阻塞式原子提交協議。理論上可以設計非阻塞式原子提交協議,使它在節點發生故障時不會卡住;但要在實踐中做到這一點並不容易。
有人提出三階段提交(3PC)作為 2PC 的替代方案13 77。然而,3PC 假設網路延遲有上界、節點響應時間也有上界;大多數現實系統都存在無界網路延遲和程序暫停(參見第 9 章),3PC 在其中無法保證原子性。
實踐中更好的辦法,是用容錯共識協議替代單節點協調者。第 10 章將介紹如何做到這一點。
跨不同系統的分散式事務
分散式事務和兩階段提交的名聲譭譽參半。一方面,它們提供了難以用其他方式實現的重要安全保證;另一方面,它們又因引發運維問題、嚴重損害效能,以及作出力所不及的承諾而備受批評78 79 80 81。許多雲服務正是出於這些運維問題,選擇不實現分散式事務82。
某些分散式事務實現會帶來沉重的效能代價。兩階段提交的固有開銷,大多來自崩潰恢復所需的額外強制刷盤(fsync)和額外網路往返。
不過,我們不應就此全盤否定分散式事務,而應仔細審視,從中汲取重要經驗。首先要明確“分散式事務”究竟指什麼,因為人們經常混淆兩種截然不同的分散式事務:
- 資料庫內部分散式事務
- 一些分散式資料庫(即標準配置就使用複製和分片的資料庫)支援資料庫節點之間的內部事務。例如,YugabyteDB、TiDB、FoundationDB、Spanner、VoltDB,以及 MySQL Cluster 的 NDB 儲存引擎都支援內部事務。此時,參與事務的所有節點執行的都是同一種資料庫軟體。
- 異構分散式事務
- 異構事務的參與者來自兩種或更多不同技術,例如不同廠商的兩種資料庫,甚至還包括訊息代理等非資料庫系統。即使底層系統截然不同,跨系統分散式事務仍必須保證原子提交。
資料庫內部事務無須與其他系統相容,因而可以採用任意協議,並針對具體技術進行最佳化。所以,資料庫內部的分散式事務通常運轉良好;跨異構技術的事務則困難得多。
恰好一次訊息處理
異構分散式事務可以用強有力的方式整合不同系統。例如,當且僅當處理訊息的資料庫事務成功提交,才確認訊息佇列中的那條訊息已經處理。實現方法是在同一個事務中原子提交訊息確認和資料庫寫入。有了分散式事務,即使訊息代理與資料庫是執行在不同機器上的兩種互不相關的技術,也能做到這一點。
如果訊息傳遞或資料庫事務任一失敗,兩者都會中止,訊息代理稍後便可安全地重新投遞。透過原子提交訊息及其處理副作用,可以確保訊息實際上恰好處理一次,即使成功之前重試了好幾次。每次中止都會丟棄未完成事務產生的所有副作用。這就是恰好一次語義。
不過,只有事務影響的所有系統都能採用同一種原子提交協議,這類分散式事務才有可能實現。例如,假設處理訊息的副作用是傳送郵件,而郵件伺服器不支援兩階段提交;一旦訊息處理失敗並重試,郵件就可能傳送兩次甚至更多次。反之,如果事務中止時,訊息處理產生的所有副作用都能回滾,那麼處理過程便可安全重試,就像什麼也沒有發生一樣。
本章稍後還會回到恰好一次語義。現在先來看看支援這類異構分散式事務的原子提交協議。
XA 事務
X/Open XA(eXtended Architecture,即擴充套件架構的縮寫)是跨異構技術實現兩階段提交的標準73。它於 1991 年釋出,如今已得到廣泛實現:許多傳統關係型資料庫(包括 PostgreSQL、MySQL、Db2、SQL Server 和 Oracle)和訊息代理(包括 ActiveMQ、HornetQ、MSMQ 和 IBM MQ)都支援 XA。
XA 並不是網路協議,而只是一套與事務協調者互動的 C API。其他語言也提供相應繫結。例如,在 Java EE 應用中,XA 事務透過 Java 事務 API(JTA)實現;許多使用 Java 資料庫連線(JDBC)的資料庫驅動,以及使用 Java 訊息服務(JMS)API 的訊息代理驅動都支援 JTA。
XA 假定應用透過網路驅動或客戶端庫,與參與者資料庫或訊息服務通訊。驅動支援 XA,意味著它會呼叫 XA API,判斷某項操作是否屬於分散式事務;若是,就把必要資訊傳送給資料庫伺服器。驅動還會暴露回撥介面,供協調者要求參與者準備、提交或中止。
事務協調者負責實現 XA API。標準沒有規定具體實現方式;實踐中,協調者通常只是一個載入到事務發起應用同一程序中的庫,而不是獨立服務。它跟蹤事務中的所有參與者,要求它們準備後透過驅動回撥收集響應,並用本地磁碟日誌記錄每個事務的提交或中止決定。
如果應用程序崩潰,或執行應用的機器宕機,協調者也會隨之消失。所有持有已準備但尚未提交事務的參與者,都會陷入存疑狀態。協調者日誌位於應用伺服器的本地磁碟上,因此必須重啟這臺伺服器,再由協調者庫讀取日誌,恢復各事務的提交或中止結果。之後,協調者才能透過資料庫驅動的 XA 回撥,要求參與者提交或中止。資料庫伺服器無法直接聯絡協調者,因為所有通訊都必須經過客戶端庫。
存疑時持有鎖
為什麼要如此在意存疑事務?難道系統其他部分不能照常工作,暫且忽略這些最終總會清理掉的事務嗎?
問題出在鎖上。正如“讀已提交”所述,資料庫事務通常會對修改的每一行取得行級獨佔鎖,以防止髒寫。如果還要求可序列化隔離,那麼採用兩階段鎖定的資料庫也必須為事務讀取的每一行取得共享鎖。
事務提交或中止之前,資料庫不能釋放這些鎖(如圖 8-13中的陰影部分所示)。所以使用兩階段提交時,事務必須在整個存疑期間持鎖。協調者若崩潰後要花 20 分鐘重啟,鎖就會持有 20 分鐘;協調者日誌若因故徹底丟失,鎖甚至會永遠保留——至少要一直等到管理員手動解決問題。
持鎖期間,其他事務無法修改這些行;視隔離級別而定,甚至連讀取也可能被阻塞。因此,其他事務無法若無其事地繼續執行——只要訪問同一份資料,就會卡住。應用程式的大部分功能都可能因此不可用,直至存疑事務得到解決。
從協調者失效中恢復
理論上,協調者崩潰重啟後,應該能從日誌完整恢復狀態,並解決所有存疑事務。但實踐中確實會出現孤立的存疑事務83 84:協調者由於某種原因無法確定事務結果,例如軟體缺陷導致事務日誌丟失或損壞。這些事務無法自動解決,只能一直留在資料庫中,持有鎖並阻塞其他事務。
重啟資料庫伺服器也解決不了問題。正確的 2PC 實現必須跨重啟保留存疑事務的鎖,否則就有破壞原子性保證的風險。這種局面十分棘手。
唯一的出路,是由管理員手動決定提交還是回滾。管理員必須檢查每個存疑事務的所有參與者,確定是否已有參與者提交或中止,再把同樣的結果應用到其餘參與者。這項工作可能耗費大量人力,而且往往要在嚴重生產中斷期間,承受巨大的精神與時間壓力來完成——否則協調者也不會陷入如此糟糕的狀態。
許多 XA 實現都留有一個稱為啟發式決策的緊急出口:即使協調者沒有給出明確決定,也允許參與者單方面中止或提交存疑事務73。需要直說的是,這裡的“啟發式”只是“很可能破壞原子性”的委婉說法,因為它違背了兩階段提交的承諾體系。因此,啟發式決策只用於擺脫災難性局面,絕不能作為常規手段。
XA 事務的問題
單節點協調者是整個系統的單點故障。把協調者放進應用伺服器同樣有問題,因為協調者本地磁碟上的日誌會成為持久系統狀態的關鍵部分,其重要性不亞於資料庫本身。
原則上,XA 事務的協調者可以像其他重要資料庫一樣實現高可用和複製。遺憾的是,這仍解決不了 XA 的一個根本問題:它沒有提供協調者與事務參與者直接通訊的辦法。二者只能透過發起事務的應用程式碼,以及應用用來呼叫參與者的資料庫驅動進行通訊。
因此,即使複製了協調者,應用程式碼依然是單點故障。要解決這個問題,必須徹底重構應用程式碼的執行方式,使其可複製或可重啟,形式上可能類似持久化執行(參見“持久化執行與工作流”)。但實踐中似乎沒有工具真正採用這種方案。
另一個問題是,XA 必須相容形形色色的資料系統,因而只能取它們的最低公分母。例如,它無法檢測橫跨不同系統的死鎖,因為這需要一套標準協議,讓系統交換每個事務正在等待哪些鎖;它也無法配合 SSI 使用(參見“可序列化快照隔離(SSI)”),因為 SSI 需要一套跨系統識別衝突的協議。
這些問題在一定程度上是跨異構技術執行事務的固有困難。然而,讓多個異構資料系統彼此保持一致仍是一個真實而重要的需求,所以必須另尋解決方案。辦法的確存在,下一節和“衍生資料與分散式事務”將作介紹。
資料庫內部的分散式事務
如前所述,橫跨多種異構儲存技術的分散式事務,與系統內部的分散式事務大不相同。在後者中,所有參與節點都是同一資料庫的分片,執行相同軟體。內部的分散式事務是 CockroachDB5、TiDB6、Spanner7、FoundationDB8 和 YugabyteDB 等“NewSQL”資料庫的標誌性特徵。Kafka 等訊息代理也支援內部的分散式事務85。
這些系統中有許多使用兩階段提交,來保證寫入多個分片的事務具備原子性,卻不會遇到 XA 事務的那些問題。因為其分散式事務無須對接其他技術,所以能夠避開最低公分母陷阱;系統設計者可以自由採用更可靠、更快速的協議。
XA 最嚴重的幾個問題可以這樣解決:
- 複製協調者;主協調者崩潰時,自動故障切換到另一個協調者節點;
- 允許協調者和資料分片直接通訊,不再經過應用程式碼;
- 複製參與事務的分片,降低因某個分片故障而不得不中止事務的風險;
- 將原子提交協議與分散式併發控制協議結合起來,由後者支援跨分片死鎖檢測和一致讀取。
協調者和資料庫分片通常採用共識演算法複製。第 10 章將介紹怎樣用共識演算法實現分散式事務的原子提交。這些演算法無需人工干預,就能自動從發生故障的節點切換到另一個節點,在容忍故障的同時繼續保證強一致性屬性。
分散式事務提供哪種隔離級別,取決於具體系統;但跨分片實現快照隔離和可序列化快照隔離都是可行的。其工作原理詳見本章末尾引用的論文。
再談恰好一次訊息處理
“恰好一次訊息處理”介紹過,分散式事務的一個重要用途,是保證某項操作恰好生效一次,即使處理期間發生崩潰、不得不重試也不例外。如果能跨訊息代理和資料庫原子提交事務,就可以做到:當且僅當訊息成功處理、處理產生的資料庫寫入也成功提交時,才向代理確認訊息。
不過,要實現恰好一次語義,其實並不需要這樣的分散式事務。下面的替代方案只要求資料庫本身支援事務:
- 假設每條訊息都有唯一 ID,資料庫中另有一張表,記錄已經處理過的訊息 ID。從代理取得訊息並開始處理時,先在資料庫中啟動新事務,再檢查訊息 ID。如果資料庫中已有相同 ID,就知道該訊息已經處理過,可以向代理確認並丟棄這條訊息。
- 如果資料庫中尚無該訊息 ID,就將其加入表中,然後處理訊息。處理過程可能在同一個事務中對資料庫執行更多寫入。訊息處理完成後,提交資料庫事務。
- 資料庫事務成功提交後,便可向代理確認訊息。
- 訊息成功向代理確認後,就知道代理不會再次嘗試處理同一條訊息,因此可以另開一個事務,從資料庫刪除該訊息 ID。
如果訊息處理器在提交資料庫事務前崩潰,事務會中止,訊息代理隨後重試。如果它在提交後、向代理確認前崩潰,代理同樣會重試;但重試時能在資料庫中看到訊息 ID,於是直接丟棄該訊息。如果它在確認後、從資料庫刪除訊息 ID 前崩潰,資料庫只會殘留一條舊訊息 ID,除了佔用少量空間,不會造成其他危害。重試也可能發生在原資料庫事務中止之前——例如訊息處理器與資料庫之間的通訊中斷——此時訊息 ID 表上的唯一性約束應能防止兩個併發事務插入相同 ID。
因此,實現恰好一次處理只需要資料庫內部的事務;這個用例並不要求資料庫與訊息代理之間具備原子性。把訊息 ID 記入資料庫,可以讓訊息處理具備冪等性,因而能夠安全重試而不重複產生副作用。Kafka Streams 等流處理框架也用類似辦法實現恰好一次語義,詳見“容錯”。
不過,資料庫內部的分散式事務仍有助於擴充套件這類模式。例如,訊息 ID 可以存放在一個分片上,訊息處理所更新的業務資料放在其他分片上,再由內部事務保證跨分片提交的原子性。
總結
事務是一層抽象,讓應用程式可以假裝某些併發問題,以及某些軟硬體故障並不存在。種類繁多的錯誤都被簡化成一次事務中止,應用程式只需重試即可。
本章考察了許多事務有助於防範的問題。並非所有應用都會遇到其中每一種問題:訪問模式非常簡單的應用(例如只讀寫單條記錄),沒有事務或許也能應付。但面對更複雜的訪問模式,事務可以大幅減少需要考慮的潛在錯誤場景。
沒有事務,程序崩潰、網路中斷、斷電、磁碟空間耗盡、意外併發等各種錯誤場景,都可能以不同方式造成資料不一致。例如,反正規化資料很容易與源資料失去同步。缺少事務時,複雜而相互影響的訪問究竟會給資料庫帶來什麼後果,很難推理。
本章尤其深入地探討了併發控制。我們介紹了幾種廣泛使用的隔離級別,特別是讀已提交、快照隔離(有時稱為可重複讀)和可序列化,並透過各種競態條件來刻畫它們。表 8-1彙總了這些內容:
表 8-1. 各種隔離級別下可能發生的異常彙總。
| 隔離級別 | 髒讀 | 讀偏差 | 幻讀 | 丟失更新 | 寫偏差 |
|---|---|---|---|---|---|
| 讀未提交 | ✗ 可能 | ✗ 可能 | ✗ 可能 | ✗ 可能 | ✗ 可能 |
| 讀已提交 | ✓ 防止 | ✗ 可能 | ✗ 可能 | ✗ 可能 | ✗ 可能 |
| 快照隔離 | ✓ 防止 | ✓ 防止 | ✓ 防止 | ? 視情況 | ✗ 可能 |
| 可序列化 | ✓ 防止 | ✓ 防止 | ✓ 防止 | ✓ 防止 | ✓ 防止 |
- 髒讀
- 一個客戶端在另一個客戶端的寫入提交前就讀到了這些資料。讀已提交及更強的隔離級別可以防止髒讀。
- 髒寫
- 一個客戶端覆蓋另一個客戶端已經寫入、但尚未提交的資料。幾乎所有事務實現都會防止髒寫。
- 讀偏差
- 客戶端在不同時間點觀察資料庫的不同部分。有些讀偏差也稱為不可重複讀。防止這個問題最常用的辦法是快照隔離,它允許事務讀取與某個特定時刻對應的一致快照,通常以多版本併發控制(MVCC)實現。
- 丟失更新
- 兩個客戶端併發執行讀取—修改—寫入迴圈,其中一個覆蓋另一個的寫入,卻沒有合併對方的修改,因而造成資料丟失。有些快照隔離實現會自動防止這種異常,另一些則需要手動加鎖(
SELECT FOR UPDATE)。 - 寫偏差
- 事務讀取資料,根據看到的值作出決定,再把決定寫入資料庫。但真正寫入時,作出決定所依據的前提已經不再成立。只有可序列化隔離能夠防止這種異常。
- 幻讀
- 事務讀取符合某項搜尋條件的物件,另一個客戶端隨後寫入,改變了搜尋結果。快照隔離可以防止直接的幻讀,但寫偏差場景中的幻讀需要特殊處理,例如使用索引範圍鎖。
弱隔離級別能防止其中一部分異常,卻把其他異常留給應用開發者手動處理(例如顯式加鎖)。只有可序列化隔離能防範所有這些問題。我們討論了三種實現可序列化事務的辦法:
- 嚴格按序列順序執行事務
- 如果每個事務都能極快完成(通常藉助儲存過程),而且事務吞吐量足夠低,能由單個 CPU 核處理,或者事務能夠分片執行,那麼這是一種簡單有效的選擇。
- 兩階段鎖定
- 幾十年來,這一直是實現可序列化的標準方法,但由於效能不佳,許多應用會避開它。
- 可序列化快照隔離(SSI)
- 一種相對較新的演算法,避開了前兩種方案的大部分缺點。它採用樂觀方式,允許事務不受阻塞地繼續執行;事務準備提交時再接受檢查,若執行結果不可序列化,就會中止。
最後,我們研究了如何用兩階段提交,為跨多個節點的事務實現原子性。如果所有節點都執行相同的資料庫軟體,分散式事務往往能良好運轉;但一旦橫跨不同儲存技術(使用 XA 事務),2PC 就會問題重重:它對協調者和驅動事務的應用程式碼中的故障非常敏感,與併發控制機制也難以配合。所幸,冪等性可以在不要求跨異構儲存原子提交的情況下,保證恰好一次語義。後續章節還會進一步討論這個主題。
本章的示例採用關係資料模型。不過,正如“多物件事務的需求”所說,無論採用哪一種資料模型,事務都是一項很有價值的資料庫功能。
參考文獻
Steven J. Murdoch. What went wrong with Horizon: learning from the Post Office Trial. benthamsgaze.org, July 2021. Archived at perma.cc/CNM4-553F ↩︎
Donald D. Chamberlin, Morton M. Astrahan, Michael W. Blasgen, James N. Gray, W. Frank King, Bruce G. Lindsay, Raymond Lorie, James W. Mehl, Thomas G. Price, Franco Putzolu, Patricia Griffiths Selinger, Mario Schkolnick, Donald R. Slutz, Irving L. Traiger, Bradford W. Wade, and Robert A. Yost. A History and Evaluation of System R. Communications of the ACM, volume 24, issue 10, pages 632–646, October 1981. doi:10.1145/358769.358784 ↩︎
Jim N. Gray, Raymond A. Lorie, Gianfranco R. Putzolu, and Irving L. Traiger. Granularity of Locks and Degrees of Consistency in a Shared Data Base. in Modelling in Data Base Management Systems: Proceedings of the IFIP Working Conference on Modelling in Data Base Management Systems, edited by G. M. Nijssen, pages 364–394, Elsevier/North Holland Publishing, 1976. Also in Readings in Database Systems, 4th edition, edited by Joseph M. Hellerstein and Michael Stonebraker, MIT Press, 2005. ISBN: 978-0-262-69314-1 ↩︎ ↩︎ ↩︎ ↩︎
Kapali P. Eswaran, Jim N. Gray, Raymond A. Lorie, and Irving L. Traiger. The Notions of Consistency and Predicate Locks in a Database System. Communications of the ACM, volume 19, issue 11, pages 624–633, November 1976. doi:10.1145/360363.360369 ↩︎ ↩︎ ↩︎
Rebecca Taft, Irfan Sharif, Andrei Matei, Nathan VanBenschoten, Jordan Lewis, Tobias Grieger, Kai Niemi, Andy Woods, Anne Birzin, Raphael Poss, Paul Bardea, Amruta Ranade, Ben Darnell, Bram Gruneir, Justin Jaffray, Lucy Zhang, and Peter Mattis. CockroachDB: The Resilient Geo-Distributed SQL Database. At ACM SIGMOD International Conference on Management of Data (SIGMOD), pages 1493–1509, June 2020. doi:10.1145/3318464.3386134 ↩︎ ↩︎ ↩︎
Dongxu Huang, Qi Liu, Qiu Cui, Zhuhe Fang, Xiaoyu Ma, Fei Xu, Li Shen, Liu Tang, Yuxing Zhou, Menglong Huang, Wan Wei, Cong Liu, Jian Zhang, Jianjun Li, Xuelian Wu, Lingyu Song, Ruoxi Sun, Shuaipeng Yu, Lei Zhao, Nicholas Cameron, Liquan Pei, and Xin Tang. TiDB: a Raft-based HTAP database. Proceedings of the VLDB Endowment, volume 13, issue 12, pages 3072–3084. doi:10.14778/3415478.3415535 ↩︎ ↩︎
James C. Corbett, Jeffrey Dean, Michael Epstein, Andrew Fikes, Christopher Frost, JJ Furman, Sanjay Ghemawat, Andrey Gubarev, Christopher Heiser, Peter Hochschild, Wilson Hsieh, Sebastian Kanthak, Eugene Kogan, Hongyi Li, Alexander Lloyd, Sergey Melnik, David Mwaura, David Nagle, Sean Quinlan, Rajesh Rao, Lindsay Rolig, Dale Woodford, Yasushi Saito, Christopher Taylor, Michal Szymaniak, and Ruth Wang. Spanner: Google’s Globally-Distributed Database. At 10th USENIX Symposium on Operating System Design and Implementation (OSDI), October 2012. ↩︎ ↩︎
Jingyu Zhou, Meng Xu, Alexander Shraer, Bala Namasivayam, Alex Miller, Evan Tschannen, Steve Atherton, Andrew J. Beamon, Rusty Sears, John Leach, Dave Rosenthal, Xin Dong, Will Wilson, Ben Collins, David Scherer, Alec Grieser, Young Liu, Alvin Moore, Bhaskar Muppana, Xiaoge Su, and Vishesh Yadav. FoundationDB: A Distributed Unbundled Transactional Key Value Store. At ACM International Conference on Management of Data (SIGMOD), June 2021. doi:10.1145/3448016.3457559 ↩︎ ↩︎ ↩︎ ↩︎
Theo Härder and Andreas Reuter. Principles of Transaction-Oriented Database Recovery. ACM Computing Surveys, volume 15, issue 4, pages 287–317, December 1983. doi:10.1145/289.291 ↩︎
Peter Bailis, Alan Fekete, Ali Ghodsi, Joseph M. Hellerstein, and Ion Stoica. HAT, not CAP: Towards Highly Available Transactions. At 14th USENIX Workshop on Hot Topics in Operating Systems (HotOS), May 2013. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Armando Fox, Steven D. Gribble, Yatin Chawathe, Eric A. Brewer, and Paul Gauthier. Cluster-Based Scalable Network Services. At 16th ACM Symposium on Operating Systems Principles (SOSP), October 1997. doi:10.1145/268998.266662 ↩︎
Tony Andrews. Enforcing Complex Constraints in Oracle. tonyandrews.blogspot.co.uk, October 2004. Archived at archive.org ↩︎ ↩︎
Philip A. Bernstein, Vassos Hadzilacos, and Nathan Goodman. Concurrency Control and Recovery in Database Systems. Addison-Wesley, 1987. ISBN: 978-0-201-10715-9, available online at microsoft.com. ↩︎ ↩︎ ↩︎
Alan Fekete, Dimitrios Liarokapis, Elizabeth O’Neil, Patrick O’Neil, and Dennis Shasha. Making Snapshot Isolation Serializable. ACM Transactions on Database Systems, volume 30, issue 2, pages 492–528, June 2005. doi:10.1145/1071610.1071615 ↩︎ ↩︎ ↩︎
Mai Zheng, Joseph Tucek, Feng Qin, and Mark Lillibridge. Understanding the Robustness of SSDs Under Power Fault. At 11th USENIX Conference on File and Storage Technologies (FAST), February 2013. ↩︎
Laurie Denness. SSDs: A Gift and a Curse. laur.ie, June 2015. Archived at perma.cc/6GLP-BX3T ↩︎
Adam Surak. When Solid State Drives Are Not That Solid. blog.algolia.com, June 2015. Archived at perma.cc/CBR9-QZEE ↩︎
Hewlett Packard Enterprise. Bulletin: (Revision) HPE SAS Solid State Drives - Critical Firmware Upgrade Required for Certain HPE SAS Solid State Drive Models to Prevent Drive Failure at 32,768 Hours of Operation. support.hpe.com, November 2019. Archived at perma.cc/CZR4-AQBS ↩︎
Craig Ringer et al. PostgreSQL’s handling of fsync() errors is unsafe and risks data loss at least on XFS. Email thread on pgsql-hackers mailing list, postgresql.org, March 2018. Archived at perma.cc/5RKU-57FL ↩︎
Anthony Rebello, Yuvraj Patel, Ramnatthan Alagappan, Andrea C. Arpaci-Dusseau, and Remzi H. Arpaci-Dusseau. Can Applications Recover from fsync Failures? At USENIX Annual Technical Conference (ATC), July 2020. ↩︎
Thanumalayan Sankaranarayana Pillai, Vijay Chidambaram, Ramnatthan Alagappan, Samer Al-Kiswany, Andrea C. Arpaci-Dusseau, and Remzi H. Arpaci-Dusseau. Crash Consistency: Rethinking the Fundamental Abstractions of the File System. ACM Queue, volume 13, issue 7, pages 20–28, July 2015. doi:10.1145/2800695.2801719 ↩︎
Thanumalayan Sankaranarayana Pillai, Vijay Chidambaram, Ramnatthan Alagappan, Samer Al-Kiswany, Andrea C. Arpaci-Dusseau, and Remzi H. Arpaci-Dusseau. All File Systems Are Not Created Equal: On the Complexity of Crafting Crash-Consistent Applications. At 11th USENIX Symposium on Operating Systems Design and Implementation (OSDI), October 2014. ↩︎ ↩︎
Chris Siebenmann. Unix’s File Durability Problem. utcc.utoronto.ca, April 2016. Archived at perma.cc/VSS8-5MC4 ↩︎
Aishwarya Ganesan, Ramnatthan Alagappan, Andrea C. Arpaci-Dusseau, and Remzi H. Arpaci-Dusseau. Redundancy Does Not Imply Fault Tolerance: Analysis of Distributed Storage Reactions to Single Errors and Corruptions. At 15th USENIX Conference on File and Storage Technologies (FAST), February 2017. ↩︎
Lakshmi N. Bairavasundaram, Garth R. Goodson, Bianca Schroeder, Andrea C. Arpaci-Dusseau, and Remzi H. Arpaci-Dusseau. An Analysis of Data Corruption in the Storage Stack. At 6th USENIX Conference on File and Storage Technologies (FAST), February 2008. ↩︎
Bianca Schroeder, Raghav Lagisetty, and Arif Merchant. Flash Reliability in Production: The Expected and the Unexpected. At 14th USENIX Conference on File and Storage Technologies (FAST), February 2016. ↩︎
Don Allison. SSD Storage – Ignorance of Technology Is No Excuse. blog.korelogic.com, March 2015. Archived at perma.cc/9QN4-9SNJ ↩︎
Gordon Mah Ung. Debunked: Your SSD won’t lose data if left unplugged after all. pcworld.com, May 2015. Archived at perma.cc/S46H-JUDU ↩︎
Martin Kleppmann. Hermitage: Testing the ‘I’ in ACID. martin.kleppmann.com, November 2014. Archived at perma.cc/KP2Y-AQGK ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Todd Warszawski and Peter Bailis. ACIDRain: Concurrency-Related Attacks on Database-Backed Web Applications. At ACM International Conference on Management of Data (SIGMOD), May 2017. doi:10.1145/3035918.3064037 ↩︎ ↩︎
Tristan D’Agosta. BTC Stolen from Poloniex. bitcointalk.org, March 2014. Archived at perma.cc/YHA6-4C5D ↩︎
bitcointhief2. How I Stole Roughly 100 BTC from an Exchange and How I Could Have Stolen More! reddit.com, February 2014. Archived at archive.org ↩︎
Sudhir Jorwekar, Alan Fekete, Krithi Ramamritham, and S. Sudarshan. Automating the Detection of Snapshot Isolation Anomalies. At 33rd International Conference on Very Large Data Bases (VLDB), September 2007. ↩︎ ↩︎
Michael Melanson. Transactions: The Limits of Isolation. michaelmelanson.net, November 2014. Archived at perma.cc/RG5R-KMYZ ↩︎
Edward Kim. How ACH works: A developer perspective — Part 1. engineering.gusto.com, April 2014. Archived at perma.cc/7B2H-PU94 ↩︎
Hal Berenson, Philip A. Bernstein, Jim N. Gray, Jim Melton, Elizabeth O’Neil, and Patrick O’Neil. A Critique of ANSI SQL Isolation Levels. At ACM International Conference on Management of Data (SIGMOD), May 1995. doi:10.1145/568271.223785 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Atul Adya. Weak Consistency: A Generalized Theory and Optimistic Implementations for Distributed Transactions. PhD Thesis, Massachusetts Institute of Technology, March 1999. Archived at perma.cc/E97M-HW5Q ↩︎ ↩︎
Peter Bailis, Aaron Davidson, Alan Fekete, Ali Ghodsi, Joseph M. Hellerstein, and Ion Stoica. Highly Available Transactions: Virtues and Limitations. At 40th International Conference on Very Large Data Bases (VLDB), September 2014. ↩︎ ↩︎ ↩︎
Natacha Crooks, Youer Pu, Lorenzo Alvisi, and Allen Clement. Seeing is Believing: A Client-Centric Specification of Database Isolation. At ACM Symposium on Principles of Distributed Computing (PODC), pages 73–82, July 2017. doi:10.1145/3087801.3087802 ↩︎
Bruce Momjian. MVCC Unmasked. momjian.us, July 2014. Archived at perma.cc/KQ47-9GYB ↩︎ ↩︎ ↩︎
Peter Alvaro and Kyle Kingsbury. MySQL 8.0.34. jepsen.io, December 2023. Archived at perma.cc/HGE2-Z878 ↩︎ ↩︎ ↩︎
Egor Rogov. PostgreSQL 14 Internals. postgrespro.com, April 2023. Archived at perma.cc/FRK2-D7WB ↩︎
Hironobu Suzuki. The Internals of PostgreSQL. interdb.jp, 2017. ↩︎ ↩︎
Rohan Reddy Alleti. Internals of MVCC in Postgres: Hidden costs of Updates vs Inserts. medium.com, March 2025. Archived at perma.cc/3ACX-DFXT ↩︎
Andy Pavlo and Bohan Zhang. The Part of PostgreSQL We Hate the Most. cs.cmu.edu, April 2023. Archived at perma.cc/XSP6-3JBN ↩︎ ↩︎
Yingjun Wu, Joy Arulraj, Jiexi Lin, Ran Xian, and Andrew Pavlo. An empirical evaluation of in-memory multi-version concurrency control. Proceedings of the VLDB Endowment, volume 10, issue 7, pages 781–792, March 2017. doi:10.14778/3067421.3067427 ↩︎ ↩︎
Nikita Prokopov. Unofficial Guide to Datomic Internals. tonsky.me, May 2014. ↩︎
Daniil Svetlov. A Practical Guide to Taming Postgres Isolation Anomalies. dansvetlov.me, March 2025. Archived at perma.cc/L7LE-TDLS ↩︎
Nate Wiger. An Atomic Rant. nateware.com, February 2010. Archived at perma.cc/5ZYB-PE44 ↩︎
James Coglan. Reading and writing, part 3: web applications. blog.jcoglan.com, October 2020. Archived at perma.cc/A7EK-PJVS ↩︎ ↩︎
Peter Bailis, Alan Fekete, Michael J. Franklin, Ali Ghodsi, Joseph M. Hellerstein, and Ion Stoica. Feral Concurrency Control: An Empirical Investigation of Modern Application Integrity. At ACM International Conference on Management of Data (SIGMOD), June 2015. doi:10.1145/2723372.2737784 ↩︎ ↩︎
Jaana Dogan. Things I Wished More Developers Knew About Databases. rakyll.medium.com, April 2020. Archived at perma.cc/6EFK-P2TD ↩︎
Michael J. Cahill, Uwe Röhm, and Alan Fekete. Serializable Isolation for Snapshot Databases. At ACM International Conference on Management of Data (SIGMOD), June 2008. doi:10.1145/1376616.1376690 ↩︎ ↩︎
Dan R. K. Ports and Kevin Grittner. Serializable Snapshot Isolation in PostgreSQL. At 38th International Conference on Very Large Databases (VLDB), August 2012. ↩︎ ↩︎ ↩︎ ↩︎
Douglas B. Terry, Marvin M. Theimer, Karin Petersen, Alan J. Demers, Mike J. Spreitzer and Carl H. Hauser. Managing Update Conflicts in Bayou, a Weakly Connected Replicated Storage System. At 15th ACM Symposium on Operating Systems Principles (SOSP), December 1995. doi:10.1145/224056.224070 ↩︎
Hans-Jürgen Schönig. Constraints over multiple rows in PostgreSQL. cybertec-postgresql.com, June 2021. Archived at perma.cc/2TGH-XUPZ ↩︎
Michael Stonebraker, Samuel Madden, Daniel J. Abadi, Stavros Harizopoulos, Nabil Hachem, and Pat Helland. The End of an Architectural Era (It’s Time for a Complete Rewrite). At 33rd International Conference on Very Large Data Bases (VLDB), September 2007. ↩︎
John Hugg. H-Store/VoltDB Architecture vs. CEP Systems and Newer Streaming Architectures. At Data @Scale Boston, November 2014. ↩︎
Robert Kallman, Hideaki Kimura, Jonathan Natkins, Andrew Pavlo, Alexander Rasin, Stanley Zdonik, Evan P. C. Jones, Samuel Madden, Michael Stonebraker, Yang Zhang, John Hugg, and Daniel J. Abadi. H-Store: A High-Performance, Distributed Main Memory Transaction Processing System. Proceedings of the VLDB Endowment, volume 1, issue 2, pages 1496–1499, August 2008. ↩︎ ↩︎
Rich Hickey. The Architecture of Datomic. infoq.com, November 2012. Archived at perma.cc/5YWU-8XJK ↩︎
John Hugg. Debunking Myths About the VoltDB In-Memory Database. dzone.com, May 2014. Archived at perma.cc/2Z9N-HPKF ↩︎ ↩︎
Xinjing Zhou, Viktor Leis, Xiangyao Yu, and Michael Stonebraker. OLTP Through the Looking Glass 16 Years Later: Communication is the New Bottleneck. At 15th Annual Conference on Innovative Data Systems Research (CIDR), January 2025. ↩︎
Xinjing Zhou, Xiangyao Yu, Goetz Graefe, and Michael Stonebraker. Lotus: scalable multi-partition transactions on single-threaded partitioned databases. Proceedings of the VLDB Endowment (PVLDB), volume 15, issue 11, pages 2939–2952, July 2022. doi:10.14778/3551793.3551843 ↩︎
Joseph M. Hellerstein, Michael Stonebraker, and James Hamilton. Architecture of a Database System. Foundations and Trends in Databases, volume 1, issue 2, pages 141–259, November 2007. doi:10.1561/1900000002 ↩︎
Michael J. Cahill. Serializable Isolation for Snapshot Databases. PhD Thesis, University of Sydney, July 2009. Archived at perma.cc/727J-NTMP ↩︎
Cristian Diaconu, Craig Freedman, Erik Ismert, Per-Åke Larson, Pravin Mittal, Ryan Stonecipher, Nitin Verma, and Mike Zwilling. Hekaton: SQL Server’s Memory-Optimized OLTP Engine. At ACM SIGMOD International Conference on Management of Data (SIGMOD), pages 1243–1254, June 2013. doi:10.1145/2463676.2463710 ↩︎
Thomas Neumann, Tobias Mühlbauer, and Alfons Kemper. Fast Serializable Multi-Version Concurrency Control for Main-Memory Database Systems. At ACM SIGMOD International Conference on Management of Data (SIGMOD), pages 677–689, May 2015. doi:10.1145/2723372.2749436 ↩︎ ↩︎
D. Z. Badal. Correctness of Concurrency Control and Implications in Distributed Databases. At 3rd International IEEE Computer Software and Applications Conference (COMPSAC), November 1979. doi:10.1109/CMPSAC.1979.762563 ↩︎
Rakesh Agrawal, Michael J. Carey, and Miron Livny. Concurrency Control Performance Modeling: Alternatives and Implications. ACM Transactions on Database Systems (TODS), volume 12, issue 4, pages 609–654, December 1987. doi:10.1145/32204.32220 ↩︎
Marc Brooker. Snapshot Isolation vs Serializability. brooker.co.za, December 2024. Archived at perma.cc/5TRC-CR5G ↩︎
B. G. Lindsay, P. G. Selinger, C. Galtieri, J. N. Gray, R. A. Lorie, T. G. Price, F. Putzolu, I. L. Traiger, and B. W. Wade. Notes on Distributed Databases. IBM Research, Research Report RJ2571(33471), July 1979. Archived at perma.cc/EPZ3-MHDD ↩︎
C. Mohan, Bruce G. Lindsay, and Ron Obermarck. Transaction Management in the R* Distributed Database Management System. ACM Transactions on Database Systems, volume 11, issue 4, pages 378–396, December 1986. doi:10.1145/7239.7266 ↩︎
X/Open Company Ltd. Distributed Transaction Processing: The XA Specification. Technical Standard XO/CAE/91/300, December 1991. ISBN: 978-1-872-63024-3, archived at perma.cc/Z96H-29JB ↩︎ ↩︎ ↩︎
Ivan Silva Neto and Francisco Reverbel. Lessons Learned from Implementing WS-Coordination and WS-AtomicTransaction. At 7th IEEE/ACIS International Conference on Computer and Information Science (ICIS), May 2008. doi:10.1109/ICIS.2008.75 ↩︎
James E. Johnson, David E. Langworthy, Leslie Lamport, and Friedrich H. Vogt. Formal Specification of a Web Services Protocol. At 1st International Workshop on Web Services and Formal Methods (WS-FM), February 2004. doi:10.1016/j.entcs.2004.02.022 ↩︎
Jim Gray. The Transaction Concept: Virtues and Limitations. At 7th International Conference on Very Large Data Bases (VLDB), September 1981. ↩︎
Dale Skeen. Nonblocking Commit Protocols. At ACM International Conference on Management of Data (SIGMOD), April 1981. doi:10.1145/582318.582339 ↩︎
Gregor Hohpe. Your Coffee Shop Doesn’t Use Two-Phase Commit. IEEE Software, volume 22, issue 2, pages 64–66, March 2005. doi:10.1109/MS.2005.52 ↩︎
Pat Helland. Life Beyond Distributed Transactions: An Apostate’s Opinion. At 3rd Biennial Conference on Innovative Data Systems Research (CIDR), January 2007. ↩︎
Jonathan Oliver. My Beef with MSDTC and Two-Phase Commits. blog.jonathanoliver.com, April 2011. Archived at perma.cc/K8HF-Z4EN ↩︎
Oren Eini (Ahende Rahien). The Fallacy of Distributed Transactions. ayende.com, July 2014. Archived at perma.cc/VB87-2JEF ↩︎
Clemens Vasters. Transactions in Windows Azure (with Service Bus) – An Email Discussion. learn.microsoft.com, July 2012. Archived at perma.cc/4EZ9-5SKW ↩︎
Ajmer Dhariwal. Orphaned MSDTC Transactions (-2 spids). eraofdata.com, December 2008. Archived at perma.cc/YG6F-U34C ↩︎
Paul Randal. Real World Story of DBCC PAGE Saving the Day. sqlskills.com, June 2013. Archived at perma.cc/2MJN-A5QH ↩︎
Guozhang Wang, Lei Chen, Ayusman Dikshit, Jason Gustafson, Boyang Chen, Matthias J. Sax, John Roesler, Sophie Blee-Goldman, Bruno Cadonna, Apurva Mehta, Varun Madan, and Jun Rao. Consistency and Completeness: Rethinking Distributed Stream Processing in Apache Kafka. At ACM International Conference on Management of Data (SIGMOD), June 2021. doi:10.1145/3448016.3457556 ↩︎