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

 

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

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

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

2.1基於XA協議的方案

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

兩階段方案在解決數據庫分佈式事務問題方面應用很是普遍,oracle、Mysql等主流關係數據庫均支持XA協議,並且ocenbase、DCDB等著名的分佈式數據庫也都基於兩階段協議。在解決服務事務問題上,其實 XA協議不是隻能做用於單個服務內部的多資源場景,跨服務的多資源場景也是能夠的,只不過須要額外的事務傳遞機制。但其都有致命的缺點,性能不理想。因爲須要等到各分支事務都就緒後全局事務纔開始提交,因此每一個事務鎖定數據的時間較長,XA方案所以很難知足高併發場景。並且在解決微服務問題時XA方案的性能問題將會被放大。由於應用在訪問服務的調用方式、網絡環境等要比訪問數據庫複雜的多。例如,應用和其訪問的數據庫一般在一個局域網中,而其經過rpc調用的服務則可能屬於另外一個網絡或者在公網上,其時延更長、出故障的機率更高。這將致使數據鎖定時間和系統併發度進一步下降。因此XA方案基本不適合解決微服務的事務問題。github

2.2TCC方案

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

事務開始時,業務應用會向事務協調器註冊啓動事務。以後業務應用會調用全部服務的try接口,至關於XA的第一階段。若是有任何一個服務的try接口調用失敗會向事務協調器發送事務回滾請求,不然發送事務提交請求。事務協調器收到事務回滾請求後會依次調用事務的confirm接口,不然調用cancel接口回滾,這至關於XA的第二階段。若是第二階段接口調用失敗,會進行重試。數據庫

TCC方案經過經過三個接口很好的規避了長時間數據加鎖的問題,業務表在每一個接口調用完畢便可釋放,這很大程度上提升了業務的併發度,這也是TCC方案最大的優點。因此在SOA時期,TCC方案被不少金融、電商的業務系統大量使用。
固然TCC方案也有不足之處,集中表如今如下兩個方面:segmentfault

  • 開發工做量大。它將部分資源管理器的功能融入到每一個服務的開發中,致使服務的每一個接口都須要實現try、confirm、cancle,還須要實現事務協調器,開發量不僅翻了一倍。
  • 實現難度大。系統須要記錄每一個應用的服務調用鏈路。我前面講過rpc調用狀況比較複雜,因爲網絡情況、系統故障等調用失敗被視爲常態,必須按照不一樣的失敗緣由實現不一樣策略的回滾。爲了知足一致性的要求,二階段無論調用confirm仍是cancle都必須調用成功,若是一次調用不成功,事務協調器必須嘗試重試。這就要求confirm和cancle接口必須實現冪等。

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

2.3 消息事務一致性方案

消息一致性方案是經過消息中間件保證上、下游應用數據操做的一致性。基本思路是將本地操做和發送消息放在一個事務中,保證本地操做和消息發送要麼二者都成功或者都失敗。下游應用向消息系統訂閱該消息,收到消息後執行相應操做。
如下單業務爲例進行說明,下單基本流程是先存儲訂單信息,而後扣相應商品的庫存,兩個操做必須在一個事務中。以下圖,業務應用首先調用訂單服務,訂單存儲成功後,訂單服務會經過消息處理服務投遞訂單消息到MQ。庫存服務從MQ收到消息後進行扣庫存操做,若是執行成功會向消息處理服務發送通知。消息處理服務會實時監測訂單消息是否超時,若是超時會從新投遞到MQ中,以驅動庫存服務進行扣庫存操做。若是扣庫存操做執行失敗後,庫存服務後續還會從MQ接收到相同的訂單消息,須要屢次重複執行,直到成功或者進行人工干預。庫存服務須要實現冪等。 
cookie

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

 

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

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

 

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

一、基本概念

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

二、數據表結構

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。

三、案例描述

這裏以訂單建立爲例。

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

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

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

業務主流程以下:

一、客戶端調用orderService.createOrder, 發起訂單建立流程
二、orderService調用stockService.decreaseStock, 扣減庫存
三、orderService建立訂單,並返回客戶端。

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

 

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

對應時序圖的No.1調用

參數

3.二、全局事務的Try階段

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

3.2.一、訂單服務開啓全局事務

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

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

3.2.二、事務管理器處理訂單服務請求

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

事務管理器根據txId去決定調用方是事務發起者仍是事務參與者。 這裏,orderService是事務發起方, 那麼: 一、TM首先經過createTGX(txId)方法建立一個全局事務(插入一條全局事務記錄到t_gtx表中,狀態爲新建) 二、經過createStep(txId, params)方法建立一個子事務日誌(插入一條子事務記錄到t_gtx_step表中, 狀態爲新建)

全局事務開啓, 操做成功後返回stepId繼續下一步,不然失敗後直接返回調用方,由調用方決定是繼續仍是回滾(在這個案例中, 這裏的調用方是client)。

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

對應時序圖中的No.6/7 全局事務開啓成功後, TI轉發請求到業務服務。這裏爲orderService.createOrder

在這個方法中, 首先調用庫存服務的扣減庫存接口:stockService.decreaseStock

若是全局事務開啓失敗,那麼TI會直接報錯返回給調用方(Err-Gtx-001: begin gtx error)

3.2.四、庫存服務開啓全局事務

對應時序圖的No.8

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

3.2.五、事務管理器處理庫存服務請求

對應時序圖的No.9/10

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

3.2.六、庫存服務本地邏輯處理

對應時序圖的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.七、庫存服務的TI更新全局事務

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

TI根據3.2.6的結果,調用tm.updateGTX更新全局事務。

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

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

還有一個可能就是本地子事務成功,TI更新全局事務也成功了, 可是因爲網絡中斷或者其餘緣由,致使服務調用方(這裏是orderService)的對扣減庫存調用失敗。

無論如何,服務調用方調用失敗後,由服務調用方自行決定是繼續前行仍是回滾全局事務。

3.2.八、訂單服務本地業務邏輯處理

對應時序圖的No.18/19

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

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

3.2.九、訂單服務的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.三、confirm階段

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

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

3.3.1 事務管理器的confirm操做

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

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

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

只有所有子事務的狀態爲完成,全局事務狀態才能更新爲完成。

TI發起confirm操做後,無論本次confirm操做是否成功, 都返回成功給client。

3.四、cancel階段

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

TI發起cancel操做後,無論本次cancel操做是否成功, 都返回失敗給client。

3.五、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

相關文章
相關標籤/搜索