在一年前我寫過一篇關於分佈式事務的文章: 再有人問你分佈式事務,把這篇扔給他,在這篇文章中我詳細介紹了分佈式事務是什麼,實現分佈式事務有哪些經常使用的方案,可是其中的東西不少是偏於理論,不少讀者對其真正在實戰上的使用可能仍是有點差距。因此在前幾回文章的更新中,我介紹了不少關於Seata(一款由阿里開源的分佈式事務框架)的內容,若是你們對Seata不是很熟悉的能夠閱讀下面的內容:mysql
Seata已經爲咱們提供了兩種實現分佈式模式:程序員
大多數時候Seata已經足夠了,可是不少時候不一樣場景下咱們沒辦法選擇Seata這類TCC框架:sql
而我最近在作一些分佈式事務的事的時候也遇到了這些問題,因爲通常使用分佈式事務是業務方,你須要驅動作RPC組件的同事支持,而且咱們並非純金融服務的公司,搭建一套相似Seata的分佈式事務中間件也是比較耗費資源。數據庫
以前介紹的方案大多數都比較籠統,俗話說授人以魚不如授人以漁,因此接下來我將會一步一步的教你們如何不用框架,而是咱們本身去編碼去實現分佈式事務。後端
爲了更好的講解如何在實戰中完成分佈式事務,這裏直接舉一個你們都熟悉的例子:用戶下單的時候,能夠選擇三種資產,分別是儲值餘額,積分,券,這個場景幾乎在每一個應用都能看見,而這個場景在咱們的後端能夠映射爲4個服務,以下圖所示:bash
在這個場景下大多數人的代碼基本會按照下面的寫,在訂單服務中有以下步驟,這裏爲了簡單沒有設置過多的訂單狀態:網絡
差很少這裏就是簡簡單單4行,有不少人會把這5步直接放進事務之中,也就是加上@Transactional註解,但其實加上這個註解不只沒有起到事務做用,並且還讓咱們的事務變成了長事務,咱們這裏的Step2-4都是RPC遠程調用,一旦某個RPC出現了Timeout,那麼咱們的數據庫鏈接會被長期持有不被釋放,有可能致使咱們系統雪崩。框架
既然這裏加上事務沒有用,咱們能夠看看會出現什麼問題,若是Step2支付成功,Step3失敗,那麼就會致使數據不一致。其實不少人就會有僥倖心理,默認咱們的Step 2-4會成功,若是出現問題咱們人工修復就是了。人工修復的成本過高,你就想若是你在外面旅遊忽然叫你修復數據,那你是否是會氣得吐血?因此咱們這裏一步一步的教你們如何逐漸的把這段業務邏輯優化成能保證咱們數據一致的。分佈式
通常來講任何一個分佈式事務框架都離不開三個關鍵字:重作記錄,重試機制,冪等。而在咱們的業務中一樣也離不開這三個關鍵字。優化
咱們想一想咱們mysql的事務回滾是依靠什麼的?依靠的是undolog,咱們的undolog保存了事務發生以前的數據的一個版本,那麼咱們發生回滾的時候直接利用這個版本的數據回滾便可。這裏咱們首先須要添加咱們的重作記錄,咱們不必叫undolog,咱們再各個資源服務中須要添加一個事務記錄表:
CREATE TABLE `transaction_record` (
`orderId` int(11) unsigned NOT NULL AUTO_INCREMENT,
`op_total` int(11) NOT NULL COMMENT '本次操做資源操做數量',
`status` int(11) NOT NULL COMMENT '1:表明支付成功 2:表明支付取消',
`resource_id` int(11) NOT NULL COMMENT '本次操做資源的Id',
`user_id` int(11) NOT NULL COMMENT '本次操做資源的用戶Id',
PRIMARY KEY (`orderId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
複製代碼
在咱們的分佈式事務中有一個全局事務ID,而咱們orderId
就能很好的適應這個角色,這裏咱們每一個資源的事務記錄表都須要記錄這個OrderId,用於和全局事務進行關聯,而且咱們這裏直接將其做爲主鍵,也代表了這個表中只會出現一次全局事務ID。這裏的op_total
用於記錄本次操做資源的數量,用於後續回滾,哪怕不回滾咱們也能夠用於後續記錄的查詢。status
用於記錄咱們當前這條記錄的狀態如何,這裏用了兩個狀態,後續咱們能夠擴展更多的狀態,解決更多的分佈式事務問題。
有了這個重作記錄以後咱們只須要在每一次執行記錄下咱們的當前資源的transaction_record,在回滾的時候根據咱們的OrderId將全部的資源回滾,咱們優化以後代碼能夠以下:
int orderId = createInitOrder();
checkResourceEnough();
try {
accountService.payAccount(orderId, userId, opTotal);
coinService.payCoin(orderId, userId, opTotal);
couponService.payCoupon(orderId, userId, couponId);
updateOrderStatus(orderId, PAID);
}catch (Exception e){
//這裏進行回滾
accountService.rollback(orderId, userId);
coinService.rollback(orderId, userId);
couponService.rollback(orderId, userId);
updateOrderStatus(orderId, FAILED);
}
複製代碼
這裏咱們將建立好的初始化訂單,看成參數傳遞給咱們的資源服務記錄,最後再進行狀態更新,若是發生了異常,那麼咱們須要進行手動回滾並將訂單數據變爲FAILED, 回滾的依據就是咱們的訂單Id。對於咱們的支付和回滾的僞代碼有以下:
@Transactional
void payAccount(int orderId, int userId, int opTotal){
account.payAccount(userId, opTotal); // 實際的去咱們account表扣減
transactionRecordStorage.save(orderId, userId, opTotal, account.getId()); //保存事務記錄表
}
@Transactional
void rollback(int orderId, int userId){
TransactionRecord tr = transactionRecordStorage.get(orderId); //從記錄表中查詢
account.rollbackBytr(tr); // 根據記錄回滾
}
複製代碼
這裏的版本是比較簡略的,問題還比較多後面會講優化。
有些同窗可能會問好像咱們上面的代碼基本能保證分佈式事務了吧?的確上面的代碼能保證咱們在沒有宕機或者其餘更加嚴重的狀況下基本上是沒有問題的,可是若是出現了宕機,好比咱們剛剛把account給支付完了,而後支付coin的時候咱們的訂單機器宕機了,沒有發出去這個請求,這裏就不會走到咱們的手動回滾請求,因此咱們的account將會永遠不會回滾,又只得靠咱們的人工回滾,若是你此時還在旅遊,又叫你回滾,估計你會繼續氣暈。或者說咱們再回滾的時候出現錯誤,怎麼辦?咱們沒有有效的手段進行鍼對回滾的回滾。
因此咱們須要額外的重試機制來保證,首先咱們須要定義什麼樣的數據須要重試,這裏的話咱們根據業務差很少一分鐘能將全部的都資源都支付完,若是咱們的訂單狀態爲init 而且 建立時間超過一分鐘,那麼就認爲發生了上述錯誤的事件。接下來能夠經過咱們的重試機制進行回滾,這裏有兩個常見重試機制:
判斷一個程序猿經驗是否老道能夠從他寫代碼的時候可否考慮到冪等就能夠看出。不少年輕的程序員根本不會考慮冪等的存在,甚至都不知道冪等是什麼。這裏先解釋一下冪等的概念:能夠簡單的認爲任意屢次執行所產生的影響和一次執行的影響相同。
爲何咱們完成分佈式事務的時候須要冪等?你們能夠想一想若是在執行回滾操做的時候宕機了,咱們上面的重試機制就會開始工做,好比咱們的券這個資源已經回滾,可是咱們重試操做的時候我並不知道券已經回滾了,這個時候就再次嘗試回滾券,若是沒有作冪等操做會怎麼辦,有可能致使用戶資產會多增長,這樣就會對公司形成不少損失。
因此冪等在咱們重試的時候很是重要,實現冪等的關鍵是什麼?咱們想讓屢次操做和一次操做是同樣的,那麼咱們只須要比較第一次已經作過了,而這個標記經過什麼來完成呢?這裏咱們可使用咱們狀態機轉換的手段完成標記。只有標記這裏仍是不夠,爲何呢這裏咱們用個例子來講明一下,把上面的rollback簡單優化一下:
@Transactional
void rollback(int orderId, int userId){
TransactionRecord tr = transactionRecordStorage.get(orderId);
if(tr.isCanceled()){
return; //若是已經被取消了那麼直接返回
}
//從記錄表中查詢
account.rollbackBytr(tr); // 根據記錄回滾
}
複製代碼
上面代碼咱們經過判斷狀態若是是已經被取消了,也就是被回滾了那麼咱們就直接返回,這裏就完成了咱們所說的冪等。可是這裏還有個問題是若是有兩個rollback同時執行怎麼辦?你可能會問什麼樣的狀況可能會有兩個rollback,這裏舉一個場景當第一次rollback的時候請求在阻塞了,這個時候調用方已經觸發超時了,而後一段時間以後第二次rollback來了,這個時候剛好第一次也不阻塞了,那麼這裏就會有兩個rollback請求發出,當執行狀態判斷的時候,若是兩個請求同時執行狀態判斷,那麼都會繞過這個檢查,最後用戶就會退兩次錢,這樣的狀況咱們必定要避免。
那麼怎麼才能避免呢?聰明的同窗立刻就會想到使用分佈式鎖呀,一提到分佈式鎖立刻想到的就是Redis加鎖,ZK加鎖等等,我在這篇文章也作了介紹:聊聊分佈式鎖,可是咱們這裏直接使用數據庫行鎖便可,也就是用下面的sql語句查詢:
select * from transaction where orderId = "#{orderId}" for update;
複製代碼
其餘的代碼不變,經過這種形式咱們完成了冪等。這時候有可能會有同窗會問到,若是TransactionRecord不存在怎麼辦?由於咱們重試的時候咱們怎麼知道他的Try是否成功,咱們這裏是不知道的,因此咱們這裏還有策略保證咱們的邏輯不會出現空指針,這裏有兩種策略來作這個事:
上面的第一個策略比較簡單,可是咱們這裏須要選擇第二個策略,爲何呢由於咱們還須要預防一個事情:防懸掛,咱們再說rollback冪等的時候,若是第一個rollback發生網絡阻塞,那麼這裏咱們將rollback替換成咱們第一次支付的時候發生了阻塞,致使了pay在rollback以後到達咱們的客戶端,若是咱們採用第一種方式,咱們這個阻塞的Pay請求時沒法感知整個事務由於rollback,而後繼續pay致使咱們這個pay永遠得不到回滾,這就是懸掛。因此咱們這裏採用第二個策略,保存一條記錄,咱們在pay也會檢查有沒有這條記錄,因此優化以後的代碼爲:
@Transactional
void payAccount(int orderId, int userId, int opTotal){
TransactionRecord tr = transactionRecordStorage.getForUpdate(orderId);
if(tr != null){
return; //若是已經有數據了,這裏直接返回
}
account.payAccount(userId, opTotal); // 實際的去咱們account表扣減
transactionRecordStorage.save(orderId, userId, opTotal, account.getId()); //保存事務記錄表
}
@Transactional
void rollback(int orderId, int userId){
TransactionRecord tr = transactionRecordStorage.getForUpdate(orderId);
if(tr == null){
saveNullCancelTr(orderId, userId); //保存空回滾的記錄
}
if(tr.isCanceled() || tr.isNullCancel()){
return; //若是已經被取消了那麼直接返回
}
//從記錄表中查詢
account.rollbackBytr(tr); // 根據記錄回滾
}
複製代碼
到這裏咱們整個構建分佈式事務基本大功告成了,經過這種方式基本上之後遇到相關分佈式事務的業務問題的時候均可以解決。這裏咱們再回顧一下咱們的三個要點:
咱們只要能掌握好這三個點,其實不只僅是對分佈式事務這一塊有幫助,對其餘的業務一樣也有很大的提高。
若是你們以爲這篇文章對你有幫助,你的關注和轉發是對我最大的支持,O(∩_∩)O: