• 精確定義 ACID 四個性質,並知道每個性質由資料庫的哪個機制負責(以及哪個其實是應用程式的責任)。

  • 以衝突可序列化與優先圖判定一個排程是否正確。

  • 辨認 dirty read、non-repeatable read、phantom、lost update、write skew、read skew 等異常,並寫出重現它們的交錯時序。

  • 說明 ANSI 四個隔離等級的定義、Berenson 等人對它的批評,以及 PostgreSQL/MySQL/Oracle 各自的實際行為。

  • 理解 snapshot isolation 為何不是可序列化,以及如何處理 serialization failure。

交易是什麼

是一組被視為單一工作單位的資料庫操作:要嘛全部生效,要嘛全部不生效。 它是資料庫對應用程式最重要的抽象——讓開發者在面對當機與並行時,仍能以「一次只有我在操作、而且操作不會做到一半」的方式思考。

BEGIN;                                  -- 或 START TRANSACTION
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
SAVEPOINT before_fee;                   -- 部分回滾點
UPDATE accounts SET balance = balance - 5 WHERE id = 1;   -- 手續費
ROLLBACK TO SAVEPOINT before_fee;       -- 取消手續費,但保留第一筆更新
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;                                 -- 或 ROLLBACK 放棄全部

沒有明確 時,多數系統處於 autocommit:每個語句自成一筆交易。

ACID

交易中的操作「全有或全無」。由日誌(undo 資訊)或 shadow paging 實現; PostgreSQL 靠 MVCC——中止的交易所寫的版本只是被標為無效,不需要物理回復(第 9 章)。

交易把資料庫從一個合法狀態帶到另一個合法狀態。資料庫只能強制它知道的規則(主鍵、外鍵、CHECK、觸發器); 業務不變量(「帳戶總額守恆」)是應用程式的責任——C 是 ACID 中唯一不完全由資料庫保證的字母。

並行執行的交易彼此看不到對方的中間狀態,結果等價於某種序列執行。由並行控制(鎖、MVCC、OCC)實現,是本篇的主軸。

一旦 commit,即使當機、斷電也不會遺失。由 WAL + fsync(第 8 章)實現; PostgreSQL 的 允許以「最多遺失最後幾百毫秒已回報成功的交易」換取吞吐。

排程與可序列化

排程(schedule)是多筆交易操作的交錯順序。序列排程是交易一個接一個執行; 一個排程若效果等價於某個序列排程,稱為可序列化(serializable)——這是隔離性的黃金標準。

兩個操作衝突的條件:來自不同交易、存取同一資料項、且至少一個是寫入。三種衝突:

  • R–W(unrepeatable read):\(T_1\) 讀 \(A\) 後 \(T_2\) 寫 \(A\)。

  • W–R(dirty read):\(T_1\) 寫 \(A\) 後 \(T_2\) 讀 \(A\)。

  • W–W(lost update):\(T_1\) 寫 \(A\) 後 \(T_2\) 寫 \(A\)。

衝突可序列化:能透過交換不衝突的相鄰操作變成序列排程。判定方法:畫優先圖(precedence graph)—— 節點是交易,若 \(T_i\) 的某操作與 \(T_j\) 的操作衝突且 \(T_i\) 在前,畫 \(T_i \to T_j\)。無環 \(\Leftrightarrow\) 衝突可序列化,拓撲排序就是等價的序列順序。

View serializability 是更寬鬆的定義(允許 blind write 的特例),但判定是 NP-complete,實務系統都以衝突可序列化為目標。

此外還要考慮可復原性:若 \(T_2\) 讀了 \(T_1\) 的寫入,\(T_2\) 必須在 \(T_1\) 之後 commit(recoverable); 否則 \(T_1\) 中止時 \(T_2\) 已 commit 的結果建立在不存在的資料上。更強的 strict 排程(不讀也不覆寫未 commit 的資料) 避免連鎖中止(cascading abort)並讓 undo 變簡單——這是多數系統實際採用的。

異常現象

以下以兩筆交易 \(T_1\)、\(T_2\) 的時間軸呈現(時間由上而下)。

lP5.6cmP5.6cm 異常 & 時序 & 後果
Dirty read & \(T_1\): (未 commit)
\(T_2\): 讀到
\(T_1\): & \(T_2\) 看到從未存在的值。
Non-repeatable read & \(T_1\): 讀 得 5
\(T_2\):
\(T_1\): 再讀 得 7 & 同一交易內兩次讀同一列結果不同。
Phantom & \(T_1\): 得 10
\(T_2\):
\(T_1\): 同一查詢得 11 & 集合層級的不可重複讀:多出「幽靈」列。
Lost update & \(T_1\): 讀
\(T_2\): 讀
\(T_1\):
\(T_2\): & 兩次加一只加了一次。
Write skew & 約束:至少一位醫師值班。目前 Alice、Bob 都值班。
\(T_1\): 查到 2 人值班,Alice 下班
\(T_2\): 查到 2 人值班,Bob 下班
兩者 commit & 各自檢查都通過,合起來違反約束;兩者寫不同列,故 W–W 偵測抓不到。
Read skew & \(T_1\): 讀帳戶 A = 100
\(T_2\): A \(\to\) 50、B \(\to\) 150 轉帳並 commit
\(T_1\): 讀 B = 150 & \(T_1\) 看到總額 250,不一致的快照。

隔離等級

ANSI/ISO SQL-92 用「禁止哪些異常」定義四個等級:

等級 Dirty read Non-repeatable Phantom Write skew
Read Uncommitted 可能 可能 可能 可能
Read Committed 可能 可能 可能
Repeatable Read 可能 可能
Serializable

指出:ANSI 的定義是以鎖為心智模型寫的、對異常的描述有歧義、而且漏掉了 lost update、write skew 等現象。該文定義了 Snapshot Isolation(SI):每筆交易看到開始時刻的一致快照, 寫入時若發現同一列已被並行交易改過則中止(first-committer-wins)。SI 沒有 dirty/non-repeatable/phantom read, 卻允許 write skew——所以它不是 serializable,但 Oracle 把 SI 稱為「SERIALIZABLE」,PostgreSQL 9.1 之前亦然。

各系統的實際行為:

P2.1cmP3.0cmP4.3cmP4.3cm 系統 & 預設等級 & Repeatable Read 的實作 & Serializable 的實作
PostgreSQL & Read Committed(每個語句取新快照) & Snapshot Isolation:交易級快照;無 phantom;更新衝突報 & SSI(Serializable Snapshot Isolation,9.1+):偵測危險結構並中止,真正可序列化
MySQL InnoDB & Repeatable Read & MVCC 快照 + gap/next-key lock 防 phantom(僅對鎖定讀) & 全部讀加 S 鎖(2PL)
Oracle & Read Committed & 不提供(直接跳到 Serializable) & 其實是 SI,允許 write skew
SQL Server & Read Committed(鎖式;可開 RCSI) & 鎖式 & 鎖式 2PL;另有 SNAPSHOT 等級
CockroachDB / Spanner & Serializable & — & 預設即可序列化(分別為 SSI 變體與 2PL + TrueTime)

PostgreSQL 把 Read Uncommitted 當成 Read Committed 處理——MVCC 架構下根本不會讀到未 commit 的版本。

Snapshot Isolation 與 write skew

-- 兩個 session 同時執行
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM doctors WHERE on_call;          -- 兩邊都得到 2
UPDATE doctors SET on_call = false WHERE name = 'Alice';   -- session 1
UPDATE doctors SET on_call = false WHERE name = 'Bob';     -- session 2
COMMIT;                                              -- 兩邊都成功:0 人值班!

兩筆交易寫的是不同列,first-committer-wins 不會發生衝突。防範方法: (1) 改用 ——SSI 會在其中一方 commit 時偵測到 rw-conflict 的危險結構並回報 ; (2) 把「隱含的衝突」物化: 鎖住所讀的值班列、或在一張 表上做 製造 W–W 衝突; (3) 用約束(如對「至少一人值班」建立觸發器或 exclusion constraint)。

在 REPEATABLE READ 與 SERIALIZABLE 下,PostgreSQL 可能以 SQLSTATE (或 deadlock)中止任何交易, 這不是錯誤,而是協定的一部分:應用程式應該 重試整筆交易(含重新讀取),通常搭配指數退避並限制次數。 框架常提供 類的工具。同時避免長交易:它們阻礙 VACUUM 回收舊版本(第 9 章), 也放大衝突機率; 可以自動砍掉忘記 commit 的連線。

並行控制的三大家族

接下來三章分別深入三種實現隔離性的策略,這裡先建立地圖:

P2.6cmP4.4cmP2.6cmP4.0cm 策略 & 機制 & 假設 & 代表
悲觀:兩階段鎖(2PL) & 讀前取 S 鎖、寫前取 X 鎖,commit 時一併釋放;需偵測死結 & 衝突常見 & SQL Server、DB2、InnoDB(寫入)
樂觀:OCC & 讀/寫在私有空間進行,commit 時驗證讀集沒被改過,失敗則重做 & 衝突罕見、交易短 & 記憶體資料庫(Hekaton、Silo)、FoundationDB
多版本:MVCC & 寫入產生新版本,讀者看快照永不阻塞寫者;搭配 2PL 或 SSI 處理寫衝突 & 讀多寫少 & PostgreSQL、Oracle、InnoDB(讀取)、CockroachDB
時間戳排序(T/O) & 依交易時間戳決定順序,違反者中止 & 理論基礎;分散式常用 & Spanner(TrueTime)、部分 NewSQL

每筆寫入交易取得一個 32 位元遞增的 XID(/); 唯讀交易不消耗 XID(只用虛擬 XID),這就是為何「唯讀為主」的負載較不會遇到 XID 回繞。 交易狀態(in progress / committed / aborted)存在 commit log(pg_xact,舊稱 clog),每個 XID 兩個 bit。 Savepoint 以子交易(subtransaction)實作,各有自己的 XID; 記錄父子關係。 超過 64 個子交易的交易會讓快照的子交易快取溢出,造成明顯的效能退化——ORM 的「每個語句一個 savepoint」設定要特別留意。

v4 加入 : 配置遞增的 txn id、 寫 commit 記錄並等 WAL 落盤、 依 undo 資訊回復。 初版隔離以表級 2PL 實現(簡單但正確),v5 換成 MVCC 快照。測試中應包含本章每一種異常的重現腳本, 確認在你宣稱的隔離等級下它們不會發生。

  1. 排程 \(R_1(A)\,R_2(B)\,W_1(B)\,W_2(A)\,C_1\,C_2\):畫優先圖,判定是否衝突可序列化;是否 recoverable?是否 strict?

  2. 寫出一個 view serializable 但非 conflict serializable 的排程(提示:blind write)。

  3. 在 Read Committed 下, 兩個並行執行會不會 lost update? 若改成應用程式先 再 呢?說明 PostgreSQL 在 RC 下對已被更新列的「重新檢查」行為。

  4. 為 write skew 例子設計三種不同的防護方式並比較它們對並行度的影響。

  5. 為什麼 PostgreSQL 的 Repeatable Read 沒有 phantom,而 ANSI 允許 RR 有 phantom?這代表 PostgreSQL 的 RR 比 ANSI 的 RR 強還是弱?

  6. (動手)開兩個 ,在 RC、RR、SERIALIZABLE 下分別重現表格中的每一種異常,記錄哪些等級擋住了哪些異常。