CMU15-445 數據庫系統播客:OLTP 與分布式事務
在互聯網浪潮席卷全球的今天,從電商購物到社交分享,從在線支付到流媒體服務,數據以前所未有的規模和速度產生。傳統的單體數據庫,盡管穩定可靠,卻早已在海量并發和高可用性的要求面前顯得力不從心。分布式數據庫,通過將數據和計算負載分散到多臺機器上,成為了支撐現代應用不可或缺的基石。
然而,將數據“分而治之”的同時,也引入了前所未有的復雜性。當一次操作需要跨越多個網絡節點時,我們如何確保它像在單臺機器上一樣可靠?當某個節點突然宕機時,系統如何繼續提供服務而不丟失數據?
本文將帶你深入分布式在線事務處理(OLTP)數據庫的核心,從最基礎的事務概念出發,層層剖析分布式環境下的原子性、一致性與可用性挑戰,探討從二階段提交(2PC)、共識協議(Paxos/Raft)到最終指導所有設計的 CAP 定理,理解構建一個健壯分布式系統背后所必須做出的深刻權衡。
為什么我們離不開事務(Transaction)?
在深入“分布式”之前,我們必須先回到原點: 事務(Transaction) 。事務是數據庫管理系統執行過程中的一個邏輯單位,它將一系列操作打包,確保它們要么 全部成功 ,要么 全部失敗 。這種“全有或全無”的特性,就是我們熟知的 原子性(Atomicity) ,也是 ACID(原子性、一致性、隔離性、持久性)四大特性的基石。
這種保障在 在線事務處理(OLTP) 場景中至關重要。OLTP 系統的特點是處理大量、短促、高頻的讀寫請求。想象一下你在電商網站下單的場景:
- 創建訂單
- 扣減商品庫存
- 從你的賬戶扣款
- 增加商家賬戶余額
這是一個典型的 OLTP 事務。如果系統在第 3 步成功后、第 4 步執行前崩潰,你的錢被扣了,但商家沒收到,這將是一場災難。事務確保了這四個步驟被視為一個不可分割的整體,從而維護了系統的數據一致性。
在分布式環境中,這四個步驟可能發生在不同的服務器上(訂單服務、庫存服務、支付服務)。挑戰也因此升級:我們如何協調散布在各地的節點,讓它們對同一個事務的最終結果(提交或中止)達成一致的決定?
用二階段提交(2PC)實現分布式原子性
為了解決跨節點的原子性問題, 原子提交協議(Atomic Commit Protocol) 應運而生,其中最經典、最廣為人知的就是 二階段提交(Two-Phase Commit, 2PC) 。
2PC 引入了一個 協調者(Coordinator) 的角色,來統一指揮所有參與該事務的節點,即 參與者(Participants) 。顧名思義,整個過程分為兩個階段:
階段一:準備階段(投票階段)
- 詢問(Request) : 協調者向所有參與者發送一個“準備提交(Prepare)”的消息,詢問它們是否可以提交事務。
- 表態(Vote) : 每個參與者會檢查本地的事務是否可以成功(例如,是否滿足約束、日志是否已寫入持久化存儲)。
- 如果可以,它就鎖定相關資源,并將“可以提交(VOTE-COMMIT)”的響應發給協調者。
- 如果不行(例如,發生本地錯誤),它就直接發送“請求中止(VOTE-ABORT)”的響應。
階段二:提交階段(決策階段)
- 決策(Decision) : 協調者收集所有參與者的投票。
- 如果所有參與者 都回復“可以提交”,協調者就做出“全局提交(GLOBAL-COMMIT)”的決定,并向所有參與者發送提交消息。
- 只要有一個參與者 回復“請求中止”,或者有參與者超時未響應,協調者就做出“全局中止(GLOBAL-ABORT)”的決定,并向所有參與者發送中止消息。
- 執行(Execution) : 參與者根據協調者的最終決定,完成本地事務的提交或回滾,并釋放資源。
2PC 的致命缺陷:阻塞
2PC 用一種簡單民主的方式(一票否決)保證了原子性,但它存在一個致命的缺陷: 同步阻塞 。
- 協調者單點故障 :如果在第二階段,協調者發出決策后、所有參與者確認前宕機,那么所有參與者都會被“卡住”。它們不知道最終的決定是提交還是中止,只能無限期地等待協調者恢復。這期間,事務所占有的資源無法釋放,整個系統可能因此癱瘓。
- 參與者故障 :如果某個參與者在第一階段后宕機,協調者將無法收齊所有投票,導致整個事務超時并最終中止。即使只是網絡緩慢,也會導致協調者長時間等待,拖慢整個系統的性能。
正是因為 2PC 的脆弱性,它在對可用性要求極高的現代系統中已逐漸被更健壯的協議所取代。
從 Paxos 共識到 Raft 的普及
2PC 的問題在于,決策權過于集中且需要所有節點達成一致。有沒有一種方法,即使少數節點失聯,系統也能繼續運轉?答案就是 共識協議(Consensus Protocol) 。
與 2PC 的“全票通過”不同,像 Paxos 和 Raft 這樣的共識協議遵循 “少數服從多數” 的原則。它們的目標是讓分布式節點集群就某個值(例如,事務是提交還是中止)達成不可撤銷的一致。
與 2PC 的核心區別在于:共識協議只需要集群中超過半數(Majority)的節點達成一致,就可以做出決策。
這意味著,在一個由 5 個節點組成的集群中,只要有 3 個節點正常工作并達成一致,系統就能繼續處理請求,它可以容忍最多 2 個節點的失效。這極大地提高了系統的容錯能力和可用性,徹底解決了 2PC 的阻塞問題。
- Paxos : 由萊斯利·蘭波特提出,是共識協議的鼻祖,以嚴謹但難以理解著稱。為了解決多個提議者可能導致“活鎖”(livelock)的問題,工程實踐中通常采用其變種 Multi-Paxos ,即選舉一個 領導者(Leader) 來統一發起提議,從而提高效率。
- Raft : 由斯坦福大學的學者設計,其目標就是提供與 Paxos 相同的容錯保證,但更容易被理解和實現。Raft 通過明確的領導者選舉、日志復制等步驟,使得共識過程更加清晰,如今已在 TiDB、etcd、CockroachDB 等眾多知名項目中得到廣泛應用。
高可用的基石:數據復制策略
解決了事務的決策問題后,我們還需要考慮數據的物理安全。如果存儲數據的唯一節點宕機,數據就會永久丟失。為此, 數據復制(Replication) 成為分布式系統的標配。通過將數據存儲多個副本,即使部分節點失效,系統依然可以依賴其他副本繼續提供服務。
最常見的復制模型是 主從復制(Leader-Follower Replication) :
- 寫入流程 : 所有寫入請求都必須發送到 主節點(Leader) 。主節點完成寫入后,負責將數據變更日志同步給所有 從節點(Followers) 。
- 讀取流程 : 讀取請求既可以由主節點處理(保證讀到最新數據),也可以為了分流而發送到從節點(可能讀到稍有延遲的數據)。
- 故障轉移 : 當主節點宕機時,系統會通過共識協議(如 Raft)在從節點中選舉出一個新的主節點,接管所有寫入流量,實現自動故障恢復。
在這個復制過程中,一個關鍵的權衡點在于:主節點何時向客戶端確認“寫入成功”?這直接決定了系統的一致性模型。
- 同步復制(Synchronous Replication)
- 機制 : 主節點必須等待 所有 (或指定數量的)從節點確認已成功接收并持久化日志后,才向客戶端返回成功。
- 保障 : 強一致性(Strong Consistency) 。一旦寫入成功,任何后續的讀取請求(無論訪問哪個節點)都保證能看到最新的數據。不會有數據丟失的風險。
- 代價 : 性能較低,因為事務的延遲取決于最慢的那個從節點。
- 場景 : 金融交易、核心訂單系統等對數據正確性要求零容忍的場景。
- 異步復制(Asynchronous Replication)
- 機制 : 主節點將日志發送出去后, 無需等待 從節點的確認,立即向客戶端返回成功。
- 保障 : 最終一致性(Eventual Consistency) 。數據最終會同步到所有副本,但在主節點宕機且數據尚未同步完成的極端情況下,可能會丟失少量“已提交”的數據。
- 代價 : 數據安全性稍弱,但寫入延遲極低,吞吐量高。
- 場景 : 社交媒體點贊、發布評論等對性能和可用性要求高,但能容忍秒級數據延遲的場景。
CAP 定理的權衡藝術
至此,我們討論了原子性、可用性、一致性等多個維度。而將這些概念統一在一個框架下的,就是分布式系統領域著名的 CAP 定理 。
該定理指出,一個分布式系統在以下三個核心特性中, 最多只能同時滿足兩個 :
- 一致性 (Consistency, C) : 所有節點在同一時間訪問到的數據是完全一致的。這里的一致性通常指最嚴格的線性一致性(Linearizability),即所有讀操作都能獲取到最近一次寫入的最新數據。
- 可用性 (Availability, A) : 任何來自客戶端的請求,無論成功或失敗,系統都能在有限時間內給出響應。簡單說,就是系統永遠是“活的”。
- 分區容錯性 (Partition Tolerance, P) : 系統在遇到網絡分區(即節點間的網絡連接中斷,導致集群被分割成多個孤島)時,仍然能夠繼續運行。
在現代分布式系統中,網絡故障是常態而非例外,因此 P(分區容錯性)是必選項 。這意味著,我們必須在 C(一致性) 和 A(可用性) 之間做出選擇。
選擇 CP (Consistency / Partition Tolerance)
- 當網絡分區發生時,為了保證數據的一致性,系統會 犧牲可用性 。
- 通常的做法是,被分割出去的少數節點會停止服務,只有擁有“法定人數”(Majority)的主分區會繼續處理請求。這避免了“腦裂”(Split-Brain)問題,即不同分區產生相互沖突的數據。
- 代表系統 : 大多數關系型數據庫、NewSQL 數據庫如 Google Spanner, TiDB, CockroachDB。它們優先保證數據絕不出錯。
選擇 AP (Availability / Partition Tolerance)
- 當網絡分區發生時,為了保證系統的可用性(每個分區都能響應請求),系統會 犧牲一致性 。
- 所有分區會繼續獨立處理讀寫請求。這意味著在分區期間,你寫入一個分區的數據對另一個分區是不可見的。當網絡恢復后,系統需要通過復雜的沖突解決機制來合并這些差異數據,最終達到一致。
- 代表系統 : 大多數 NoSQL 數據庫如 Amazon DynamoDB, Cassandra。它們優先保證服務永遠在線,即使數據可能暫時不一致。
深度拓展(一):MySQL 內部的“二階段提交”—— redo log 與 binlog 的雙重奏
讀到這里,經驗豐富的后端工程師可能會聯想到一個非常熟悉的場景:MySQL 中 redo log 和 binlog 的協同工作。這其實就是二階段提交(2PC)思想在 單機系統內部、跨組件協調 中的一個絕佳應用。它與我們之前討論的、用于多臺機器間的分布式事務 2PC,在思想上同源,但在應用范疇和目標上有所不同。
為何需要這個內部 2PC?
首先,我們必須明確兩個日志的核心職責:
redo log(重做日志) : 這是 InnoDB 存儲引擎層面的日志。它記錄了對數據頁(Page)的物理修改,保證了事務的 持久性(Durability) 和 崩潰安全(Crash Safety) 。即使數據庫異常宕機,InnoDB 也可以通過redo log恢復到宕機前的狀態。binlog(二進制日志) : 這是 MySQL Server 層面的日志。它記錄了所有修改數據的邏輯操作(SQL 語句或行的變更),主要用于 主從復制(Replication) 和 數據恢復 。從庫通過拉取并回放主庫的binlog來實現數據同步。
核心矛盾在于 :一個事務的提交,既要確保在 InnoDB 內部是持久的(redo log 寫入),也要確保能被從庫正確復制(binlog 寫入)。如果這兩個操作不是原子的,一旦在它們之間發生宕機,就會導致主從數據不一致的災難:
- 場景A:先寫
redo log,再寫binlog。如果redo log寫完后、binlog寫入前宕機。主庫重啟后通過redo log恢復了數據,但binlog里沒有這次變更。結果就是,從庫永遠收不到這次更新,主從數據產生永久性差異。 - 場景B:先寫
binlog,再寫redo log。如果binlog寫完后、redo log寫入前宕機。主庫重啟后由于沒有redo log記錄,會回滾這個事務,數據沒有變化。但binlog已經記錄了這次變更并可能已傳給從庫。結果是,從庫比主庫“多”了一次更新,數據再次不一致。
MySQL 的解決方案:內部 2PC
為了解決這個“跨組件原子寫入”的問題,MySQL 巧妙地引入了內部 2PC:
準備階段 (Prepare Phase)
- 當客戶端執行
COMMIT時,InnoDB 引擎會將事務的所有變更寫入redo log,并將該事務標記為 “準備(prepare)” 狀態。此時,事務并未真正提交。
提交階段 (Commit Phase)
- MySQL Server 層接收到 InnoDB 的“準備好了”信號后,會將該事務的變更寫入
binlog。 binlog成功寫入磁盤后,Server 層會調用 InnoDB 的接口,通知其完成事務的最終提交。InnoDB 收到指令后,將redo log中對應的事務狀態從“準備”修改為 “提交(commit)” 。
這個過程確保了 redo log 和 binlog 的內容是邏輯一致的。即使在任何一個步驟之間發生崩潰,MySQL 在重啟時都有明確的恢復策略:
- 如果一個事務在
redo log中是 “prepare” 狀態,但在binlog中 找不到 ,說明崩潰發生在第一階段之后、第二階段之前。恢復時, 回滾 該事務。 - 如果一個事務在
redo log中是 “prepare” 狀態,并且在binlog中 能找到 ,說明崩潰發生在第二階段binlog寫完之后、redo logcommit 之前。恢復時, 提交 該事務。
通過這種方式,MySQL 保證了任何一個成功提交的事務,其 redo log 和 binlog 必然是同時存在的、完整的,從而為主從復制的正確性提供了堅實的基礎。這與分布式 2PC 用于協調多個獨立節點的思路異曲同工,都是為了確保一個邏輯操作在不同參與方之間的原子性。
深度拓展(二):現代高并發架構 —— MySQL、Raft 與分片的“三位一體”
了解了 MySQL 的內部機制后,我們再將視角拉回宏觀架構。大型互聯網公司面對每秒數萬甚至數十萬的請求,是如何基于 MySQL 構建高可用、高并發的存儲服務的?答案并非單一技術,而是一個由 數據復制、共識算法和智能代理 組合而成的精密體系。
傳統的 MySQL 主從復制(一主多從)雖然能通過讀寫分離分攤負載,但其“軟肋”也十分明顯:
- 故障轉移(Failover)是手動的或依賴腳本 :主庫宕機后,需要 DBA 或自動化腳本介入,選擇一個從庫提升為新主庫,并修改應用配置。這個過程耗時且容易出錯。
- 存在“腦裂”風險 :在自動切換的方案中,如果因為網絡問題導致主庫“假死”,可能會產生兩個主庫,造成數據沖突。
為了解決這些問題,現代架構通常采用 基于 Raft 共識的 MySQL 高可用方案 ,如官方的 MySQL Group Replication (MGR) 或開源的 Orchestrator ,以及在此之上的代理層(如 ProxySQL )。
架構藍圖:共識算法賦能的 MySQL 集群
一個典型的高可用 MySQL 集群架構如下:
- 數據層 : 由一組(通常至少 3 個)安裝了 MySQL 實例的服務器組成。它們之間通過 Group Replication 插件進行通信。
- 共識層 : Group Replication 內部集成了一個 Raft 變種的共識協議 。這個協議不負責同步業務數據(業務數據同步依然依賴
binlog),而是專門負責 集群成員管理、主節點選舉和維護集群元數據的一致性 。 - 代理/路由層 : 應用并不會直接連接 MySQL 實例,而是連接一個輕量級的智能代理(如 ProxySQL)。這個代理了解整個 MySQL 集群的拓撲結構和節點角色(誰是主,誰是從),并負責:
- 自動讀寫分離 : 將所有寫請求(
INSERT,UPDATE,DELETE)自動路由到唯一的主節點。 - 讀負載均衡 : 將讀請求(
SELECT)根據策略分發到多個從節點。 - 故障感知與無縫切換 : 實時監控集群狀態。一旦共識層選舉出新的主節點,代理會立刻感知到變化,并將新的寫請求無縫地轉發到新主庫,對應用層完全透明。
工作流程與高并發應對
正常運行 : Raft 協議確保集群中有且僅有一個主節點。所有寫操作都經過代理,匯集到主節點。主節點通過 半同步復制(Semi-Synchronous Replication) (一種強化的復制模式,要求至少一個從庫確認收到日志后才向客戶端返回成功)將 binlog 同步給從庫,保證了數據的高一致性(CP 傾向)。同時,海量的讀請求被代理分發到所有從庫,實現了讀能力的水平擴展。
主庫故障 :
- 集群內的節點通過心跳檢測發現主庫失聯。
- 共識模塊立即觸發新一輪的 領導者選舉 。
- 存活的從庫節點通過 Raft 協議,在數秒內投票選舉出一個數據最新的從庫成為新主庫。
- 代理層檢測到主庫變更,立即將流量切換到新主庫。整個過程 快速、自動,無需人工干預 ,極大地提升了系統的可用性(Availability)。
應對極致并發寫入:分庫分表(Sharding)
當單一主庫的寫入性能達到瓶頸時,上述架構將作為“一個單元”被水平復制。這就是 分庫分表(Sharding) 。數據被按照某個維度(如用戶 ID、訂單 ID)切分成多個分片(Shard),每個分片都是一個獨立的、由 Raft 管理的高可用 MySQL 集群。代理層(或更上層的服務治理框架)會根據請求中的 Sharding Key,將其路由到對應的集群。通過這種方式,系統的寫入能力可以隨著分片的增加而近乎線性地擴展。
綜上所述,現代 MySQL 高并發架構,正是我們博文中所述理論的完美實踐:
- 它使用 Raft 共識算法 解決了集群的 選主和故障轉移 問題,提供了 CP 級別的元數據一致性保障。
- 它使用 主從復制 作為 數據同步 的手段,并可在 強一致(半同步) 和 高性能(異步) 之間靈活配置。
- 它通過 讀寫分離 和 分庫分表 ,將負載分散到龐大的集群中,實現了強大的 橫向擴展 能力。
這是一個將共識理論、數據復制模型和業務擴展策略有機結合的、優雅而強大的工程范例。
分布式事務解析(一):為何分布式事務不可避免?
當提到微服務、分庫分表等架構時,必然會追問:“那如何處理跨服務/跨庫的數據一致性問題?”這個問題直接引出了分布式事務。我們首先要知道這個問題的根源在何處。
分布式事務并非憑空產生,它幾乎總是我們為了追求 系統可擴展性(Scalability) 而做出的架構決策所帶來的“甜蜜的負擔”。主要源于以下三大經典場景:
場景一:微服務拆分(業務維度)
這是最常見的場景。隨著業務變得復雜,單體應用被拆分成多個獨立的微服務,每個服務都有自己的專屬數據庫。
案例:電商下單
- 業務流程 :用戶下單,需要同時操作 訂單服務 (創建訂單)、 庫存服務 (扣減庫存)和 賬戶服務 (扣減余額)。
- 痛點 :這三個服務部署在不同節點,訪問各自獨立的數據庫。任何一個環節失敗,都必須撤銷已經成功的操作,否則就會出現“創建了訂單但庫存沒扣”或“錢扣了但訂單創建失敗”等嚴重業務錯誤。單個數據庫的本地 ACID 事務在此已無能為力。
- 結論 : 微服務化將單個業務流程內的多個數據操作,從“庫內跨表”變成了“跨庫跨服務” 。為了保證業務流程的原子性,分布式事務成為剛需。
場景二:數據分片/分庫分表(數據維度)
當單一數據庫無法承受海量數據的存儲和訪問壓力時,我們會對其進行水平拆分(Sharding)。
案例:朋友圈/社交轉賬
- 業務流程 :用戶 A 給用戶 B 轉賬。為了存儲海量用戶數據,用戶表和賬戶表早已根據
user_id進行了分片,用戶 A 的數據在 DB1,用戶 B 的數據在 DB2。 - 痛點 :一個簡單的轉賬操作,現在變成了對 DB1 的
UPDATE(A 扣款)和對 DB2 的UPDATE(B 收款)。這實質上已經是一個跨兩個數據庫實例的事務。 - 結論 : 數據分片將原本可能在同一個數據庫內完成的事務,強制拆分成了跨庫事務 。只要業務邏輯需要同時修改落在不同分片上的數據,就必然會遇到分布式事務問題。
場景三:異構系統數據同步
在復雜的系統中,數據往往需要在不同類型的存儲或系統間保持一致。
案例:數據庫與緩存/搜索引擎雙寫
- 業務流程 :用戶修改了商品信息。操作需要 先更新 MySQL 數據庫,然后更新 Redis 緩存和 Elasticsearch 中的商品索引 ,以確保用戶能立即看到并搜到最新的信息。
- 痛點 :如何保證數據庫寫入成功后,緩存和 ES 也一定能更新成功?如果更新緩存或 ES 失敗,就會導致用戶看到舊數據,產生數據不一致。
- 結論 :只要一個業務操作需要原子化地修改多個異構數據源,就產生了分布式事務的需求 。
分布式事務解析(二):解決方案從“剛性”到“柔性”的權衡
分布式事務的解決方案,本質上是在 一致性、性能、可用性和實現復雜度 之間做選擇。我們可以將其劃分為兩大陣營: 剛性事務 和 柔性事務 。
陣營一:剛性事務(追求強一致性)
這類方案嚴格遵循 ACID 原則,特別是原子性(Atomicity)和隔離性(Isolation),試圖在分布式環境下復制單機事務的體驗。
代表:兩階段提交 (2PC) / 三階段提交 (3PC)
- 核心思想 :引入一個“協調者”來統一指揮所有“參與者”,通過“投票(準備)”和“執行(提交/回滾)”兩個階段,強制所有節點達成一致。
- 致命缺陷 : 2PC 的三大問題: 同步阻塞 (資源鎖定時間長,嚴重影響并發性能)、 協調者單點故障(協調者宕機導致整個事務阻塞甚至不一致)、極端情況下的數據不一致 (協調者在第二階段部分通知后宕機)。
三階段提交(3PC)相比 2PC 做了哪些改進?
三階段提交(Three-Phase Commit, 3PC)是在 2PC 的基礎上為了解決 2PC 在協調者故障時導致參與者長時間阻塞 的問題而提出的改進。3PC 通過把“做出提交決定”拆成三個階段,引入一個中間的 預提交(pre-commit / can-commit → pre-commit → do-commit) 階段,目的是讓參與者在協調者失聯時也能做出安全的局部決策,從而盡量避免無限等待。但必須強調:3PC 的安全性依賴于網絡延遲有界(bounded delay)和不存在長期網絡分區的假設——在實際互聯網環境中,這個假設往往不成立,所以 3PC 并沒有像 Raft 那樣在工程實踐中廣泛替代 2PC。
3PC 的三個階段(簡要)
- CanCommit(詢問 / 準備投票) :協調者詢問參與者是否能準備提交(與 2PC 的 Prepare 類似)。參與者檢查本地條件并返回
YES/NO(可以/不可以)。 - PreCommit(預提交 / 鎖定) :如果所有參與者都返回
YES,協調者發送PRE-COMMIT。參與者在收到PRE-COMMIT后進入“已準備但尚未最終提交”的狀態,并在本地做必要的持久化(但仍可安全回滾)。 - DoCommit(最終提交 / 決策) :協調者廣播
DO-COMMIT(或ABORT),參與者據此完成提交或回滾。
3PC 相對 2PC 的改進點 :
- 在協調者在第二階段宕機的情況下,參與者在
PRE-COMMIT狀態下能通過與其他參與者交換信息,確定是否可以安全地自主完成提交,從而 減少長期阻塞的概率 。 - 通過增加一個全局“預提交”步驟,讓參與者在進入真正提交之前把狀態寫到可恢復的持久化日志,這樣在協調者短時失聯時,參與者能依據已知的“預提交”信息做出更安全的判斷。
局限與現實原因(為什么不常用) :
- 3PC 依賴于 網絡延遲有界且不存在持久分區 的假設。在真實分布式系統(互聯網)中,這個假設通常無法保證,因此 3PC 在分區發生時可能導致不安全或仍然阻塞。
- 相比之下,共識算法(Paxos/Raft)以“多數派可決”為核心,不依賴網絡延遲有界的強假設,并且在網絡分區與領導選舉上表現更穩健,所以工程實踐中更傾向于用共識協議配合其他模式解決分布式一致性問題。
總結一句話:3PC 在理論上通過引入“預提交”緩解了 2PC 的一些阻塞場景,但其對網絡假設的依賴使它在不可靠網絡環境中并非通用解,因此在互聯網級別的系統中并未廣泛替代 2PC/被普遍采用。
正因為這些嚴重缺陷,2PC/3PC 這類方案 性能極差、對網絡容錯性低 ,在現代互聯網高并發、高可用的微服務架構中 幾乎不被采用 。它更多是理論基礎和傳統單體應用集成領域的方案(如 XA 規范)。
陣營二:柔性事務(追求最終一致性)
這類方案放寬了對強一致性的要求,不要求數據在事務執行過程中時刻一致,而是承諾在經歷一個短暫的延遲后,數據 最終 會達到一致狀態。這是基于 BASE 理論(Basically Available, Soft state, Eventually consistent) 的設計,是當前互聯網架構的主流選擇。
代表一:TCC (Try-Confirm-Cancel)
核心思想 :將業務邏輯分為三個階段: 資源預留 (Try) 、 確認執行 (Confirm) 、 取消預留 (Cancel) 。
舉一個形象的例子 :比如“預訂機票”。Try 階段是“凍結”機票庫存和用戶資金,但并未實際扣減;Confirm 階段是實際扣減庫存和資金,完成出票;Cancel 階段是釋放被凍結的庫存和資金。
下面給出一個簡化的 TCC 偽代碼示例,演示預留(Try)、確認(Confirm)和取消(Cancel)三個接口的調用關系與冪等性要求。示例以“下單 -> 凍結庫存 -> 凍結余額 -> 確認支付/取消”為場景。
# 服務 A (Order service) 調用示例(偽代碼)
def place_order(user_id, item_id, amount):
# 1. Try 階段:各服務做資源預留
ok1 = inventory.try_reserve(item_id, qty=1, tx_id=tx_id)
ok2 = account.try_freeze(user_id, amount, tx_id=tx_id)
if not (ok1 and ok2):
# 如果任一預留失敗,執行 Cancel
inventory.cancel(item_id, tx_id=tx_id)
account.cancel(user_id, tx_id=tx_id)
return False
# 2. 如果預留都成功,調用 Confirm(通常協調者在確認業務可以完成后觸發)
confirm1 = inventory.confirm(item_id, tx_id=tx_id)
confirm2 = account.confirm(user_id, tx_id=tx_id)
if not (confirm1 and confirm2):
# 若 Confirm 任一失敗,需要調用 Cancel 作為補償(或重試策略)
inventory.cancel(item_id, tx_id=tx_id)
account.cancel(user_id, tx_id=tx_id)
return False
return True
# 單個參與者(庫存服務)內部邏輯示意
class InventoryService:
def try_reserve(self, item_id, qty, tx_id):
# 寫入本地預留表,鎖定庫存(應冪等)
with db.tx() as cur:
if already_tried(tx_id):
return True
if available(item_id) < qty:
return False
insert_reservation(tx_id, item_id, qty)
# 不做最終扣減,只標記已預留
cur.commit()
return True
def confirm(self, item_id, tx_id):
# 將預留轉化為實際扣減:冪等且持久
with db.tx() as cur:
if already_confirmed(tx_id):
return True
apply_deduct(item_id, get_reserved_qty(tx_id))
mark_confirmed(tx_id)
cur.commit()
return True
def cancel(self, item_id, tx_id):
# 釋放預留:冪等
with db.tx() as cur:
if already_cancelled_or_confirmed(tx_id):
return True
release_reservation(tx_id)
mark_cancelled(tx_id)
cur.commit()
return True要點提醒:
- 每個接口必須 冪等 ,以應對重試和網絡不確定性。
- Try 階段應盡量只做 資源預留 (輕量),避免長時間持有不可釋放的鎖,否則影響并發。
- Confirm/Cancel 必須能根據
tx_id判斷狀態并安全執行。
優點 :一致性很高,因為它在業務提交前已經確保所有資源都可用,可以實現“準強一致性”。回滾(Cancel)邏輯通常很清晰。
缺點 : 對業務代碼侵入性極強 。你需要為每個TCC服務都編寫 Try, Confirm, Cancel 三個接口,開發和維護成本高。
代表二:SAGA 模式
核心思想 :將一個長事務拆分為多個 本地事務 的序列,每個本地事務都有一個對應的 補償事務 。如果正向流程中的任何一步失敗,系統會反向調用前面已成功步驟的補償事務,以達到回滾的目的。
對比 TCC :SAGA 沒有“預留”階段,是“先斬后奏”。TCC 是預留-執行,SAGA 是執行-補償。
協調方式 :兩種協調方式。
- 編排式(Orchestration) :由一個中央協調器(Saga Coordinator)統一調度。
- 協同式(Choreography) :各個服務通過訂閱/發布事件(通常借助消息隊列)來驅動流程。
編排式 SAGA(Centralized Orchestrator)
# Saga 協調器(中央編排)
def saga_place_order(order_id, user_id, item_id, amount):
try:
# Step 1: 創建訂單(本地事務)
svc_order.create_order(order_id, user_id, item_id)
# Step 2: 調用庫存服務
svc_inventory.decrease(item_id, qty=1) # 本地事務
# Step 3: 調用支付服務
svc_payment.charge(user_id, amount) # 本地事務
# Step 4: 發貨
svc_shipping.ship(order_id, item_id) # 本地事務
# 全部成功,Saga 結束
return True
except Exception as e:
# 出現任何錯誤,按已完成步驟依次執行補償
# 注意:補償順序通常是反序
try: svc_shipping.cancel_ship(order_id)
except: pass
try: svc_payment.refund(user_id, amount)
except: pass
try: svc_inventory.increase(item_id, qty=1)
except: pass
try: svc_order.cancel_order(order_id)
except: pass
return False協同式 SAGA(Event-driven Choreography)
# 服務間通過事件驅動,每個服務在處理完自己的本地事務后發布事件
# 示例:Order Service 創建訂單后,發布 OrderCreated 事件
# Order Service
def create_order(order_id, user_id, item_id):
with db.tx():
insert_order(order_id, user_id, item_id, status='CREATED')
publish_event('OrderCreated', {'order_id': order_id, 'item_id': item_id})
# Inventory Service (訂閱 OrderCreated)
def on_order_created(event):
try:
with db.tx():
decrease_stock(event.item_id, 1)
publish_event('InventoryReserved', {'order_id': event.order_id})
except:
publish_event('InventoryFailed', {'order_id': event.order_id})
# Payment Service (訂閱 InventoryReserved)
def on_inventory_reserved(event):
try:
with db.tx():
charge_user(order.user_id, amount)
publish_event('PaymentSucceeded', {'order_id': event.order_id})
except:
publish_event('PaymentFailed', {'order_id': event.order_id})
# 下游服務根據成功/失敗事件決定下一步或觸發補償
# 例如:如果 PaymentFailed,發布 PaymentFailed 事件,Order Service 或其它服務會收到并觸發 refund/rollback要點說明:
- 編排式 可控性更強,易于監控與補償(中央協調器負責全局狀態),但集中化會增加單點邏輯復雜度。
- 協同式 更松耦合、彈性好,但容易變得分散且難以跟蹤全局狀態,補償和異常處理需要更復雜的事件治理與冪等設計。
- 無論哪種方式, 補償事務必須設計為冪等 ,并且要能部分或逐步恢復系統一致性。
SAGA 優點 :耦合度低,實現相對 TCC 簡單(只需關心補償邏輯),性能好,特別適合流程長、需要異步執行的業務。
SAGA 缺點 : 不保證隔離性 。在事務提交到一半時,其他請求可能會讀到中間狀態(例如,訂單已創建,但庫存還未扣減)。補償邏輯的設計可能非常復雜。
代表三:基于可靠消息的最終一致性 (Transactional Outbox)
核心思想 :這是目前應用最廣泛的方案之一。其精髓在于 利用本地事務的原子性,來保證“業務操作”和“發送消息”這兩個動作的原子性 。
流程 :
- 開啟 數據庫本地事務 。
- 執行業務操作(如扣庫存)。
- 將要發送的消息(如“庫存已扣減”)寫入到 同一個數據庫 的一張“本地消息表”中。
- 提交本地事務 。此時,業務數據和消息數據要么都成功,要么都失敗。
- 一個獨立的后臺任務/服務,輪詢該消息表,將消息投遞到真正的消息隊列(MQ)中。
- 下游服務(如訂單服務)消費 MQ 中的消息,執行自己的本地事務。
Transactional Outbox 的核心在于: 把要發送的事件/消息先寫入與業務數據相同的數據庫事務中(outbox 表) ,保證寫業務數據和寫 outbox 的原子性;隨后由獨立的投遞器將 outbox 的消息可靠地投遞給消息隊列(MQ),并將其標記為已發送。
# 生產者:在同一個本地事務中寫業務數據和 outbox
def deduct_inventory_and_emit_event(item_id, qty):
with db.tx() as cur:
# 1. 本地業務修改
update_inventory(item_id, -qty)
# 2. 寫入 outbox 表(消息尚未發送)
outbox_msg = {
'id': gen_uuid(),
'topic': 'inventory.deducted',
'payload': json.dumps({'item_id': item_id, 'qty': qty}),
'status': 'PENDING',
'created_at': now()
}
insert_outbox(outbox_msg)
cur.commit()
# 投遞器(異步后臺任務)
def outbox_dispatcher_loop():
while True:
msgs = select_pending_outbox(limit=100)
for msg in msgs:
try:
mq.publish(msg.topic, msg.payload) # 將消息發到 MQ(需保證冪等/至少一次)
mark_outbox_sent(msg.id, sent_at=now())
except Exception as e:
# 發布失敗:記錄日志,稍后重試(冪等性在消費端保證)
log.error("publish failed", e)
sleep(poll_interval)消費端需保證 冪等性 以應對消息可能的重復投遞。優點是實現簡單、與現有關系型數據庫天然集成,且能利用數據庫事務保證本地原子性;缺點是需要一個額外的投遞進程和 outbox 表的運維,且消息發出存在短時延遲(取決于投遞器的輪詢頻率或觸發策略)。
優點 :與業務邏輯解耦,實現相對簡單(依賴數據庫和 MQ),性能好,可靠性高。
缺點 :一致性有時效性延遲;需要額外維護消息表和輪詢服務;下游服務需要保證消費的冪等性。
為了簡化本地消息表的模式,像 RocketMQ 這樣的消息中間件提供了內置的 事務消息 功能,它通過 半消息 和 事務回查 機制,在 MQ 層面實現了業務操作與消息發送的原子性,開發者只需關注業務邏輯和回查接口的實現。
分布式事務解析(三):如何在具體場景中做技術選型?
比如:“如果要你設計一個電商平臺的下單到支付流程,你會選擇哪種分布式事務方案?”
分析業務場景,明確一致性要求
下單扣庫存 :這個環節,用戶對延遲不敏感,但庫存數據必須準確。這是一個典型的異步、長流程場景。
支付扣款 :這個環節涉及到真實的資金,對一致性要求極高。用戶不能容忍錢扣了但訂單狀態沒更新,或者訂單顯示支付成功但錢沒扣。
方案選型與組合
一個復雜的業務流程,通常不是單一方案能搞定的,而是 組合拳 。
對于“下單 -> 扣庫存 -> 通知發貨”這類非核心資金鏈路
- 首選:基于可靠消息的最終一致性 或 SAGA 模式。
- 理由 :將“創建訂單”作為核心的上游本地事務。訂單創建成功后,通過“本地消息表”模式,可靠地向 MQ 發送一個
OrderCreated事件。下游的“庫存服務”、“物流服務”、“積分服務”等各自訂閱這個消息,并執行自己的業務邏輯。這種方式 松耦合 ,允許各服務異步執行, 系統吞吐量高 。即使某個下游服務暫時失敗,重試機制也能保證數據 最終一致 ,完全滿足這類場景的需求。
對于“支付 -> 更新訂單狀態”這類核心資金鏈路
- 首選:TCC 模式 ,或者通過巧妙的業務設計規避。
- 理由 :支付環節,優先考慮 TCC。當用戶點擊支付時,支付網關會調用我們的支付服務。
- Try 階段 :支付服務調用訂單服務,嘗試將訂單狀態置為“支付中”(預留狀態),并調用賬戶服務凍結用戶余額。
- Confirm 階段 :如果支付渠道返回成功,則調用訂單服務的 Confirm 接口,將狀態改為“已支付”,并調用賬戶服務真正扣款。
- Cancel 階段 :如果支付失敗或超時,則調用 Cancel 接口,將訂單狀態回滾,并解凍用戶余額。
TCC 方案雖然開發復雜,但它能提供 準強一致性 的保障,防止資金類業務出現差錯,這是業務的底線。
如下是更貼近支付場景的具體偽代碼,展示 Try/Confirm/Cancel 的典型調用順序與冪等性保障。
# 支付協調器(偽代碼)
def process_payment(order_id, user_id, amount):
tx_id = f"pay-{order_id}-{now_ts()}"
# 1. Try 階段:訂單服務和賬戶服務進行預留/凍結
ok_order = order_service.try_mark_paying(order_id, tx_id) # 將訂單置為 PAYING(預留)
ok_account = account_service.try_freeze(user_id, amount, tx_id)
if not (ok_order and ok_account):
# 如果任一 Try 失敗,調用 Cancel
order_service.cancel_mark_paying(order_id, tx_id)
account_service.cancel_freeze(user_id, tx_id)
return False
# 2. 調用第三方支付渠道(同步或異步)
success = payment_gateway.charge(user_id, amount)
# 3. 根據渠道結果調用 Confirm 或 Cancel
if success:
order_service.confirm_paid(order_id, tx_id)
account_service.confirm_charge(user_id, tx_id)
return True
else:
order_service.cancel_mark_paying(order_id, tx_id)
account_service.cancel_freeze(user_id, tx_id)
return False
# 賬戶服務內部(示意)
class AccountService:
def try_freeze(self, user_id, amount, tx_id):
with db.tx():
if already_tried(tx_id): return True
if get_balance(user_id) < amount: return False
insert_freeze_record(tx_id, user_id, amount)
# 不實際扣款,只凍結
cur.commit()
return True
def confirm_charge(self, user_id, tx_id):
with db.tx():
if already_confirmed(tx_id): return True
deduct_balance(user_id, get_freeze_amount(tx_id))
mark_confirmed(tx_id)
cur.commit()
return True
def cancel_freeze(self, user_id, tx_id):
with db.tx():
if already_cancelled_or_confirmed(tx_id): return True
remove_freeze_record(tx_id)
mark_cancelled(tx_id)
cur.commit()
return True要點回顧:
- 支付相關的 Try 必須保證對外部渠道調用之前,資金/狀態已被“鎖住”或標記,否則會產生競態。
- Confirm/Cancel 都必須是冪等的,以便在網絡或重試下不會重復扣款或錯誤釋放資金。
- 實際工程中,外部支付渠道通常是異步回調,協調器需要設計回調處理邏輯,并把回調與原始
tx_id關聯起來以完成 Confirm/Cancel。
對 2PC 的態度 :在這個場景中,一般不會考慮使用 2PC。因為它對性能的損耗和對可用性的影響,對于面向海量用戶的互聯網應用是不可接受的。
結語
分布式數據庫的世界,本質上是一個充滿權衡的藝術。
- 為了實現 分布式原子性 ,我們從有阻塞風險的 2PC 演進到了基于多數共識的 Paxos/Raft 。
- 為了實現 高可用和數據安全 ,我們采用 主從復制 ,并在 同步(強一致) 與 異步(最終一致) 之間權衡。
- 而這一切頂層設計的指導原則,都落在了 CAP 定理 上——在網絡分區不可避免的前提下,我們究竟是選擇數據的絕對正確(CP),還是選擇服務的永不中斷(AP)。
理解這些核心概念與它們背后的取舍,不僅能幫助我們更好地選擇和使用數據庫,更能讓我們在設計任何分布式系統時,都能做出更明智、更貼合業務需求的決策。





































