分布式系統中的分布式事務、原子性、兩階段鎖與兩階段提交
ACID 確保事務的正確性
事務(Transaction)是并發控制和原子提交的抽象,它將一系列操作(可能在數據庫中的不同記錄上)視為一個單一的單元,并且不受故障或來自其他活動觀察的影響。事務處理系統要求程序員標記操作序列的開始和結束。
數據庫通常采用 ACID 原則來確保事務的正確性:
- 原子性(Atomicity - A)
要么全部完成,要么全部不完成 。即使發生故障,也不會出現部分更新完成而部分更新未完成的情況,它是“全有或全無”(all-or-nothing)的。原子性旨在掩蓋程序執行過程中發生的故障。
- 一致性(Consistency - C)
一致性通常指數據庫將強制執行由應用程序聲明的特定不變性(invariants)。
如果我們在銀行進行轉賬,那么一個重要的不變性是 銀行的總金額不應改變 。也就是說,即使錢從一個賬戶轉移到另一個賬戶,銀行所有賬戶的總和也應該保持不變。
“一致性”屬性確保在事務開始和結束時,數據庫的狀態都符合預定義的規則或約束。例如,如果一個轉賬事務成功完成,那么在扣除和增加金額之后,總資金量應仍然保持一致,否則事務將被視為無效并回滾。
- 隔離性(Isolation - I)
隔離性是指并發執行的事務是否能夠看到彼此的中間更改。目標是“不能”。從技術上講,隔離性意味著事務的執行是 可串行化(serializable)的 。
可串行化(Serializable)的含義: 如果一組事務并發執行并產生結果(包括新的數據庫記錄和任何輸出),那么這些結果是可串行化的, 當且僅當存在這些相同事務的某種串行執行順序(即一次執行一個,不并行)能夠產生與實際執行相同的結果。 這意味著,即使事務是并發執行的,它們最終的效果也如同按某種順序逐個執行一樣。
- 兩階段鎖定(Two-Phase Locking - 2PL) 是一種常用的并發控制機制,通過要求事務在操作數據前獲取鎖,并在事務提交或中止前一直持有鎖,來強制實現隔離性或可串行化。兩階段鎖定的“兩個階段”是指事務在 獲取鎖 和 釋放鎖 行為上的兩個獨立階段(后文會詳細探討)。
- 持久性(Durability - D)
持久性意味著在事務提交后(即客戶端收到數據庫已執行事務的回復后), 事務對數據庫的修改將是持久的,不會因任何形式的故障而被擦除 。
這通常意味著數據必須寫入 非易失性存儲 ,例如磁盤。
- 日志(Logging) 是實現持久性的一種重要技術。系統會在將更改寫入實際數據庫之前,先將更改記錄到一個 日志 中。即使系統在將更改“安裝”(install)到主存儲之前崩潰,恢復程序也可以使用日志來重做這些更改,確保數據最終的持久性。
一切或一無所有,這就是原子性
想象一下你在進行一次銀行轉賬:從你的賬戶 A 轉 100 元到朋友的賬戶 B。這個操作至少包含兩個步驟:
- 從賬戶 A 扣除 100 元。
- 給賬戶 B 增加 100 元。
現在,設想一個最糟糕的情況:系統在完成第一步后突然崩潰了。你的錢被扣了,但你朋友沒收到。這顯然是不可接受的。
為了解決這類問題,我們需要一個保證: 這一系列操作要么全部成功,要么就像從未發生過一樣 。這就是 原子性 (atomicity) 的核心思想,它也是我們今天要討論的 事務 (transaction) 最重要的特性之一。一個事務就是一組操作,它被設計為在面對并發和故障時,表現得像一個單一的、不可分割的單元。
并發控制與可串行化:承諾「先到后到」
在現實世界中,系統很少一次只處理一個請求。銀行的系統可能同時在處理成千上萬筆轉賬和查詢。當多個事務并發執行,并且它們試圖讀寫相同的數據時,新的問題就出現了。
比如,在你的轉賬事務 (T1) 正在進行的同時,另一個事務 (T2) 正在做全行審計,計算所有賬戶的總金額。如果 T2 在 T1 從賬戶 A 扣款后、向賬戶 B 加款前讀取了 A 和 B 的余額,那么它會發現總金額少了 100 元,從而引發錯誤的警報。
為了防止這種混亂,我們需要另一種保證,稱為 先后原子性 (before-or-after atomicity) 。它的意思是,并發事務的執行結果,必須和它們按照 某個 串行順序(一個接一個)執行的結果完全一樣。這個屬性也叫做 可串行化 (serializability) 。
至于究竟是哪個串行順序,我們通常不關心。只要最終結果是 T1; T2 或者 T2; T1 兩種串行順序之一的結果即可。這種保證讓我們可以在享受并發帶來的高性能的同時,不必擔心事務之間互相干擾,產生意想不到的錯誤結果。
兩階段鎖定
如何實現可串行化呢?一種常見的策略是 悲觀并發控制 (pessimistic concurrency control) 。它的核心思想是“先申請再使用”,它假設沖突很可能會發生,因此通過鎖定機制來阻止潛在的沖突。
最著名的悲觀鎖協議就是 兩階段鎖定 (two-phase locking, 簡稱 2PL) 。注意,它的名字和我們稍后要講的“兩階段提交”很像,但它們是完全不同的兩個概念。
2PL 的規則很簡單:
- 擴展階段 (Phase 1) :事務可以根據需要獲取鎖,但不能釋放任何鎖。
- 收縮階段 (Phase 2) :一旦事務釋放了第一個鎖,它就進入收縮階段,此后只能釋放鎖,不能再獲取任何新的鎖。
在實踐中,一種更嚴格也更常見的變體叫“強嚴格兩階段鎖定”,它要求事務必須持有所有鎖,直到事務結束(提交或中止)后才能一次性釋放。
為什么鎖必須持有到事務結束?
這是一個核心問題。想象一下,如果事務 T1 修改了數據 x,然后立即釋放了對 x 的鎖。此時,事務 T2 讀取了 x 的新值并提交。但隨后,T1 因為某種原因決定 中止 (abort) ,它會撤銷自己對 x 的修改。這時,T2 的計算結果就建立在一個“從未存在過”的數據之上,破壞了系統的一致性。持有鎖直到事務最終狀態(提交或中止)確定,就是為了防止這種“臟讀”問題。
兩階段鎖定會產生死鎖嗎?
會的 。這是一個經典的場景:事務 T1 鎖定了資源 A,然后嘗試獲取資源 B 的鎖;同時,事務 T2 鎖定了資源 B,并嘗試獲取資源 A 的鎖。兩者將永遠地等待對方,形成 死鎖 (deadlock) 。數據庫系統通常有專門的機制來處理這種情況,比如通過超時或者檢測等待圖中的循環來發現死鎖,然后強制中止其中一個事務來打破僵局。
鎖是排他的,還是允許多個讀者?
為了簡化討論,我們常假設鎖是 排他鎖 (exclusive locks) 。但在實際系統中,為了提高性能,通常會區分 共享鎖 (shared locks) 和排他鎖。多個事務可以同時持有同一個數據的共享鎖(用于讀取),但只要有一個事務想寫入,它就必須獲取排他鎖,并且此時不能有任何其他事務持有該數據的任何鎖(無論是共享還是排他)。
樂觀與悲觀之爭
與悲觀鎖“先問后走”的策略相反,還有一種 樂觀并發控制 (optimistic concurrency control) 。它的哲學是“先走再說,不行再道歉”。
在這種模型下,事務執行時不會加鎖,它們自由地讀取數據,并將修改寫入一個私有工作區。直到事務準備提交時,系統才會進行沖突檢查,看看在它執行期間,它讀取的數據是否被其他已提交的事務修改過。如果沒有沖突,就提交;如果發現沖突,那么這個事務就必須中止并重試。
如何在悲觀和樂觀并發控制之間選擇?
這取決于你的應用場景中沖突發生的頻率。
- 如果事務之間沖突非常頻繁(比如很多用戶搶購同一件商品), 悲觀鎖 更合適。雖然它可能會因為等待鎖而降低并發度,但它避免了大量事務因沖突而中止重試所帶來的無效工作。
- 如果事務之間沖突很少(比如用戶大多在修改自己的個人資料), 樂觀鎖 更優。它省去了加鎖和解鎖的開銷,允許更高的并發,只有在極少數發生沖突時才付出中止重日志回滾的代價。
樂觀鎖與悲觀鎖在 MySQL 中的實現
在 MySQL 中,這兩種鎖更多的是一種設計思想的體現,而不是兩種有明確開關的獨立功能。它們通過不同的 SQL 命令和表結構設計來實現。
悲觀鎖 (Pessimistic Locking)
悲觀鎖的實現,完全依賴于數據庫提供的原生鎖機制。在 MySQL (主要指 InnoDB 存儲引擎) 中,當你執行特定的 SELECT 語句時,就可以顯式地為數據行加上悲觀鎖。
主要有兩種方式:
- 共享鎖 (Shared Lock)
SELECT ... LOCK IN SHARE MODE;
這條語句會為你查詢的行加上一個共享鎖。其他事務可以讀取這些行(也可以加共享鎖),但不能修改它們,直到你的事務提交或回滾。這允許多個“讀者”同時存在,但會阻塞“寫者”。
- 排他鎖 (Exclusive Lock)
SELECT ... FOR UPDATE;
這是更強的鎖。它會為你查詢的行加上一個排他鎖。其他任何事務都不能再為這些行加任何鎖(無論是共享還是排他),也不能修改它們,直到你的事務結束。它同時阻塞了“讀者”和“寫者”。
當你執行一個普通的 UPDATE 或 DELETE 語句時,InnoDB 實際上也會自動地為涉及的行加上排他鎖,這本身就是一種悲觀鎖的體現。
總而言之,悲觀鎖在 MySQL 中是通過 LOCK IN SHARE MODE 和 FOR UPDATE 以及隱式的 UPDATE/DELETE 鎖來實現的,它利用數據庫的鎖機制來強制同步,保證在修改數據期間的獨占訪問。
樂觀鎖 (Optimistic Locking)
樂觀鎖則完全相反,它不依賴于數據庫的鎖機制,而是在 應用層面 實現的一種并發控制策略。實現它的前提是,你需要在你的數據表中增加一個額外的列,通常是 version (版本號) 或者 timestamp (時間戳)。
實現步驟如下:
- 增加版本列
在你的表中增加一個 version 列,通常是整型,默認值為 0 或 1。
ALTER TABLE products ADD COLUMN version INT NOT NULL DEFAULT 1;
- 讀取數據時包含版本號
當你的應用程序需要修改一條數據時,你首先將這條數據連同它的 version 值一起讀出來。
SELECT id, name, stock, version FROM products WHERE id = 101;
假設讀出的 stock 是 50,version 是 2。
- 更新數據時校驗并更新版本號
當你準備將修改寫回數據庫時,你的 UPDATE 語句必須同時滿足兩個條件:id 匹配,并且 version 也要匹配你當初讀出來的值。如果更新成功,則同時將 version 加一。
UPDATE products
SET stock = 49, version = version + 1
WHERE id = 101 AND version = 2;
工作原理
- 如果這條 UPDATE 語句成功執行,并且影響的行數為 1,說明在你讀取數據到寫入數據的這段時間內,沒有其他事務修改過這條數據。更新成功!
- 如果影響的行數為 0,則說明在你操作的這段時間里,有另一個事務已經修改了這條數據,并增加了 version 的值。你手里的 version = 2 已經過時了。此時,更新失敗。你的應用程序需要捕獲這個“失敗”,然后通常會重新讀取最新的數據,再嘗試一遍修改流程,或者提示用戶操作沖突。
總結來說,樂觀鎖在 MySQL 中是通過在表中增加版本字段,并在 UPDATE 時利用 WHERE 子句進行版本校驗來實現的。它將并發控制的責任從數據庫轉移到了應用程序。
分布式兩階段提交
當一個事務需要修改分布在不同服務器上的數據時,問題變得更加復雜。比如,一個跨行轉賬事務,既要操作 A 銀行的數據庫,也要操作 B 銀行的數據庫。我們如何保證這個分布式事務的原子性?
這就是 兩階段提交 (two-phase commit, 簡稱 2PC) 大顯身手的地方。2PC 引入了兩個角色:一個 協調者 (coordinator) 和多個 參與者 (participants) (或稱為 workers)。
會有多個事務同時活躍嗎?參與者如何知道消息屬于哪個事務?
是的,系統中可以同時有許多活躍的分布式事務。為了區分它們,協調者發起的每個事務都會帶有一個全局唯一的 事務 ID (transaction ID) 。所有在參與者之間傳遞的消息都會包含這個 ID,這樣每個參與者就知道自己是在為哪個事務工作。
2PC 的流程如下:
階段一:投票階段 (Voting Phase)
- 準備 (Prepare) :協調者向所有參與者發送一個 PREPARE 消息,詢問“你們是否準備好提交?”。
- 投票 (Vote) :
- 每個參與者收到 PREPARE 消息后,會檢查自己是否能完成任務。如果可以,它會將所有需要的數據和操作記錄到持久化的 日志 (log) 中,確保即使現在崩潰,重啟后也能完成提交。然后,它向協調者回復 PREPARED 消息。一旦發送了 PREPARED,參與者就進入了“準備就緒”狀態,它放棄了單方面中止的權利,只能等待協調者的最終指令。
- 如果參與者因為任何原因無法完成任務,它會直接回復 ABORT 消息。
參與者為何會發送 ABORT 而不是 PREPARED?
有多種可能的原因:
- 本地約束沖突 :例如,事務試圖插入一個重復的主鍵,而表定義了主鍵唯一性約束。
- 死鎖 :參與者可能卷入了一個本地的鎖死鎖,為了打破死鎖,它必須中止當前事務。
- 崩潰恢復 :參與者可能在收到 PREPARE 之前就已經崩潰并重啟,導致它丟失了為該事務所做的臨時修改和持有的鎖,因此無法保證能完成提交。
階段二:決定階段 (Decision Phase)
- 做決定:協調者收集所有參與者的投票。
- 如果 所有 參與者都回復了 PREPARED,協調者就決定 提交 (commit) 整個事務。
- 如果 任何一個 參與者回復了 ABORT,或者在超時時間內沒有響應,協調者就決定 中止 (abort) 整個事務。
- 通知結果 :協調者將最終決定(COMMIT 或 ABORT)廣播給所有參與者。參與者收到后,執行相應的操作(正式提交或回滾),然后釋放資源。
2PC 系統如何撤銷修改?
關鍵在于日志。在準備階段,參與者不僅僅記錄了要“做什么” (redo 信息),也記錄了如何“撤銷” (undo 信息)。如果最終決定是中止,參與者就會根據日志中的 undo 記錄,執行反向操作,將數據恢復到事務開始前的狀態。
2PC 的挑戰與替代方案
2PC 雖然經典,但有一個致命弱點: 阻塞問題 。
如果協調者崩潰了,參與者該怎么辦?
這是 2PC 最大的問題。如果一個參與者已經發送了 PREPARED 消息,然后協調者崩潰了,這個參與者就完全不知道該提交還是中止。它不能自己做決定,因為其他參與者可能投了反對票。因此,它只能 無限期地等待 ,并持有事務期間獲得的鎖,這會阻塞其他需要這些資源的事務,直到協調者恢復。
為什么不用三階段提交 (3PC)?
3PC 確實是為了解決 2PC 的阻塞問題而設計的,它在準備和提交之間增加了一個“預提交”階段。理論上,這允許在協調者崩潰后,存活的參與者們可以互相通信并達成一個一致的決定。然而,3PC 協議更復雜,通信開銷更大,并且在面對網絡分區(一部分參與者無法與另一部分通信)時仍然可能阻塞。在工程實踐中,許多系統認為這種復雜性帶來的收益有限,因此 2PC 仍然是更主流的選擇。
可以用 Raft 替代 2PC 嗎?
不行,它們解決的是不同的問題。
- 2PC 是用來協調多個節點執行 不同但相關 的操作,并保證這些操作的原子性(要么都做,要么都不做)。它通常要求所有參與者都存活才能做出進展。
- Raft 是一種共識算法,用于讓一組節點(副本)就 同一個值或同一個操作序列 達成一致,從而實現一個高可用的狀態機。Raft 只需要大多數節點存活即可工作。
實戰中的兩階段提交 —— 以 MySQL 為例
你可能覺得分布式事務離我們很遙遠,但實際上,像 MySQL 這樣的常用數據庫內部就在使用 2PC 的思想來解決一致性問題。
背景:MySQL 的主從復制
在生產環境中,MySQL 常常采用主從(Primary-Replica)或主備(Primary-Secondary)架構。所有寫操作在主庫上進行,然后通過一種叫做 二進制日志 (binary log, binlog) 的文件記錄下來。從庫會讀取主庫的 binlog,并在自己身上重放這些操作,從而與主庫保持數據同步。
同時,MySQL 的 InnoDB 存儲引擎自身也有一套用于崩潰恢復的日志系統,叫做 重做日志 (redo log) 。
問題:redo log 和 binlog 的一致性
現在問題來了:一次事務提交,既要寫 redo log(為了保證 InnoDB 自身崩潰后能恢復),也要寫 binlog(為了讓從庫能同步)。這兩次寫操作必須是原子的。
想象一下,如果先寫了 redo log,事務在主庫上生效了,但還沒來得及寫 binlog,主庫就崩潰了。主庫重啟后,通過 redo log 恢復了數據,但 binlog 里沒有這次的修改記錄,導致所有從庫都丟失了這次更新,數據就不一致了。
反之,如果先寫了 binlog,但還沒寫 redo log 就崩潰了。主庫重啟后,通過 redo log 回滾了未完成的事務,數據被撤銷了。但 binlog 里卻有這次的記錄,從庫會執行這次更新,數據同樣不一致。
解決方案:內部的兩階段提交
為了解決這個問題,MySQL 巧妙地在數據庫服務器內部實現了一個兩階段提交。在這里:
- 協調者 :是 MySQL 服務器本身。
- 參與者 :主要是 InnoDB 存儲引擎。
當客戶端執行 COMMIT 時,流程如下:
- 階段一:準備 (Prepare)
- MySQL 服務器通知 InnoDB:“準備提交事務”。
- InnoDB 寫入 redo log,并將這個事務標記為 prepared 狀態。注意,此時事務并未真正提交,只是處于可以被提交的狀態。
- 階段二:提交 (Commit)
- 如果 InnoDB prepare 成功,MySQL 服務器就會將該事務寫入 binlog 。
- 寫完 binlog 后,MySQL 服務器再通知 InnoDB:“正式提交事務”。
- InnoDB 收到指令后,將 redo log 中該事務的狀態從 prepared 修改為 committed 。提交完成。
XID 的作用與崩潰恢復
在這個過程中,一個關鍵的東西是 事務 ID (XID) 。它會被同時寫入 redo log 和 binlog,作為兩者關聯的憑證。
如果系統在寫完 binlog 后、InnoDB 最終提交前崩潰,重啟時 MySQL 會這樣做:
- 它會掃描最后的 binlog 文件,找出其中已經包含的事務 XID。
- 然后去檢查 InnoDB redo log 中處于 prepared 狀態的事務。
- 如果一個 prepared 狀態的事務,其 XID 存在于 binlog 中,說明協調者(MySQL Server)在崩潰前已經做出了“提交”的決定(因為 binlog 已經寫入),那么就命令 InnoDB 提交這個事務。
- 如果一個 prepared 狀態的事務,其 XID 不 存在于 binlog 中,說明協調者在崩潰前還沒來得及做決定,那么就命令 InnoDB 回滾這個事務。
通過這種方式,MySQL 保證了 redo log 和 binlog 之間的數據一致性,從而確保了整個主從復制架構的可靠性。
先寫 redo log 還是 binlog?
答案是: 先寫 redo log (prepare 階段),再寫 binlog 。
這個順序至關重要,是保證數據一致性的核心。讓我們再回顧一下不這么做的后果:
- 如果先寫 binlog,后寫 redo log
- binlog 寫入成功。(此時從庫已經可以看到這個修改,并準備同步)
- 數據庫 崩潰 。
- redo log 還沒來得及寫。
- 重啟后 MySQL 通過 redo log 進行崩潰恢復,發現這個事務沒有完成 prepare 和 commit,于是 回滾 了它。
- 結果主庫數據被回滾,但從庫執行了 binlog 中的操作。 主從數據不一致 。
- 正確的順序:先寫 redo log (prepare),后寫 binlog
- redo log 寫入成功,狀態為 prepared。
- 數據庫 崩潰 。
- binlog 還沒來得及寫。
- 重啟后 :MySQL 進行恢復。它發現 redo log 中有一個 prepared 狀態的事務,然后它會去 binlog 中查找對應的事務 ID (XID)。
- 決策 :因為它在 binlog 中 找不到 這個事務的記錄,MySQL 就知道這個事務在崩潰前并沒有被分發給從庫,于是決定 回滾 它。
- 結果 :主庫回滾了事務,binlog 里也沒有這個事務,從庫自然也不會執行。 主從數據保持一致 。
只有當 binlog 也成功寫入后,整個事務才被認為是“可以安全提交的”。這時即使在最終 commit redo log 之前崩潰,恢復時也會因為在 binlog 中能找到記錄而決定提交事務,最終依然能保證主從一致性。