聊一聊如何用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 個新的特性,詳見 https://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 分佈式事務就編寫完成了。

需要注意的地方:

  1. 依賴 TccGlobalTransaction ,這個是單例的
  2. 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

參考資料

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章