本章摘要: 從 Read Committed 到 Snapshot Isolation 再到 Serializability,逐一分析各隔離等級能防止哪些並行問題,並比較實際序列執行、兩階段鎖定與可序列化快照隔離(SSI)三種可序列化實作的效能與適用場景。

在現實世界的資料系統中,各種錯誤隨時可能發生:硬體故障、應用程式崩潰、網路中斷、並行寫入衝突、部分更新導致的不一致狀態等。事務(transaction)是數十年來簡化這些問題的核心機制——它將多個讀寫操作包裝成一個邏輯單元,要嘛全部成功提交,要嘛全部中止回復,不存在中間狀態。

本章深入探討事務的語意與隔離等級,釐清哪些並行問題能被自動防止、哪些仍需開發者手動處理。

ACID 的真正含義#

ACID 是事務安全性保證的縮寫,由 Theo Harder 和 Andreas Reuter 於 1983 年提出。然而在實務中,不同資料庫對 ACID 的實作差異相當大,ACID 更像是一個行銷術語而非精確的技術規格。與之相對的是 BASE(Basically Available, Soft state, Eventually consistent),這個縮寫比 ACID 更加模糊,基本上只表示「不是 ACID」。

Atomicity(原子性)#

原子性的核心並非指「並行操作的不可分割」(那是 isolation 的範疇),而是描述在故障發生時的行為:如果一個事務在執行多個寫入的過程中發生錯誤,已完成的寫入必須全部回復。應用程式可以安全地重試整個事務,不必擔心只完成了一半。

作者認為 abortability(可中止性)或許是更貼切的用語。

Figure 7-3: 原子性確保錯誤發生時回復先前的寫入

Consistency(一致性)#

ACID 中的 consistency 是指應用程式層面的不變式(invariants)必須始終成立,例如「帳戶借貸兩端必須平衡」。但這本質上是應用程式的責任,資料庫無法保證任意的業務邏輯正確性。嚴格來說,C 並不真正屬於資料庫提供的保證,只是為了湊齊 ACID 這個縮寫而放進去的。

Isolation(隔離性)#

隔離性確保並行執行的事務彼此互不干擾,結果等同於它們依序執行。教科書通常將此定義為 serializability(可序列化),但實務上因為效能代價太高,多數資料庫使用的是較弱的隔離等級。

Figure 7-2: 違反隔離性:一個事務讀取另一個未提交事務的資料

Durability(持久性)#

持久性保證一旦事務成功提交,其資料不會因硬體故障或資料庫崩潰而遺失。在單節點資料庫中,這通常意味著資料已寫入非揮發性儲存裝置(如硬碟或 SSD)並搭配 write-ahead log 以供崩潰恢復。在帶有複製的資料庫中,持久性則可能意味著資料已成功複製到一定數量的節點。

但在實務中,絕對的持久性並不存在——當所有硬碟同時損毀、所有備份同時失效時,沒有任何技術能拯救你的資料。甚至有研究指出 SSD 在斷電時可能違反其保證。

ACID 這個術語在不同資料庫中的具體含義可能截然不同。聲稱「符合 ACID」不代表任何精確的保證水準,開發者需要仔細閱讀各家資料庫的實際文件。

單物件與多物件操作#

多物件事務的必要性#

許多場景需要在同一個事務中協調多個物件的寫入:

  • 在關聯式資料庫中,一個資料表的外鍵參照必須與另一個資料表保持一致
  • 在文件式資料庫中,需要同時更新的相關欄位可能散落在不同文件中
  • 具有次級索引的資料庫中,每次寫入值時索引也必須同步更新

如果這些寫入只完成了一半,資料就會處於不一致的狀態。多物件事務確保這些操作以原子方式執行。

單物件寫入#

即使是單一物件的寫入也可能在中途失敗(例如寫到一半時網路斷線)。因此幾乎所有儲存引擎都提供了單物件層級的原子性和隔離性,例如透過 compare-and-set 操作來確保寫入不會覆蓋已被其他人修改的值。但這些不等同於事務——事務通常指的是將多個物件上的多個操作組合為一個執行單元的機制。

錯誤處理與中止#

事務的關鍵優勢在於:出錯時可以中止並安全重試。然而重試機制也有其限制:

  • 如果事務實際上已成功提交但確認回應在網路中遺失,重試會導致操作被重複執行(除非應用程式有額外的去重機制)
  • 如果錯誤來自系統過載,重試反而會惡化問題,此時應使用指數退避(exponential backoff)或限制重試次數
  • 只有暫時性錯誤值得重試(如死鎖、網路中斷),永久性錯誤(如違反約束)重試也無濟於事
  • 如果事務在資料庫之外有副作用(如發送電子郵件),重試可能導致副作用重複發生

弱隔離等級#

完全的可序列化隔離雖然概念上最簡潔,但效能代價高昂。因此實務中多數資料庫使用弱隔離等級(weak isolation levels),僅防止部分並行問題。這些弱隔離等級的微妙行為曾導致真實世界中的嚴重財務損失與資料損毀。常見的回應是「處理金融資料就該用 ACID 資料庫」,但這句話忽略了一個事實:即使是號稱 ACID 的主流關聯式資料庫,預設使用的也是弱隔離等級。

與其盲目信賴工具,不如深入理解各種並行問題的本質以及如何防止它們。

Figure 7-1: 兩個客戶端並行遞增計數器的競爭條件

Read Committed#

Read Committed 是最基本的事務隔離等級,也是 Oracle 11g、PostgreSQL、SQL Server 2012 等多數資料庫的預設隔離等級。它提供兩個保證:

  • 無髒讀(no dirty reads):只能讀取已提交的資料
  • 無髒寫(no dirty writes):只能覆蓋已提交的資料

無髒讀#

如果一個事務可以看到另一個尚未提交的事務所寫入的資料,這就是髒讀(dirty read)。髒讀的問題在於:你可能看到一個事務的部分更新結果,也可能看到最終被回復的資料。

Figure 7-4: 無髒讀:User 2 僅在 User 1 事務提交後才看到新值

無髒寫#

當兩個事務同時嘗試更新同一物件時,如果後者覆蓋了前者尚未提交的值,就是髒寫(dirty write)。

Figure 7-5: 髒寫導致不同事務的寫入交錯混合

以二手車交易網站為例:買車需要更新兩個資料表(車輛列表和銷售發票)。如果允許髒寫,可能導致車被判給 Bob,但發票卻寄給 Alice——兩個事務的寫入交錯混合。

實作方式#

  • 防止髒寫:使用行級鎖(row-level lock),事務修改物件前必須取得鎖,直到提交或中止才釋放
  • 防止髒讀:多數資料庫不使用讀取鎖(避免長寫入事務阻塞讀取),而是記住舊值和新值兩個版本,在事務提交前將舊值返回給其他讀取者

Snapshot Isolation 與可重複讀取#

Read Committed 仍然無法防止所有並行問題。考慮以下情境:Alice 的 $1,000 存款分在兩個帳戶各 $500。當一筆轉帳正在進行時,Alice 恰好查看餘額,可能看到一個帳戶為 $500(轉帳前)、另一個為 $400(轉帳後),合計只有 $900——彷彿 $100 憑空消失了。

Figure 7-6: 讀取偏斜:Alice 觀察到不一致的資料庫狀態

這種異常稱為讀取偏斜(read skew)或不可重複讀取(nonrepeatable read)。對於日常查詢而言這是暫時的不一致,重新整理頁面就好。但對於以下場景則不可接受:

  • 備份:備份過程中資料持續寫入,可能導致備份的不同部分對應不同時間點的資料
  • 分析查詢與完整性檢查:大範圍掃描可能在不同時間點觀察到不同版本的資料,產生無意義的結果

快照隔離(Snapshot Isolation)的解決方案是:每個事務從資料庫的一致性快照(consistent snapshot)讀取——它看到的是事務開始時資料庫中所有已提交的資料,即使資料後來被其他事務修改,當前事務看到的仍是快照中的舊資料。這對備份和分析等長時間執行的唯讀查詢尤其重要:當查詢操作的資料在執行過程中不斷變化時,很難推論查詢結果的意義;而一致性快照讓查詢能在一個凍結的時間點上操作。

快照隔離受到廣泛支援:PostgreSQL、MySQL(InnoDB)、Oracle、SQL Server 等都提供此功能。

MVCC 實作機制#

快照隔離的核心實作技術是多版本並行控制(Multi-Version Concurrency Control, MVCC)。其關鍵原則是:讀者不阻塞寫者,寫者不阻塞讀者

Figure 7-7: 使用多版本物件實作快照隔離

具體做法(以 PostgreSQL 為例):

  • 每個事務都有一個唯一的、遞增的事務 ID(txid)
  • 資料表中每一行都有 created_bydeleted_by 欄位,記錄建立和刪除該行的事務 ID
  • 更新操作在內部被拆解為「刪除舊行 + 建立新行」
  • 垃圾回收程序會在適當時機清除不再需要的舊版本

可見性規則#

事務決定哪些物件可見的規則如下:

  1. 忽略在當前事務開始時仍在進行中(未提交)的所有其他事務的寫入
  2. 忽略所有已中止事務的寫入
  3. 忽略事務 ID 晚於當前事務的所有寫入(即使已提交)
  4. 其餘所有寫入對當前事務可見

換句話說,一個物件可見的條件是:建立它的事務在讀取者開始前已提交,且它未被標記為刪除(或標記刪除的事務在讀取者開始時尚未提交)。

長時間執行的事務可以持續使用某個快照很長一段時間,即使從其他事務的角度看,這些資料早已被覆蓋或刪除。透過永不就地更新值、而是每次變更都建立新版本,資料庫可以在僅增加少量開銷的情況下提供一致性快照。

命名混淆#

快照隔離在不同資料庫中有不同的名稱:Oracle 稱之為 serializable,PostgreSQL 和 MySQL 稱之為 repeatable read。這是因為 SQL 標準中沒有快照隔離的概念(標準基於 System R 在 1975 年的隔離等級定義,當時快照隔離尚未被發明)。SQL 標準定義了 repeatable read,表面上看起來類似快照隔離,所以 PostgreSQL 和 MySQL 便以此命名來宣稱符合標準。

更混亂的是,IBM DB2 用 “repeatable read” 來指稱 serializability。學術文獻中有 repeatable read 的形式化定義,但多數實作並不滿足該定義。結果就是:沒有人真正知道 repeatable read 到底代表什麼

防止更新遺失#

前面的討論聚焦在「讀取事務在並行寫入時能看到什麼」。現在轉向另一個重要問題:更新遺失(lost update)。

更新遺失發生在讀取-修改-寫入(read-modify-write)循環中:兩個事務同時讀取同一個值、各自修改、再寫回,後者的寫入會覆蓋前者的修改。典型場景包括:

  • 遞增計數器或更新帳戶餘額
  • 修改 JSON 文件中的某個元素
  • 兩個使用者同時編輯同一個 wiki 頁面

防止更新遺失的策略有以下幾種:

策略說明
原子寫入操作UPDATE counters SET value = value + 1 WHERE key = 'foo',由資料庫保證原子性。通常透過對物件取得排他鎖或在單一執行緒上執行來實作
顯式鎖定應用程式使用 SELECT ... FOR UPDATE 鎖定要修改的行,其他事務必須等待
自動偵測更新遺失允許並行執行,當事務管理器偵測到更新遺失時,自動中止有問題的事務並強制重試。PostgreSQL 的 repeatable read、Oracle 的 serializable、SQL Server 的 snapshot isolation 都支援此功能,但 MySQL/InnoDB 的 repeatable read 不會偵測更新遺失
Compare-and-set僅在值未被其他人修改時才執行更新,例如 UPDATE wiki_pages SET content = 'new' WHERE id = 1234 AND content = 'old'。但要注意資料庫是否從舊快照讀取 WHERE 條件

在多領導者或無領導者複製的資料庫中,鎖和 compare-and-set 都不適用,因為不存在單一的最新副本。此時需要依賴衝突解決策略,例如允許並行寫入建立多個衝突版本(siblings),再由應用程式碼合併。

Write Skew 與 Phantoms#

除了髒寫和更新遺失,還有一種更微妙的並行問題:write skew

以醫院值班為例:規定至少要有一位醫生值班。Alice 和 Bob 同時是值班醫生,兩人都覺得不舒服,幾乎同時請假。在快照隔離下,兩個事務各自查詢到「目前有兩位值班醫生」,因此認為自己離開是安全的,各自更新自己的記錄,結果提交後無人值班。

Figure 7-8: Write skew 造成應用程式錯誤的範例

Write skew 的特徵#

Write skew 可以視為更新遺失的推廣:兩個事務讀取相同的物件集合,然後各自更新其中不同的物件。由於更新的不是同一個物件,髒寫偵測和更新遺失偵測都不會觸發。

防止 write skew 的選項有限:

  • 原子單物件操作無法幫助,因為涉及多個物件
  • 快照隔離的自動偵測機制也不適用(PostgreSQL、MySQL、Oracle、SQL Server 都不會自動偵測 write skew)
  • 資料庫約束(uniqueness、foreign key)僅能表達有限的規則
  • 若無法使用可序列化隔離,次佳選項是使用 SELECT ... FOR UPDATE 顯式鎖定

更多 Write Skew 範例#

場景問題描述
會議室預約系統檢查同時段無衝突預約後插入新預約,兩個並行事務可能各自通過檢查
多人遊戲防止兩個玩家將不同棋子移到同一格
搶註使用者名稱兩人同時註冊同一名稱,各自檢查到不存在
防止雙重支出兩筆消費各自檢查餘額充足,同時插入導致透支

Phantoms#

上述 write skew 範例都遵循一個共同模式:

  1. SELECT 查詢檢查某個條件是否滿足
  2. 根據查詢結果決定是否繼續操作
  3. 執行 INSERT/UPDATE/DELETE,改變了步驟 1 查詢條件的前提

這種「一個事務的寫入改變了另一個事務搜尋查詢結果」的現象稱為 phantom。在某些情境中,可以用 SELECT FOR UPDATE 鎖定相關行;但當查詢的是「不存在的行」(如會議室預約檢查回傳零筆結果),就無法對不存在的東西加鎖。

具體化衝突(materializing conflicts)是一個技巧:預先建立一個包含所有可能組合的輔助資料表(如所有時段和房間的組合),再對該表的行加鎖。例如在會議室預約中,可以為未來六個月的每個 15 分鐘時段和每個房間建立一行,事務先用 SELECT FOR UPDATE 鎖定對應的行,再檢查衝突並插入預約。這個輔助表並不儲存預約資訊本身,純粹是一組鎖的集合。

但這種做法侵入了應用程式的資料模型,且很難正確設計,應視為最後手段。在多數情況下,使用可序列化隔離等級是更好的選擇。

可序列化(Serializability)#

弱隔離等級只能防止部分並行問題,write skew 和 phantom 等複雜情境仍需開發者自行處理。這帶來了幾個令人困擾的現實:

  • 隔離等級本身就難以理解,且在不同資料庫中的實作不一致
  • 從應用程式碼很難判斷在某個隔離等級下是否安全,尤其在大型應用中
  • 目前沒有好的工具來偵測競爭條件,測試也很困難,因為問題通常是非確定性的

這個問題自 1970 年代弱隔離等級首次被引入以來就存在。研究者的答案始終很簡單:使用可序列化隔離(serializable isolation)。它是最強的隔離等級,保證並行執行的事務結果等同於某種序列執行順序,從而防止所有可能的競爭條件。

目前主流的三種可序列化實作方式各有優劣。

實際序列執行(Actual Serial Execution)#

最直接的方法是完全消除並行:在單一執行緒上依序執行所有事務。大約 2007 年開始,這種看似簡單粗暴的方式才變得可行,原因有二:

  • RAM 變得足夠便宜,許多工作負載可以將活躍資料集完全載入記憶體,事務執行速度大幅提升
  • OLTP 事務通常短小且只涉及少量讀寫,長時間的分析查詢可在快照隔離下另行處理

VoltDB/H-Store、Redis、Datomic 都採用了此方法。在單執行緒上執行的系統有時甚至比支援並行的系統表現更好,因為它避免了鎖的協調開銷。但其吞吐量上限就是單一 CPU 核心的處理能力。

Stored Procedures#

要在單一執行緒上高效執行事務,就不能允許事務在等待網路往返或使用者輸入時占據執行緒。解決方案是要求應用程式一次提交整個事務邏輯作為 stored procedure,資料庫一口氣執行完畢。

Figure 7-9: 互動式事務與 stored procedure 的差異

傳統的 stored procedure(PL/SQL、T-SQL)因語言老舊、難以除錯和測試而風評不佳。但現代實作使用通用程式語言(VoltDB 用 Java/Groovy、Datomic 用 Java/Clojure、Redis 用 Lua),大幅改善了開發體驗。

分割區與限制#

單一執行緒的吞吐量受限於單一 CPU 核心。若能將資料分割區(partition)使每個事務只存取單一分割區內的資料,就能讓每個分割區各自擁有獨立的事務處理執行緒,線性擴展吞吐量。但跨分割區事務需要額外的協調開銷,效能大幅下降(VoltDB 報告跨分割區寫入吞吐量約 1,000 次/秒,遠低於單分割區表現)。

實際序列執行要求所有事務短小快速、活躍資料集能載入記憶體、寫入吞吐量在單核可處理的範圍內。這些條件並非所有場景都能滿足。

兩階段鎖定(Two-Phase Locking, 2PL)#

2PL 是數十年來唯一廣泛使用的可序列化演算法。它比一般的鎖機制更嚴格:寫者不僅阻塞其他寫者,也阻塞讀者;讀者也會阻塞寫者。這與快照隔離的「讀者不阻塞寫者」原則形成鮮明對比。

兩階段鎖定(2PL)和兩階段提交(2PC)是完全不同的概念,不要混淆。2PC 將在第九章討論。

實作機制#

每個物件上有一個鎖,支援共享模式(shared mode,讀取用)和排他模式(exclusive mode,寫入用):

  • 讀取需取得共享鎖,多個事務可同時持有;但若有排他鎖存在則必須等待
  • 寫入需取得排他鎖,不允許任何其他鎖同時存在
  • 讀取後轉為寫入時,共享鎖可升級為排他鎖
  • 鎖在事務結束時才釋放——這就是「兩階段」的由來:第一階段取得鎖,第二階段釋放鎖

由於大量使用鎖,死鎖(deadlock)很容易發生。資料庫會自動偵測並中止其中一個事務。

效能問題#

2PL 的最大缺點是效能。這一方面源自取得和釋放大量鎖的開銷,但更重要的是並行度大幅降低:只要兩個並行事務可能以任何方式產生競爭條件,其中一個就必須等待另一個完成。

傳統關聯式資料庫不限制事務的持續時間(因為它們為互動式應用而設計),因此一個慢速事務可能阻塞大量其他事務形成排隊效應,導致延遲不穩定且出現極端值。在 2PL 下,死鎖發生的頻率也遠高於 lock-based 的 read committed,進一步影響效能。

謂詞鎖與索引範圍鎖#

為了防止 phantom(例如會議室預約的並行插入),2PL 使用謂詞鎖(predicate lock):鎖定所有符合特定搜尋條件的物件,包括尚不存在的物件。但謂詞鎖的檢查開銷高昂,因此多數資料庫使用簡化版的索引範圍鎖(index-range lock):將鎖擴展到更大的範圍(例如鎖定某天所有房間的預約、或某房間所有時段的預約)。雖然不如謂詞鎖精確,但開銷低得多,是實務中可接受的折衷。

可序列化快照隔離(Serializable Snapshot Isolation, SSI)#

SSI 是一種相對較新的演算法(2008 年由 Michael Cahill 的論文提出),結合了快照隔離的效能優勢與可序列化的安全保證。它採用樂觀並行控制(optimistic concurrency control):允許事務自由執行,在提交時才檢查是否有衝突,有衝突則中止。

SSI 的核心策略是偵測事務是否基於過時的前提(outdated premise)做出了決策。有兩種需要偵測的情況:

偵測過時的 MVCC 讀取#

當事務從快照讀取時,它可能忽略了另一個當時未提交但後來已提交的事務的寫入。資料庫追蹤這些被忽略的寫入,在事務提交時檢查:如果被忽略的寫入已被提交,則當前事務必須中止。

為什麼要等到提交時才檢查?因為如果該事務最終只是唯讀事務,就不存在 write skew 的風險,不需要中止。而且被忽略的寫入所屬的事務可能還未提交或最終被中止,讀取最終可能並非過時。透過避免不必要的中止,SSI 維持了對長時間唯讀事務的良好支援。

Figure 7-10: 偵測事務從 MVCC 快照讀取過時值

偵測影響先前讀取的寫入#

當一個事務寫入資料庫時,它會查看索引中是否有其他事務最近讀取了受影響的資料。這類似於 2PL 中的索引範圍鎖,但不會阻塞其他事務,而是充當一個絆線(tripwire):通知相關事務它們讀取的資料可能已過時。

在醫生值班的例子中:當事務 42 寫入資料庫時,它通知事務 43 其先前讀取可能已過時,反之亦然。事務 42 先提交成功(因為此時事務 43 的寫入尚未生效),但當事務 43 嘗試提交時,來自事務 42 的衝突寫入已經生效,所以事務 43 必須被中止。

Figure 7-11: SSI 中偵測一個事務修改了另一個事務的讀取結果

SSI 的效能優勢#

相較於 2PL,SSI 的最大優勢是事務不需要互相等待。與快照隔離一樣,寫者不阻塞讀者,讀者不阻塞寫者,使查詢延遲更可預測。相較於實際序列執行,SSI 不受限於單一 CPU 核心——FoundationDB 將衝突偵測分散到多台機器上,實現高吞吐量擴展。

SSI 的代價是中止率:長時間執行的讀寫事務更容易遇到衝突而被中止,因此 SSI 要求讀寫事務盡量短小(純讀取的長時間事務不受影響)。

小結#

事務是一個強大的抽象層,讓應用程式可以忽略某些並行問題和故障場景。沒有事務的話,各種錯誤情境(程序崩潰、網路中斷、電力中斷、磁碟滿載、非預期的並行操作)都可能導致資料以各種方式變得不一致。本章深入探討了各種隔離等級及其能防止的競爭條件:

異常現象Read CommittedSnapshot IsolationSerializable
髒讀(dirty reads)防止防止防止
髒寫(dirty writes)防止防止防止
讀取偏斜(read skew)防止防止
更新遺失(lost updates)部分防止防止
Write skew防止
Phantom reads部分防止防止

三種可序列化實作方式的比較:

  • 實際序列執行:最簡單但受限於單核吞吐量,適合事務短小、資料集可載入記憶體的場景
  • 兩階段鎖定(2PL):數十年來的標準方案,但效能差、延遲不穩定,許多應用刻意避免使用
  • 可序列化快照隔離(SSI):最新的演算法,採用樂觀策略,兼顧效能與安全性,是目前最有前景的方向

本章討論的例子主要在單節點資料庫的脈絡下進行。分散式資料庫中的事務涉及全新的挑戰,將在接下來的章節中探討。