聊一聊如何用C#輕鬆完成一個TCC分散式事務
背景
銀行跨行轉賬業務是一個典型分散式事務場景,假設 A 需要跨行轉賬給 B,那麼就涉及兩個銀行的數據,無法通過一個資料庫的本地事務保證轉賬的 ACID ,只能夠通過分散式事務來解決。
在 聊一聊如何用C#輕鬆完成一個SAGA分散式事務 中介紹了藉助 DTM 用 SAGA 事務模式解決了上面的銀行跨行轉賬業務。
這一篇我們就來看看如何用 TCC 的事務模式來處理這個問題。
什麼是 TCC
TCC是Try、Confirm、Cancel三個詞語的縮寫,最早是由 Pat Helland 於 2007 年發表的一篇名為《Life beyond Distributed Transactions:an Apostate』s Opinion》的論文提出。
TCC分為3個階段
- Try 階段:嘗試執行,完成所有業務檢查(一致性), 預留必需的業務資源(准隔離性)
- Confirm 階段:如果所有分支的Try都成功了,則走到Confirm階段。Confirm真正執行業務,不作任何業務檢查,只使用 Try 階段預留的業務資源
- Cancel 階段:如果所有分支的Try有一個失敗了,則走到Cancel階段。Cancel釋放 Try 階段預留的業務資源。
對於前面的跨行轉賬業務,最簡單的做法是,在Try階段調整餘額,在Cancel階段反向調整餘額,Confirm階段則空操作。這麼做帶來的問題是,如果A扣款成功,金額轉入B失敗,最後回滾,把A的餘額調整為初始值。在這個過程中如果A發現自己的餘額被扣減了,但是收款方B遲遲沒有收到餘額,那麼會對A造成困擾。
更好的做法是,Try階段凍結A轉賬的金額,Confirm進行實際的扣款,Cancel進行資金解凍,這樣用戶在任何一個階段,看到的數據都是清晰明了的。
下面我們進行一個 TCC 事務的具體開發
前置工作
dotnet add package Dtmcli --version 0.4.0
註:相比 0.3.0,0.4.0 支援了 4 個新的特性,詳見 //github.com/dtm-labs/dtmcli-csharp/releases/tag/v0.4.0
成功的 TCC
先來看一下一個成功完成的 TCC 時序圖。
可以看到它的流程和 SAGA 的還是有比較大的區別。
同樣的,上圖的微服務1,對應我們示例的 OutApi,也就是轉錢出去的那個服務。
微服務2,對應我們示例的 InApi,也就是轉錢進來的那個服務。
下面我們來編寫兩個服務的Try/Confirm/Cancel的處理。
OutApi
app.MapPost("/api/TransOutTry", async (IBranchBarrierFactory bbFactory, HttpContext context, TransRequest req) =>
{
var bb = bbFactory.CreateBranchBarrier(context.Request.Query);
using var db = Db.GeConn();
await bb.Call(db, async (tx) =>
{
Console.WriteLine($"用戶【{req.UserId}】轉出【{req.Amount}】Try 操作,bb={bb}");
// tx 參數是事務,可和本地事務一起提交回滾
await Task.CompletedTask;
});
return Results.Ok(TransResponse.BuildSucceedResponse());
});
app.MapPost("/api/TransOutConfirm", async (IBranchBarrierFactory bbFactory, HttpContext context, TransRequest req) =>
{
var bb = bbFactory.CreateBranchBarrier(context.Request.Query);
using var db = Db.GeConn();
await bb.Call(db, async (tx) =>
{
Console.WriteLine($"用戶【{req.UserId}】轉出【{req.Amount}】Confirm操作,bb={bb}");
await Task.CompletedTask;
});
return Results.Ok(TransResponse.BuildSucceedResponse());
});
app.MapPost("/api/TransOutCancel", async (IBranchBarrierFactory bbFactory, HttpContext context, TransRequest req) =>
{
var bb = bbFactory.CreateBranchBarrier(context.Request.Query);
using var db = Db.GeConn();
await bb.Call(db, async (tx) =>
{
Console.WriteLine($"用戶【{req.UserId}】轉出【{req.Amount}】Cancel操作,bb={bb}");
await Task.CompletedTask;
});
return Results.Ok(TransResponse.BuildSucceedResponse());
});
InApi
app.MapPost("/api/TransInTry", async (IBranchBarrierFactory bbFactory, HttpContext context, TransRequest req) =>
{
var bb = bbFactory.CreateBranchBarrier(context.Request.Query);
using var db = Db.GeConn();
await bb.Call(db, async (tx) =>
{
Console.WriteLine($"用戶【{req.UserId}】轉入【{req.Amount}】Try操作,bb={bb}");
await Task.CompletedTask;
});
return Results.Ok(TransResponse.BuildSucceedResponse());
});
app.MapPost("/api/TransInConfirm", async (IBranchBarrierFactory bbFactory, HttpContext context, TransRequest req) =>
{
var bb = bbFactory.CreateBranchBarrier(context.Request.Query);
using var db = Db.GeConn();
await bb.Call(db, async (tx) =>
{
Console.WriteLine($"用戶【{req.UserId}】轉入【{req.Amount}】Confirm操作,bb={bb}");
await Task.CompletedTask;
});
return Results.Ok(TransResponse.BuildSucceedResponse());
});
app.MapPost("/api/TransInCancel", async (IBranchBarrierFactory bbFactory, HttpContext context, TransRequest req) =>
{
var bb = bbFactory.CreateBranchBarrier(context.Request.Query);
using var db = Db.GeConn();
await bb.Call(db, async (tx) =>
{
Console.WriteLine($"用戶【{req.UserId}】轉入【{req.Amount}】Cancel操作,bb={bb}");
await Task.CompletedTask;
});
return Results.Ok(TransResponse.BuildSucceedResponse());
});
到此各個子事務的處理已經OK了,在上面的程式碼中,下面這幾行是子事務屏障相關程式碼,只要按照這個方式來調用您的業務邏輯,子事務屏障保證重複請求、懸掛、空補償情況出現時,您的業務邏輯不會被調用,保證了正常業務的正確進行
var bb = bbFactory.CreateBranchBarrier(context.Request.Query);
await bb.Call(db, async (tx) =>
{
// 業務操作...
});
然後準備開啟 TCC 事務,進行分支調用
var cts = new CancellationTokenSource();
var gid = await dtmClient.GenGid(cts.Token);
var res = await tccGlobalTransaction.Excecute(gid, async (tcc) =>
{
// 用戶1 轉出30元
var res1 = await tcc.CallBranch(userOutReq, outApi + "/TransOutTry", outApi + "/TransOutConfirm", outApi + "/TransOutCancel", cts.Token);
// 用戶2 轉入30元
var res2 = await tcc.CallBranch(userInReq, inApi + "/TransInTry", inApi + "/TransInConfirm", inApi + "/TransInCancel", cts.Token);
Console.WriteLine($"case1, branch-out-res= {res1} branch-in-res= {res2}");
}, cts.Token);
Console.WriteLine($"case1, {gid} tcc 提交結果 = {res}");
到這裡,一個完整的 TCC 分散式事務就編寫完成了。
需要注意的地方:
- 依賴 TccGlobalTransaction ,這個是單例的
- tcc 的 CallBranch 方法就是事務分支的調用
搭建好 dtm 的環境後,運行上面的例子,會看到下面的輸出。
成功的示例都是相對比較簡單的。
下面來看一個 TCC 回滾的例子。
TCC 的回滾
假如銀行將金額準備轉入用戶2時,發現用戶2的賬戶異常,返回失敗,會怎麼樣?我們修改程式碼,模擬這種情況:
在 InApi 加多一個轉入Try失敗的處理介面
app.MapPost("/api/TransInTryError", (IBranchBarrierFactory bbFactory, HttpContext context, TransRequest req) =>
{
var bb = bbFactory.CreateBranchBarrier(context.Request.Query);
Console.WriteLine($"用戶【{req.UserId}】轉入【{req.Amount}】Try--失敗,bb={bb}");
return Results.Ok(TransResponse.BuildFailureResponse());
});
再來看一下事務失敗交互的時序圖
這個跟成功的 TCC 差別就在於,當某個子事務返回失敗後,後續就回滾全局事務,調用各個子事務的 Cancel 操作,保證全局事務全部回滾。
再調整一下調用方,把轉入 Try 操作替換成上面這個返回錯誤的介面。
var cts = new CancellationTokenSource();
var gid = await dtmClient.GenGid(cts.Token);
var res = await tccGlobalTransaction.Excecute(gid, async (tcc) =>
{
var res1 = await tcc.CallBranch(userOutReq, outApi + "/TransOutTry", outApi + "/TransOutConfirm", outApi + "/TransOutCancel", cts.Token);
var res2 = await tcc.CallBranch(userInReq, inApi + "/TransInTryError", inApi + "/TransInConfirm", inApi + "/TransInCancel", cts.Token);
Console.WriteLine($"case2, branch-out-res= {res1} branch-in-res= {res2}");
}, cts.Token);
Console.WriteLine($"case2, {gid} tcc 提交結果 = {res}");
需要注意的是 CallBranch 方法在對應的微服務返回失敗後會拋出異常,進而觸發全局事務的回滾操作,這個時候 dtm 才會觸發 Cancel 的操作。
運行結果如下:
重點看三個地方,
- 轉入的 Cancel 操作並沒有執行,因為這裡模擬的是轉入失敗的情況,子事務屏障判定為空補償了
- 沒有輸出分支調用的結果,是因為執行第二個分支的時候沒有返回成功的結果
- 輸出的提交結果為空,表明這個事務是失敗的,成功的話會返回這個事務的 gid
寫在最後
在這篇文章里,通過 2 個簡單的例子,完整給出了編寫一個 TCC 事務的過程,涵蓋了正常成功完成,異常回滾的情況。
希望對研究分散式事務的您有所幫助。
本文示例程式碼: DtmTccDemo
參考資料