徹底掌握分布式事務2PC、3PC模型
本文轉載自微信公眾號「源碼興趣圈」,作者馬龍臺。轉載本文請聯系源碼興趣圈公眾號。
工作中使用最多的是本地事務,但是在對單一項目拆分為 SOA、微服務之后,就會牽扯出分布式事務場景
文章以分布式事務為主線展開說明,并且針對 2PC、3PC 算法進行詳細的講解,最后通過一個 Demo 來更深入掌握分布式事務,文章目錄結構如下
- 什么是事務
- 什么是分布式事務
- DTP 模型和 XA 規范
- 什么是 DTP 模型
- 什么是 XA 規范
- 2PC 一致性算法
- 2PC-準備階段
- 2PC-提交階段
- 2PC 算法優缺點
- 3PC 一致性算法
- JDBC 操作 MySQL XA 事務
- 結言
什么是事務
事務是數據庫操作的最小工作單元,一組不可再分割的操作集合,是作為單個邏輯工作單元執行的一系列操作。這些操作作為一個整體一起向系統提交,要么都執行、要么都不執行
事務具有四個特征,分別是原子性(Atomicity)、一致性(Consistency)、隔離性(Isolation)和持久性(Durability),簡稱為事務的 ACID 特性
如何保證事務的 ACID 特性?
- 原子性(Atomicity):事務內 SQL 要么同時成功要么同時失敗,基于撤銷日志(undo 日志)實現
- 一致性(Consistency):系統從一個正確態轉移到另一個正確態,由應用通過 AID 來保證,可以說是事務的核心特性
- 隔離性(Isolation):控制事務并發執行時數據的可見性,基于鎖和多版本并發控制(mvcc)實現
- 持久性(Durability):提交后一定存儲成功不會丟失,基于重做日志(redo log)實現
文章主要是介紹分布式事務 2PC 和 3PC,關于 redo、undo 日志、mvcc、鎖這塊的內容后續再詳細介紹
在早些時候,我們應用程序還是單體項目,所以操作的都是單一數據庫,這種情況下我們稱之為本地事務。本地事務的 ACID 一般都是由數據庫層面支持的,比如我們工作中常用的 MySQL 數據庫
平常我們在操作 MySQL 客戶端時,MySQL 會隱式對事務做自動提交,所以日常工作不會涉及手動書寫事務的創建、提交、回滾等操作。如果想要試驗鎖、MVCC等特性,可以創建多個會話,通過begin、commit、rollback等命令來試驗下不同事務之間的數據,看執行結果和自己所想是否一致
我們平常開發項目代碼時使用的是 Spring 封裝好的事務,所以也不會手動編寫對數據庫事務的提交、回滾等方法(個別情況除外)。這里使用原生 JDBC 寫一個示例代碼,幫助大家理解如何通過事務保證 ACID 四大特性
- Connection conn = ...; // 獲取數據庫連接
- conn.setAutoCommit(false); // 開啟事務
- try {
- // ...執行增刪改查sql
- conn.commit(); // 提交事務
- } catch (Exception e) {
- conn.rollback(); // 事務回滾
- } finally {
- conn.close(); // 關閉鏈接
- }
設想一下,每次進行數據庫操作,都要寫重復的創建事務、提交、回滾等方法是不是挺痛苦的,那 Spring 如何自動幫助我們管理事務的呢?Spring 項目中我們一般使用兩種方式來進行事務的管理,編程式事務和聲明式事務
項目中使用 Spring 管理事務,要么在接口方法上添加注解 @Transactional,要么使用 AOP 配置切面事務。其實這兩種方式大同小異,只不過 @Transactional 的粒度更細一些,實現原理上都是依賴 AOP,舉例說明下
- @Service
- public class TransactionalService {
- @Transactional
- public void save() {
- // 業務操作
- }
- }
TransactionalService 會被 Spring 創建一個代理對象放入到容器中,創建后的代理對象相當于下述類
- public class TransactionalServiceProxy {
- private TransactionalService transactionalService;
- public TransactionalServiceProxy(TransactionalService transactionalService) {
- this.transactionalService = transactionalService;
- }
- public void save() {
- try {
- // 開啟事務操作
- transactionalService.save();
- } catch (Exception e) {
- // 出現異常則進行回滾
- }
- // 提交事務
- }
- }
示例代碼看著簡潔明了,但是真正的代碼生成代碼對比要復雜很多。關于事務管理器,Spring 提供了接口 PlatformTransactionManager,其內部包含兩個重要實現類
- DataSourceTransactionManager:支持本地事務,內部通過java.sql.Connection來開啟、提交和回滾事務
- JtaTransactionManager:用于支持分布式事務,其實現了 JTA 規范,使用 XA 協議進行兩階段提交
通過這兩個實現類得知,平常我們使用的編程式事務和聲明式事務依賴于本地事務管理實現,Spring 同時也支持分布式事務,關于 JTA 分布式事務的支持網上資料挺多的,就不在這里贅述了
什么是分布式事務
日常業務代碼中的本地事務我們一直都在用,理解起來并不困難。但是隨著服務化(SOA)、微服務的流行,平常我們的單一業務系統被拆分成為了多個系統,為了迎合業務系統的變更,數據庫也結合業務進行了拆分
比如以學校管理系統舉例說明,可能就會拆分為學生服務、課程服務、老師服務等,數據庫也拆分為多個庫。當這種情況,把不同的服務部署到服務器,就會有可能面臨下述的服務調用
ServiceA 服務需要操作數據庫執行本地事務,同時需要調用 ServiceB 和 ServiceC 服務發起事務調用,如何保證三個服務的事務要么一起成功或者一起失敗,如何保證用戶發起請求的事務 ACID 特性呢?無疑這就是分布式事務場景,三個服務的單一本地事務都無法保證整個請求的事務
分布式事務場景有很多種解決方案,以不同分類來看,強一致性解決方案、最終一致性解決方案,細分其中的方案包括2PC、3PC、TCC、可靠消息...
業界中使用較多的像阿里的 RocketMQ 事務消息、Seata XA模式、可靠消息模型這幾種解決方案。不過,分布式事務無一例外都是會直接或間接操作多個數據庫,而且使用了分布式事務同時也會帶來新的挑戰,那就是性能問題。如果為了保證強一致性分布式事務亦或者補償方案的最終一致性,導致了性能的下降,對于正常業務而言,無疑是得不償失的
DTP 模型和 XA 規范
X/Open 組織定義了分布式事務的模型(DTP)和 分布式事務協議(XA),DTP 由以下幾個模型元素組成
- AP(Application 應用程序):用于定義事務邊界(即定義事務的開始和結束),并且在事務邊界內對資源進行操作
- TM(Transaction Manager 事務管理器):負責分配事務唯一標識,監控事務的執行進度,并負責事務的提交、回滾等
- RM(Resource Manager 資源管理器):如數據庫、文件系統等,并提供訪問資源的方式
- CRM(Communication Resource Manager 通信資源管理器):控制一個TM域(TM domain)內或者跨TM域的分布式應用之間的通信
- CP(Communication Protocol 通信協議):提供CRM提供的分布式應用節點之間的底層通信服務
在 DTP 分布式事務模型中,基本組成需要涵蓋 AP、TM、RMS(不需要 CRM、CP 也是可以的),如下圖所示
XA 規范
XA 規范最重要的作用就是定義 RM(資源管理器)與 TM(事務管理器)之間的交互接口。另外,XA 規范除了定義 2PC 之間的交互接口外,同時對 2PC 進行了優化
梳理下 DTP、XA、2PC 之間的關系
DTP 規定了分布式事務中的角色模型,并在其中指定了全局事務的控制需要使用 2PC 協議來保證數據的一致性
2PC 是 Two-Phase Commit 的縮寫,即二階段提交,是計算機網絡尤其是數據庫領域內,為了保證分布式系統架構下所有節點在進行事務處理過程中能夠保證原子性和一致性而設計的一種算法。同時,2PC 也被認為是一種一致性協議,用來保證分布式系統數據的一致性
XA 規范是 X/Open 組織提出的分布式事務處理規范,XA 規范定義了 2PC(兩階段提交協議)中需要用到的接口,也就是上圖中 RM 和 TM 之間的交互。2PC 和 XA 兩者最容易混淆,可以這么理解,DTP 模型定義 TM 和 RM 之間通訊的接口規范叫 XA,然后 關系數據庫(比如MySQL)基于 X/Open 提出的的 XA 規范(核心依賴于 2PC 算法)被稱為 XA 方案
2PC 一致性算法
當應用程序(AP)發起一個事務操作需要跨越多個分布式節點的時候,每一個分布式節點(RM)知道自己進行事務操作的結果是成功或是失敗,但是卻不能獲取到其它分布式節點的操作結果。為了保證事務處理的 ACID 特性,就需要引入稱為"協調者"的組件(TM)來進行統一調度分布式的執行邏輯
協調者負責調度參與整體事務的分布式節點的行為,并最終決定這些分布式節點要把事務進行提交還是回滾。所以,基于這種思想下,衍生出了二階段提交和三階段提交兩種分布式一致性算法協議。二階段指的是準備階段和提交階段,下面我們先看準備階段都做了什么事情
2PC-準備階段
二階段提交中第一階段也叫做"投票階段",即各參與者投票表明自身是否繼續執行接下來的事務提交步驟
- 事務詢問:協調者向所有參與本次分布式事務的參與者發送事務內容,詢問是否可以執行事務提交操作,然后開始等待各個參與者的響應
- 執行事務:參與者收到協調者的事務請求,執行對應的事務,并將內容寫入 Undo 和 Redo 日志
- 返回響應:如果各個參與者執行了事務,那么反饋協調者 Yes 響應;如果各個參與者沒有能夠成功執行事務,那么就會返回協調者 No 響應
如果第一階段全部參與者返回成功響應,那么進入事務提交步驟,反之本次分布式事務以失敗返回。以 MySQL 數據庫為例,在第一階段,事務管理器(TM)向所有涉及到的數據庫(RM)發出 prepare(準備提交) 請求,數據庫收到請求后執行數據修改和日志記錄處理,處理完成后把事務的狀態修改為 "可提交",最終將結果返回給事務處理器
2PC-提交階段
提交階段分為兩個流程,一個是各參與者正常執行事務提交流程,并返回 Yes 響應,表示各參與者投票執行成功;一個是各參與者當中有執行失敗返回 No 響應或超時情況,將觸發全局回滾,表示分布式事務執行失敗
- 執行事務提交
- 中斷事務
執行事務提交
假設協調者從所有的參與者獲得的反饋都是 Yes 響應,那么就會執行事務提交操作
- 事務提交:協調者向所有參與者節點發出 Commit 請求,各個參與者接收到 Commit 請求后,將本地事務進行提交操作,并在完成提交之后釋放事務執行周期內占用的事務資源
- 完成事務:各個參與者完成事務提交之后,向協調者發送 Ack 響應,協調者接收到響應后完成本次分布式事務
中斷事務
假設任意一個事務參與者節點向協調者反饋了 No 響應(注意這里的 No 響應指的是第一階段),或者在等待超時之后,協調者沒有接到所有參與者的反饋響應,那么就會進行事務中斷流程
- 事務回滾:協調者向所有參與者發出 Rollback 請求,參與者接收到回滾請求后,使用第一階段寫入的 undo log 執行事務的回滾,并在完成回滾事務之后釋放占用的資源
- 中斷事務:參與者在完成事務回滾之后,向協調者發送 Ack 消息,協調者接收到事務參與者的 Ack 消息之后,完成事務中斷
2PC 優缺點
- 2PC 提交將事務的處理過程分為了投票和執行兩個階段,核心思想就是對每個事務都采用先嘗試后提交的方式處理。2PC 優點顯而易見,那就是 原理簡單,實現方便。簡單也意味著很多地方不能盡善盡美,這里梳理三個比較核心的缺陷
- 同步阻塞:無論是在第一階段的過程中,還是在第二階段,所有的參與者資源和協調者資源都是被鎖住的,只有當所有節點準備完畢,事務協調者才會通知進行全局提交,參與者進行本地事務提交后才會釋放資源。這樣的過程會比較漫長,對性能影響比較大
- 單點故障:如果協調者出現問題,那么整個二階段提交流程將無法運轉。另外,如果協調者是在第二階段出現了故障,那么其它參與者將會處于鎖定事務資源的狀態中
數據不一致性:當協調者在第二階段向所有參與者發送 Commit 請求后,發生了局部網絡異常或者協調者在尚未發送完 Commit 請求之前自身發生了崩潰,導致只有部分參與者接收到 Commit 請求,那么接收到的參與者就會進行提交事務,進而形成了數據不一致性
由于 2PC 的簡單方便,所以會產生上述的同步阻塞、單點故障、數據不一致等情況,所以在 2PC 的基礎上做了改進,推行出了三階段提交(3PC)
使用 2PC 存在諸多限制,首先就是數據庫需要支持 XA 規范,而且性能與數據一致性數據均不友好,所以 Seata 中雖然支持 XA 模式,但是主推的還是 AT 模式
3PC 一致性算法
三階段提交(3PC)是二階段提交(2PC)的一個改良版本,引入了兩個新的特性
- 協調者和參與者均引入超時機制,通過超時機制來解決 2PC 的同步阻塞問題,避免事務資源被永久鎖定
- 把二階段演變為三階段,二階段提交協議中的第一階段"準備階段"一分為二,形成了新的 CanCommit、PreCommit、do Commit 三個階段組成事務處理協議
這里將不再贅述 3PC 的詳細提交過程,3PC 相比較于 2PC 最大的優點就是降低了參與者的阻塞范圍,并且能夠在協調者出現單點故障后繼續達成一致
雖然通過超時機制解決了資源永久阻塞的問題,但是 3PC 依然存在數據不一致的問題。當參與者接收到 PreCommit 消息后,如果網絡出現分區,此時協調者與參與者無法進行正常通信,這種情況下,參與者依然會進行事務的提交
通過了解 2PC 和 3PC 之后,我們可以知道這兩者都無法徹底解決分布式下的數據一致性
JDBC 操作 MySQL XA 事務
MySQL 從 5.0.3 開始支持 XA 分布式事務,且只有 InnoDB 存儲引擎支持。MySQL Connector/J 從5.0.0 版本之后開始直接提供對 XA 的支持
在 DTP 模型中,MySQL 屬于 RM 資源管理器,所以這里就不再演示 MySQL 支持 XA 事務的語句,因為它執行的只是自己單一事務分支,我們通過 JDBC 來演示如何通過 TM 來控制多個 RM 完成 2PC 分布式事務
這里先來說明需要引入 GAV 的 Maven 版本,因為高版本 8.x 移除了對 XA 分布式事務的支持(可能也是覺得沒人會用吧)
- <dependencies>
- <!-- https://mvnrepository.com/artifact/mysql/mysql-connector-java -->
- <dependency>
- <groupId>mysql</groupId>
- <artifactId>mysql-connector-java</artifactId>
- <version>5.1.38</version>
- </dependency>
- </dependencies>
這里為了保證在公眾號閱讀的舒適性,通過 IDEA 將多行代碼合并為一行了,如果小伙伴需要粘貼到 IDEA 中,格式化一下就好了
因為 XA 協議的基礎是 2PC 一致性算法,所以小伙伴在看代碼時可以對照上面文章講的 DTP 模型和 2PC 來進行理解以及模擬錯誤和執行結果
- import com.mysql.jdbc.jdbc2.optional.MysqlXAConnection;import com.mysql.jdbc.jdbc2.optional.MysqlXid;import javax.sql.XAConnection;import javax.transaction.xa.XAException;import javax.transaction.xa.XAResource;import javax.transaction.xa.Xid;import java.sql.*;
- public class MysqlXAConnectionTest {
- public static void main(String[] args) throws SQLException {
- // true 表示打印 XA 語句, 用于調試
- boolean logXaCommands = true;
- // 獲得資源管理器操作接口實例 RM1
- Connection conn1 = DriverManager.getConnection("jdbc:mysql://localhost:3306/test", "root", "root");XAConnection xaConn1 = new MysqlXAConnection((com.mysql.jdbc.Connection) conn1, logXaCommands);XAResource rm1 = xaConn1.getXAResource();
- // 獲得資源管理器操作接口實例 RM2
- Connection conn2 = DriverManager.getConnection("jdbc:mysql://localhost:3306/test", "root", "root");XAConnection xaConn2 = new MysqlXAConnection((com.mysql.jdbc.Connection) conn2, logXaCommands);XAResource rm2 = xaConn2.getXAResource();
- // AP(應用程序)請求 TM(事務管理器) 執行一個分布式事務, TM 生成全局事務 ID
- byte[] gtrid = "distributed_transaction_id_1".getBytes();int formatId = 1;
- try {
- // ============== 分別執行 RM1 和 RM2 上的事務分支 ====================
- // TM 生成 RM1 上的事務分支 ID
- byte[] bqual1 = "transaction_001".getBytes();Xid xid1 = new MysqlXid(gtrid, bqual1, formatId);
- // 執行 RM1 上的事務分支
- rm1.start(xid1, XAResource.TMNOFLAGS);PreparedStatement ps1 = conn1.prepareStatement("INSERT into user(name) VALUES ('jack')");ps1.execute();rm1.end(xid1, XAResource.TMSUCCESS);
- // TM 生成 RM2 上的事務分支 ID
- byte[] bqual2 = "transaction_002".getBytes();Xid xid2 = new MysqlXid(gtrid, bqual2, formatId);
- // 執行 RM2 上的事務分支
- rm2.start(xid2, XAResource.TMNOFLAGS);PreparedStatement ps2 = conn2.prepareStatement("INSERT into user(name) VALUES ('rose')");ps2.execute();rm2.end(xid2, XAResource.TMSUCCESS);
- // =================== 兩階段提交 ================================
- // phase1: 詢問所有的RM 準備提交事務分支
- int rm1_prepare = rm1.prepare(xid1);int rm2_prepare = rm2.prepare(xid2);
- // phase2: 提交所有事務分支
- if (rm1_prepare == XAResource.XA_OK && rm2_prepare == XAResource.XA_OK) {
- // 所有事務分支都 prepare 成功, 提交所有事務分支
- rm1.commit(xid1, false);rm2.commit(xid2, false);
- } else {
- // 如果有事務分支沒有成功, 則回滾
- rm1.rollback(xid1);rm1.rollback(xid2);
- }
- } catch (XAException e) { e.printStackTrace(); } }}
結言
本文通過圖文并茂的方式講解了如何保證本地事務的四大特性,分布式事務的產出背景,以及 2PC、3PC 為何不能解決分布式情況下的數據一致性,最后通過 JDBC 演示了 2PC 的執行流程。相信大家看過后也對分布式事務有了較深的印象,同時對 DTP、XA、2PC 這幾種比較容易混淆的概念有了清楚的認識。
這是《分布式事務》專欄的第一章開篇,后面陸續完成通過消息中間件、可靠消息模型、Seata XA模型完成分布式事務的文章,并對不同的實現方式進行總結利弊,挑選出合適場景使用不同的分布式事務解決方案。
作者認為最好的學習方式那就是實戰,如果沒有接觸過分布式事務的小伙伴,可以通過自己正在寫的項目,模擬出分布式事務的業務場景,加深印象的同時也能夠更好理解分布式事務解決方案相關設計思路。