1. 程式人生 > >分散式事物一致性設計思路

分散式事物一致性設計思路

 

本地事務ACID大家應該都知道了,統一提交,失敗回滾,嚴格保證了同一事務內資料的一致性!而分散式事務不能實現這種ACID,它只能實現CAP原則裡的某兩個,CAP也是分散式事務的一個廣泛被應用的原型,CAP(Consistency, Availability, Partition Tolerance), 闡述了一個分散式系統的三個主要方面, 只能同時擇其二進行實現. 常見的有CP系統, AP系統。

關於分佈最終一致性保證始終是分散式框架要考慮的問題。

分散式事物目前解決方案有三種,比較著名的有基於XA協議的方案、TCC方案、訊息最終一致性方案。

2.1基於XA協議的方案

該方案最早由oracle提出用於解決跨資料訪問的事務問題,是一種強一致性的解決方案,由事務協調器和本地資源管理器共同完成。事務協調器和資源管理器間通過XA協議進行通訊。XA協議實現的原理如下圖所示,共分為兩個階段,也就是我們常說的兩階段協議。

兩階段方案在解決資料庫分散式事務問題方面應用非常廣泛,oracle、Mysql等主流關係資料庫均支援XA協議,而且ocenbase、DCDB等著名的分散式資料庫也都基於兩階段協議。在解決服務事務問題上,其實 XA協議不是隻能作用於單個服務內部的多資源場景,跨服務的多資源場景也是可以的,只不過需要額外的事務傳遞機制。但其都有致命的缺點,效能不理想。由於需要等到各分支事務都就緒後全域性事務才開始提交,所以每個事務鎖定資料的時間較長,XA方案因此很難滿足高併發場景。而且在解決微服務問題時XA方案的效能問題將會被放大。因為應用在訪問服務的呼叫方式、網路環境等要比訪問資料庫複雜的多。例如,應用和其訪問的資料庫通常在一個區域網中,而其通過rpc呼叫的服務則可能屬於另一個網路或者在公網上,其時延更長、出故障的概率更高。這將導致資料鎖定時間和系統併發度進一步降低。所以XA方案基本不適合解決微服務的事務問題。

2.2TCC方案

TCC方案應用是目前呼聲最高,也是落地最多的一個方案。當前也有一些開源的TCC框架實現,如TCC-TransactionByteTCC。TCC方案其實是兩階段方案的一種改進,其將本地資源管理器的功能融入到了業務實現中。其將整個業務邏輯顯示的分成了Try、Confirm、Cancel三部分。try部分完成業務的準備工作,confirm部分完成業務的提交,cancel部分完成事務的回滾。基本原理如下圖所示。

事務開始時,業務應用會向事務協調器註冊啟動事務。之後業務應用會呼叫所有服務的try介面,相當於XA的第一階段。如果有任何一個服務的try介面呼叫失敗會向事務協調器傳送事務回滾請求,否則傳送事務提交請求。事務協調器收到事務回滾請求後會依次呼叫事務的confirm介面,否則呼叫cancel介面回滾,這相當於XA的第二階段。如果第二階段介面呼叫失敗,會進行重試。

TCC方案通過通過三個介面很好的規避了長時間資料加鎖的問題,業務表在每個介面呼叫完畢即可釋放,這很大程度上提高了業務的併發度,這也是TCC方案最大的優勢。所以在SOA時期,TCC方案被很多金融、電商的業務系統大量使用。
當然TCC方案也有不足之處,集中表現在以下兩個方面:

  • 開發工作量大。它將部分資源管理器的功能融入到每個服務的開發中,導致服務的每個介面都需要實現try、confirm、cancle,還需要實現事務協調器,開發量不只翻了一倍。
  • 實現難度大。系統需要記錄每個應用的服務呼叫鏈路。我前面講過rpc呼叫情況比較複雜,由於網路狀況、系統故障等呼叫失敗被視為常態,必須按照不同的失敗原因實現不同策略的回滾。為了滿足一致性的要求,二階段不管呼叫confirm還是cancle都必須呼叫成功,如果一次呼叫不成功,事務協調器必須嘗試重試。這就要求confirm和cancle介面必須實現冪等。

上述原因導致TCC方案大多是被研發實力較強、有迫切需求的大公司所採用。其將分散式事務變成一種所謂的“貴族技術”,中小型企業由於人員有限、技術實力薄弱,很難落地。而且筆者認為微服務倡導的是服務的輕量化、易部署,而TCC方案將很多事務的處理功能融入到業務中,對業務侵入性太高,導致服務邏輯複雜,比較適合比較重的服務。

2.3 訊息事務一致性方案

訊息一致性方案是通過訊息中介軟體保證上、下游應用資料操作的一致性。基本思路是將本地操作和傳送訊息放在一個事務中,保證本地操作和訊息傳送要麼兩者都成功或者都失敗。下游應用向訊息系統訂閱該訊息,收到訊息後執行相應操作。
以下單業務為例進行說明,下單基本流程是先儲存訂單資訊,然後扣相應商品的庫存,兩個操作必須在一個事務中。如下圖,業務應用首先呼叫訂單服務,訂單儲存成功後,訂單服務會通過訊息處理服務投遞訂單訊息到MQ。庫存服務從MQ收到訊息後進行扣庫存操作,如果執行成功會向訊息處理服務傳送通知。訊息處理服務會實時監測訂單訊息是否超時,如果超時會重新投遞到MQ中,以驅動庫存服務進行扣庫存操作。如果扣庫存操作執行失敗後,庫存服務後續還會從MQ接收到相同的訂單訊息,需要多次重複執行,直到成功或者進行人工干預。庫存服務需要實現冪等。 

訊息方案從本質上講是將分散式事務轉換為兩個本地事務,然後依靠下游業務的重試機制達到最終一致性。相對TCC方案來講,訊息方案技術難度相對低,落地較容易,如果對一致性不敏感的應用也是一個不錯的選擇。美國著名電商e-bay以及國內的蘑菇街都做過嘗試。訊息一致性方案的不足之處是其對應用侵入性較高,應用需要基於訊息介面進行改造,而且需要建設專門的訊息系統,成本較高。

 

目前已有基於TCC設計方案可參考:

https://github.com/changmingxie/tcc-transaction

 

下面是轉自大鵬設計師基於TCC實現的設計思路,考慮的更加全面:詳見:https://github.com/dapeng-soa/dapeng-soa/wiki/TCC-support

1、基本概念

TI:Transaction Interceptor,事務攔截器,位於dapeng容器的filterChain鏈中。

由於TI的邏輯會比較複雜, 不太適合在IO執行緒中操作

TM:Transaction Manager, 事務管理器,作為一個獨立的服務存在。

事務發起方: 服務呼叫鏈或者說請求會話中第一個加入全域性事務的介面方法,稱為事務發起方。

事務參與方: 服務呼叫鏈或者說請求會話中除事務發起方的其它加入了全域性事務的介面方法,稱為事務參與方。

例如,對於服務a,b,c, d: client呼叫a.m1, a.m1呼叫b.m2以及c.m3, b.m2呼叫d.m4. 其中,a.m1以及b.m2,d.m4都宣告為TCC事務, 那麼在這次服務呼叫中, a.m1為事務發起方,b.m2,d.m4為事務參與方。

由事務參與方發起confirm或者cancel操作。

事務管理器負責confirm或者cancel失敗後的重試。

在定義介面的時候, 需要加上以下註解,以表明該介面需要加入全域性事務。@TCC(confirm="",cancel="") 該註解有2個可選引數, 其中, confirm代表該介面的confirm方法名字,cancel代表該介面的cancel方法名字。

預設情況下,methodA的confirm方法名為methodA_confirm, cancel方法名為methodA_cancel

2、資料表結構

t_gtx

CREATE TABLE IF NOT EXISTS `mydb`.`t_gtx` (
  `id` INT(11) NOT NULL,
  `gtx_id` INT(11) NOT NULL COMMENT '全域性事務id,一般使用服務的會話id(sesstionTid)',
  `status` SMALLINT(2) NOT NULL DEFAULT 1 COMMENT '全域性事務狀態, 1:新建(CREATED);2:成功(SUCCEED);3:失敗(FAILED);4:完成(DONE)',
  `expired_time` DATETIME(0) NOT NULL COMMENT '超時時間。事務管理器的定時任務會根據全域性事務表的狀態以及超時時間去過濾未完成且超時的事務。預設為事務建立時間後1分鐘。',
  `created_time` DATETIME(0) NOT NULL COMMENT '建立時間',
  `updated_time` TIMESTAMP(0) NOT NULL DEFAULT DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '更新時間',
  `remark` VARCHAR(255) NULL COMMENT '備註, 每次狀態變更都需要追加到remark欄位。',
  PRIMARY KEY (`id`),
  INDEX `index_gtx_id` (`gtx_id` ASC))
ENGINE = InnoDB
DEFAULT CHARACTER SET = utf8mb4
COMMENT = '全域性事務表'

t_gtx_step

CREATE TABLE IF NOT EXISTS `gtx_db`.`t_gtx_step` (
  `id` INT NOT NULL,
  `gtx_id` INT(11) NOT NULL COMMENT '全域性事務id,一般使用服務的會話id(sesstionTid)',
  `step_seq` SMALLINT(2) NOT NULL COMMENT '子事務序號',
  `status` SMALLINT(2) NOT NULL DEFAULT 1 COMMENT '子事務狀態, 1:新建(CREATED);2:成功(SUCCEED);3:失敗(FAILED);4:完成(DONE)',
  `service_name` VARCHAR(128) NOT NULL COMMENT '服務名',
  `version` VARCHAR(32) NOT NULL DEFAULT '1.0.0' COMMENT '服務版本號',
  `method_name` VARCHAR(32) NOT NULL,
  `request` BLOB NULL,
  `confirm_method_name` VARCHAR(32) NULL,
  `cancel_method_name` VARCHAR(32) NULL,
  `redo_times` INT(11) NOT NULL DEFAULT 0,
  `created_time` DATETIME(0) NOT NULL COMMENT '建立時間',
  `updated_time` TIMESTAMP(0) NOT NULL DEFAULT DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '更新時間',
  `remark` VARCHAR(45) NOT NULL DEFAULT '' COMMENT '備註, 每次狀態變更都需要追加到remark欄位。',
  PRIMARY KEY (`id`)),
  INDEX `index_gtx_id` (`gtx_id` ASC))
ENGINE = InnoDB
DEFAULT CHARACTER SET = utf8mb4
COMMENT = '全域性事務流程表'

t_gtx_journal 對於參與分散式事務的服務介面,需要在本地有個事務流水錶(例如orderDb):

CREATE TABLE IF NOT EXISTS `mydb`.`t_gtx_journal` (
  `id` INT(11) NOT NULL,
  `gtx_id` INT(11) NOT NULL COMMENT '全域性事務id',
  `step_id` INT(11) NOT NULL COMMENT '子事務id',
  `biz_tag` VARCHAR(45) NOT NULL COMMENT '本次全域性事務操作的本地業務表名字',
  `biz_id` INT(11) NOT NULL COMMENT '本次全域性事務操作的本地業務記錄id',
  `status` SMALLINT(2) NOT NULL DEFAULT 1 COMMENT '本地子事務狀態, 可在confirm/cancel階段用於判斷try階段是否成功 1:新建(CREATED);4:完成(DONE)',
  `old_values` VARCHAR(255) NULL COMMENT '修改前的值。可選,用於在cancel階段恢復原始值。例如修改字串的操作。格式為:fieldName:fieldValue fieldName:fieldValue',
  `created_time` DATETIME(0) NOT NULL COMMENT '建立時間',
  `updated_time` TIMESTAMP(0) NOT NULL DEFAULT DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '更新時間',
  `remark` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '備註, 每次狀態變更都需要追加到remark欄位。',
  PRIMARY KEY (`id`))
ENGINE = InnoDB
DEFAULT CHARACTER SET = utf8mb4
COMMENT = '子事務的本地流' /* comment truncated */ /*水錶。 當本地事務成功時, 由本地業務*/

本流水錶可用於冪等(例如confirm或者cancel的重試,如果狀態是完成,那麼就不需要執行confirm/cancel邏輯, 或者可用於判斷try階段是否成功。

本地事務流水是否需要建立,需要建立多少,是否記錄oldValues,根據業務性質去定。 例如, 建立訂單的時候,會建立一個主單若干個子單。 這時候, 只需要插入一條本地事務流水(跟主單掛鉤)即可。 因為在confirm或者cancel中, 根據主單id可以招到所有的子單id。

3、案例描述

這裡以訂單建立為例。

使用者建立訂單,同時扣除庫存。

其中訂單、庫存分別為兩個不同的服務。同時, TM也是一個單獨的服務。

本流程有2個業務服務參與,分別是訂單服務的建立訂單介面以及庫存服務的庫存扣減介面。

業務主流程如下:

1、客戶端呼叫orderService.createOrder, 發起訂單建立流程
2、orderService呼叫stockService.decreaseStock, 扣減庫存
3、orderService建立訂單,並返回客戶端。

對應的訂單建立序列圖如下: 建立訂單

3.1. 客戶端發起訂單建立的操作

對應時序圖的No.1呼叫

引數

3.2、全域性事務的Try階段

訂單服務的全域性事務攔截器(TI)收到請求後, 識別到目標方法帶有TCC標識,即進入Trying階段。

3.2.1、訂單服務開啟全域性事務

TI向事務管理服務請求開啟全域性事務,對應時序圖的No.2。 tm.beginGTX(gtxId, params)

txId可用sessionTid(long的形式),params可直接用bytes

3.2.2、事務管理器處理訂單服務請求

對應時序圖的No.3/4/5

事務管理器根據txId去決定呼叫方是事務發起者還是事務參與者。 這裡,orderService是事務發起方, 那麼: 1、TM首先通過createTGX(txId)方法建立一個全域性事務(插入一條全域性事務記錄到t_gtx表中,狀態為新建) 2、通過createStep(txId, params)方法建立一個子事務日誌(插入一條子事務記錄到t_gtx_step表中, 狀態為新建)

全域性事務開啟, 操作成功後返回stepId繼續下一步,否則失敗後直接返回呼叫方,由呼叫方決定是繼續還是回滾(在這個案例中, 這裡的呼叫方是client)。

3.2.3、訂單服務的TI轉發請求到具體的業務服務方法

對應時序圖中的No.6/7 全域性事務開啟成功後, TI轉發請求到業務服務。這裡為orderService.createOrder

在這個方法中, 首先呼叫庫存服務的扣減庫存介面:stockService.decreaseStock

如果全域性事務開啟失敗,那麼TI會直接報錯返回給呼叫方(Err-Gtx-001: begin gtx error)

3.2.4、庫存服務開啟全域性事務

對應時序圖的No.8

同3.2.1,庫存服務的TI收到扣減庫存請求後,開啟全域性事務: `tm.beginGTX'

3.2.5、事務管理器處理庫存服務請求

對應時序圖的No.9/10

事務管理器通過gtxId發現全域性事務已經開啟,那麼該請求來自事務參與方而不是發起方。 這時候,直接通過createStep插入一條子事務日誌到t_gtx_step表中即可,並返回stepId。

3.2.6、庫存服務本地邏輯處理

對應時序圖的No.11/12/13

TI開始全域性事務成功後, 轉發扣減庫存請求給具體的業務方法。 庫存服務執行本地事務(庫存餘額扣減,凍結庫存增加)後返回到TI

同時,需要插入一條本地事務流水錶到t_gtx_journal中,

INSERT INTO `t_gtx_journal` (`id`, `gtx_id`, `step_id`, `biz_tag`, `biz_id`, `status`, `old_values`) 
                     VALUES (id, gtxId, stepId, 't_stock', stockId, 1, NULL);

本案例不需要記錄oldValues, 因為根據介面的入參可以推算出oldValues

3.2.7、庫存服務的TI更新全域性事務

對應時序圖的No.14/15/16

TI根據3.2.6的結果,呼叫tm.updateGTX更新全域性事務。

TM根據gtxId以及stepId判斷該請求來自事務參與方,那麼僅更新子事務日誌表updateStep, 狀態為成功/失敗。

這一步有可能失敗,導致本地子事務提交後,結果沒反映到TM的子事務表的狀態中。

還有一個可能就是本地子事務成功,TI更新全域性事務也成功了, 但是由於網路中斷或者其他原因,導致服務呼叫方(這裡是orderService)的對扣減庫存呼叫失敗。

不管如何,服務呼叫方呼叫失敗後,由服務呼叫方自行決定是繼續前行還是回滾全域性事務。

3.2.8、訂單服務本地業務邏輯處理

對應時序圖的No.18/19

訂單服務根據庫存扣減的結果,決定是繼續往前走還是失敗回退。

如果繼續往前走的話,就完成本地事務後返回結果給訂單服務的TI; 如果失敗回退的話,就把失敗資訊返回給訂單服務的TI。

3.2.9、訂單服務的TI更新全域性事務

對應序列圖的No.20/21/22/23

如果訂單服務本地事務成功,那麼TI通過tm.updateGTX把結果反饋給TM。

TM根據gtxId判斷該請求來自事務發起方,那麼根據status把全域性事務狀態更新為成功/失敗; 同時, 更新子事務狀態為成功/失敗

全域性事務的最終狀態跟事務發起方對應的子事務的最終狀態一致。

No.20中如果事務發起方更新全域性事務狀態失敗, 那麼應通過實時告警的方式提醒人工介入,同時放棄confirm或者cancel操作, 直接返回前端(根據 根據事務發起方的本地事務流水狀態,更新全域性事務狀態為成功/失敗(也需要更新事務發起方的子事務狀態)。 後續,TM定時器會處理後續的confirm或者cancel操作。

至此,Trying階段完成。

根據本階段的結果, TI將會進入TCC的confirm(成功)或者cancel階段(失敗)

3.3、confirm階段

對應序列圖的No.24~33 理論上, Trying階段成功的話,confirm階段一定能成功(最終一致).

Confirm操作由TI發起,而具體的邏輯由TM控制。

3.3.1 事務管理器的confirm操作

首先事務管理器根據gtxId得到全域性事務記錄以及子事務記錄集合(gtx_steps)。

按照子事務的seq從小到大的順序,依次呼叫子事務的confirm方法。(這個過程可以使用非同步的方式併發去confirm?)

最後根據結果更新全域性事務以及子事務的狀態。

只有全部子事務的狀態為完成,全域性事務狀態才能更新為完成。

TI發起confirm操作後,不管本次confirm操作是否成功, 都返回成功給client。

3.4、cancel階段

對應序列圖的No.24~43 本階段跟confirm階段邏輯類似,但是子事務的執行順序相反。

TI發起cancel操作後,不管本次cancel操作是否成功, 都返回失敗給client。

3.5、confirm/cancel階段的異常處理

TM通過定時器,定時掃描全域性事務日誌表中狀態為非完成的記錄(1分鐘前),再次執行confirm/cancel操作。

4. 業務場景

TCC場景:

4.1. 客戶端呼叫單獨的TCC服務

image.png

4.1.1 正常流程

try成功,confirm成功

  1. try階段: 1.1 t_gtx, t_gtx_step插入事務日誌成功, 狀態皆為新建 1.2 tccServiceA本地事務成功 1.3 t_gtx, t_gtx_step更新事務日誌成功,狀態皆為成功
  2. confirm階段 2.1 TM呼叫tccServiceA成功,更新t_gtx, t_gtx_step成功,狀態為完成。

try失敗,cancel成功

  1. try階段: 1.1 t_gtx, t_gtx_step插入事務日誌成功, 狀態皆為新建 1.2 tccServiceA本地事務失敗 1.3 t_gtx, t_gtx_step更新事務日誌成功,狀態皆為失敗
  2. cancel階段 2.1 TM呼叫tccServiceA成功,更新t_gtx, t_gtx_step成功,狀態為完成。

4.1.2 異常流程

try成功,confirm階段或者cancel階段失敗 那麼後續由TM定時任務繼續重試。

4.1.3 異常流程

try階段TI插入事務日誌失敗(Err-Gtx-001: begin gtx error) 如果是事務發起方(本案例), 那麼TI直接返回Err-Gtx-001,本次服務呼叫失敗。 如果是事務參與方, 那麼TI直接返回Err-Gtx-001,並最終回到事務發起方,本次全域性事務失敗,並對已經有記錄的子事務做cancel操作。

因為這裡缺失了分散式事務的某個子事務日誌記錄,TM無法進行confirm或者cancel操作。

try階段本地事務成功,但是TI更新事務日誌失敗(Err-Gtx-002: update gtx error),子事務的狀態停留在新建的狀態 這時候如果是事務發起方(本案例),那麼TI會繼續走confirm或者cancel的流程。 如果是事務參與方,把Err-Gtx-002返回, 事務發起方會忽略該錯誤,其對應的TI會繼續走confirm或者cancel的流程。

在confirm或者cancel的邏輯裡,TM會把gtxId以及該子事務id、狀態通過cookie傳過來。 如果子事務狀態為成功或者失敗,那麼直接執行confirm或者cancel邏輯;

如果子事務狀態為新建,那麼目前尚不清楚到底try階段的本地事務執行了沒。

如果執行了, 那麼必然可以通過gtxId,stepId找到在try階段的本地事務操作過的本地事務流水記錄,從而確認try階段的本地事務提交情況,再進而決定本次confirm或者cancel該做的操作。

舉個例子, 庫存服務的扣減庫存介面。 在try階段,本地事務成功,然後TI在更新子事務狀態的時候失敗了,那麼該子事務狀態為新建。 然後事務發起方依然決定做confirm操作,同時庫存服務扣減庫存介面的confirm方法,通過gtxId以及stepId,找到了本地事務流水記錄,從而可以執行confirm操作。

如果在try階段,本地事務失敗,然後TI在更新子事務狀態的時候也失敗了,那麼該子事務狀態為新建。 然後事務發起方依然決定做confirm操作,同時庫存服務扣減庫存介面的confirm方法,通過gtxId以及stepId,這時候是找不到本地事務流水記錄的,說明try階段本地事務失敗。 那麼業務可以呼叫一下把try以及confirm的邏輯合併起來,完成本次confirm操作。

4.2. 客戶端先後呼叫2個TCC服務

image.png

這時候, 這兩次服務呼叫分別構成一個全域性事務, 是兩個互不相關的全域性事務

4.3. 客戶端呼叫TCC服務a,服務a再呼叫TCC服務b

image.png

4.4. 客戶端呼叫TCC服務a,服務a再分別呼叫TCC服務b以及TCC服務c

image.png

4.5. 客戶端呼叫TCC服務a,服務a呼叫TCC服務b,服務b再呼叫TCC服務c

image.png