聊一聊如何用C#轻松完成一个SAGA分布式事务
背景
银行跨行转账业务是一个典型分布式事务场景,假设 A 需要跨行转账给 B,那么就涉及两个银行的数据,无法通过一个数据库的本地事务保证转账的 ACID ,只能够通过分布式事务来解决。
市面上使用比较多的分布式事务框架,支持 SAGA 的,大部分都是 JAVA 为主的,没有提供 C# 的对接方式,或者是对接难度大,一定程度上让人望而却步。
这里推荐一下叶东富大佬的分布式事务框架 dtm,一款跨语言的开源分布式事务管理器,优雅的解决了幂等、空补偿、悬挂等分布式事务难题。提供了简单易用、高性能、易水平扩展的分布式事务解决方案。
老黄在搜索相关分布式事务资料的时候,他写的文章都是相对比较好理解的,也就是这样关注到了 dtm 这个项目。
下面就基于这个框架来实践一下银行转账的例子。
前置工作
dotnet add package Dtmcli --version 0.3.0
成功的 SAGA
先来看一下一个成功完成的 SAGA 时序图。

上图的微服务1,对应我们示例的 OutApi,也就是转钱出去的那个服务。
微服务2,对应我们示例的 InApi,也就是转钱进来的那个服务。
下面是两个服务的正向操作和补偿操作的处理。
OutApi
app.MapPost("/api/TransOut", (string branch_id, string gid, string op, TransRequest req) =>
{
// 进行 数据库操作
Console.WriteLine($"用户【{req.UserId}】转出【{req.Amount}】正向操作,gid={gid}, branch_id={branch_id}, op={op}");
return Results.Ok(TransResponse.BuildSucceedResponse());
});
app.MapPost("/api/TransOutCompensate", (string branch_id, string gid, string op, TransRequest req) =>
{
// 进行 数据库操作
Console.WriteLine($"用户【{req.UserId}】转出【{req.Amount}】补偿操作,gid={gid}, branch_id={branch_id}, op={op}");
return Results.Ok(TransResponse.BuildSucceedResponse());
});
InApi
app.MapPost("/api/TransIn", (string branch_id, string gid, string op, TransRequest req) =>
{
Console.WriteLine($"用户【{req.UserId}】转入【{req.Amount}】正向操作,gid={gid}, branch_id={branch_id}, op={op}");
return Results.Ok(TransResponse.BuildSucceedResponse());
});
app.MapPost("/api/TransInCompensate", (string branch_id, string gid, string op, TransRequest req) =>
{
Console.WriteLine($"用户【{req.UserId}】转入【{req.Amount}】补偿操作,gid={gid}, branch_id={branch_id}, op={op}");
return Results.Ok(TransResponse.BuildSucceedResponse());
});
注:示例为了简单,没有进行实际的数据库操作。
到此各个子事务的处理已经 OK 了,然后是开启 SAGA 事务,进行分支调用
var userOutReq = new TransRequest() { UserId = "1", Amount = -30 };
var userInReq = new TransRequest() { UserId = "2", Amount = 30 };
var ct = new CancellationToken();
var gid = await dtmClient.GenGid(ct);
var saga = new Saga(dtmClient, gid)
.Add(outApi + "/TransOut", outApi + "/TransOutCompensate", userOutReq)
.Add(inApi + "/TransIn", inApi + "/TransInCompensate", userInReq)
;
var flag = await saga.Submit(ct);
Console.WriteLine($"case1, {gid} saga 提交结果 = {flag}");
到这里,一个完整的 SAGA 分布式事务就编写完成了。
搭建好 dtm 的环境后,运行上面的例子,会看到下面的输出。

当然,上面的情况太理想了,转出转入都是一次性就成功了。
但是实际上我们会遇到许许多多的问题,最常见的应该就是网络故障了。
下面来看一个异常的 SAGA 示例
异常的 SAGA
做一个假设,用户1的转出是正常的,但是用户2在转入的时候出现了问题。
由于事务已经提交给 dtm 了,按照 SAGA 事务的协议,dtm 会重试未完成的操作。
这个时候用户2 这边会出现什么样的情况呢?
- 转入其实成功了,但是 dtm 收到错误 (网络故障等)
- 转入没有成功,直接告诉 dtm 失败了 (应用异常等)
无论是那一种,dtm 都会进行重试操作。这个时候会发生什么呢?我们继续往下看。
先看一下事务失败交互的时序图

再通过调整上面成功的例子,来比较直观的看看出现的情况。
在 InApi 加多一个转入失败的处理接口
app.MapPost("/api/TransInError", (string branch_id, string gid, string op, TransRequest req) =>
{
Console.WriteLine($"用户【{req.UserId}】转入【{req.Amount}】正向操作--失败,gid={gid}, branch_id={branch_id}, op={op}");
//return Results.BadRequest();
return Results.Ok(TransResponse.BuildFailureResponse());
});
失败的返回有两种,一种是状态码大于 400,一种是状态码是 200 并且响应体包含 FAILURE,上面的例子是第二种
调整一下调用方,把转入正向操作替换成上面这个返回错误的接口。
var saga = new Saga(dtmClient, gid)
.Add(outApi + "/TransOut", outApi + "/TransOutCompensate", userOutReq)
.Add(inApi + "/TransInError", inApi + "/TransInCompensate", userInReq);
运行结果如下:

在这个例子中,只考虑补偿/重试成功的情况下。
用户1 转出的 30 块钱最终是回到了他的帐号上,他没有出现损失。
用户2 就有点苦逼了,转入没有成功,返回了失败,还触发了转入的补偿机制,结果就是把用户2 还没进帐的 30 块钱给多扣了,这个就是上面的情况2,常见的空补偿问题。
这个时候就要在进行转入补偿的时候做一系列的判断,转入有没有成功,转出有没有失败等等,把业务变的十分复杂。
如果出现了上述的情况1,会发生什么呢?
用户2 第一次已经成功转入 30 块钱,返回的也是成功,但是网络出了点问题,导致 dtm 认为失败了,它就会进行重试,相当于用户2 还会收到第二个转入 30 块钱的请求!也就是说这次转帐,用户2 会进账 60 块钱,翻倍了,也就是说这个请求不是幂等。
同样的,要处理这个问题,在进行转入的正向操作中也要进行一系列的判断,同样会把复杂度上升一个级别。
前面有提到 dtm 提供了子事务屏障的功能,保证了幂等、空补偿等常见问题。

再来看看这个子事务屏障的功能有没有帮我们简化上面异常处理。
子事务屏障
子事务屏障,需要根据 trans_type,gid,branch_id 和 op 四个内容进行创建。
这4个内容 dtm 在回调时会放在 querysting 上面。
客户端里面提供了 IBranchBarrierFactory 来供我们使用。
空补偿
针对上面的异常情况(用户2 凭空消失 30 块钱),对转入的补偿进行子事务屏障的改造。
app.MapPost("/api/BarrierTransInCompensate", async (string branch_id, string gid, string op, string trans_type, TransRequest req, IBranchBarrierFactory factory) =>
{
var barrier = factory.CreateBranchBarrier(trans_type, gid, branch_id, op);
using var db = Db.GeConn();
await barrier.Call(db, async (tx) =>
{
// 转入失败的情况下,不应该输出下面这个
Console.WriteLine($"用户【{req.UserId}】转入【{req.Amount}】补偿操作,gid={gid}, branch_id={branch_id}, op={op}");
// tx 参数是事务,可和本地事务一起提交回滚
await Task.CompletedTask;
});
Console.WriteLine($"子事务屏障-补偿操作,gid={gid}, branch_id={branch_id}, op={op}");
return Results.Ok(TransResponse.BuildSucceedResponse());
});
Call 方法就是关键所在了,需要传入一个 DbConnection 和真正的业务操作,这里的业务操作就是在控制台输出补偿操作的信息。
同样的,我们再调整一下调用方,把转入补偿操作替换成上面带子事务屏障的接口。
var saga = new Saga(dtmClient, gid)
.Add(outApi + "/TransOut", outApi + "/TransOutCompensate", userOutReq)
.Add(inApi + "/TransInError", inApi + "/BarrierTransInCompensate", userInReq)
;
再来运行这个例子。

会发现转入的补偿操作并没执行,控制台没有输出补偿信息,而是输出了
Will not exec busiCall, isNullCompensation=True, isDuplicateOrPend=False
这个就表明了,这个请求是个空补偿,是不应该执行业务方法的,既空操作。
再来看一下,转入成功的,但是 dtm 收到了失败的信号,不断重试造成重复请求的情况。
幂等
针对用户2 转入两次 30 块钱的异常情况,对转入的正向操作进行子事务屏障的改造。
app.MapPost("/api/BarrierTransIn", async (string branch_id, string gid, string op, string trans_type, TransRequest req, IBranchBarrierFactory factory) =>
{
Console.WriteLine($"用户【{req.UserId}】转入【{req.Amount}】请求来了!!! gid={gid}, branch_id={branch_id}, op={op}");
var barrier = factory.CreateBranchBarrier(trans_type, gid, branch_id, op);
using var db = Db.GeConn();
await barrier.Call(db, async (tx) =>
{
var c = Interlocked.Increment(ref _errCount);
// 模拟一个超时执行
if (c > 0 && c < 2) await Task.Delay(10000);
Console.WriteLine($"用户【{req.UserId}】转入【{req.Amount}】正向操作,gid={gid}, branch_id={branch_id}, op={op}");
await Task.CompletedTask;
});
return Results.Ok(TransResponse.BuildSucceedResponse());
});
这里通过一个超时执行来让 dtm 进行转入正向操作的重试。
同样的,我们再调整一下调用方,把转入的正向操作也替换成上面带子事务屏障的接口。
var saga = new Saga(dtmClient, gid)
.Add(outApi + "/TransOut", outApi + "/TransOutCompensate", userOutReq)
.Add(inApi + "/BarrierTransIn", inApi + "/BarrierTransInCompensate", userInReq)
;
再来运行这个例子。

可以看到转入的正向操作确实是触发了多次,第一次实际上是成功,只是响应比较慢,导致 dtm 认为是失败了,触发了第二次请求,但是第二次请求并没有执行业务操作,而是输出了
Will not exec busiCall, isNullCompensation=False, isDuplicateOrPend=True
这个就表明了,这个请求是个重复请求,是不应该执行业务方法的,保证了幂等。
到这里,可以看出,子事务屏障确实解决了幂等和空补偿的问题,大大降低了业务判断的复杂度和出错的可能性。
写在最后
在这篇文章里,也通过几个例子,完整给出了编写一个 SAGA 事务的过程,涵盖了正常成功完成,异常情况,以及成功回滚的情况。希望对研究分布式事务的您有所帮助。
本文示例代码: DtmSagaSample
参考资料
聊一聊如何用C#轻松完成一个SAGA分布式事务的更多相关文章
- 聊一聊如何用C#轻松完成一个TCC分布式事务
背景 银行跨行转账业务是一个典型分布式事务场景,假设 A 需要跨行转账给 B,那么就涉及两个银行的数据,无法通过一个数据库的本地事务保证转账的 ACID ,只能够通过分布式事务来解决. 在 聊一聊如何 ...
- 关于如何实现一个Saga分布式事务框架的思考
关于Saga模式的介绍,已经有一篇文章介绍的很清楚了,链接在这里:分布式事务:Saga模式. 关于TCC模式的介绍,也已经有一篇文章介绍的很清楚了,链接在这里:关于如何实现一个TCC分布式事务框架的一 ...
- 记一个Redis分布式事务锁
package com.mall.common; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory. ...
- [跨数据库、微服务] FreeSql 分布式事务 TCC/Saga 编排重要性
前言 FreeSql 支持 MySql/SqlServer/PostgreSQL/Oracle/Sqlite/Firebird/达梦/Gbase/神通/人大金仓/翰高/Clickhouse/MsAcc ...
- 如何用 React Native 创建一个iOS APP?(三)
前两部分,<如何用 React Native 创建一个iOS APP?>,<如何用 React Native 创建一个iOS APP (二)?>中,我们分别讲了用 React ...
- 如何用 React Native 创建一个iOS APP?(二)
我们书接上文<如何用 React Native 创建一个iOS APP?>,继续来讲如何用 React Native 创建一个iOS APP.接下来,我们会涉及到很多控件. 1 AppRe ...
- Cordova之如何用命令行创建一个项目(完整示例)
原文:Cordova之如何用命令行创建一个项目(完整示例) 1. 创建cordova项目 (注意:当第一次创建或编译项目的时候,可能系统会自动下载一些东西,需要一些时间.) 在某个目录下创建cordo ...
- 如何用for..of.. 遍历一个普通的对象?
如何用for..of.. 遍历一个普通的对象? 首先了解一下for..of..: 它是es6新增的一个遍历方法,但只限于迭代器(iterator), 所以普通的对象用for..of遍历 是会报错的.下 ...
- Oracle19c 如何用rman duplicate 克隆一个数据库。(Backup-Based, achive log)
Oracle19c 如何用rman duplicate 克隆一个数据库.(Backup-Based, achive log) 首先克隆有两种方法,一种是Backup-Based,一种是Active方式 ...
随机推荐
- MySQL实现主从库,AB复制配置
AB复制是一种数据复制技术,是myslq数据库提供的一种高可用.高性能的解决方案. AB复制的模式:一主一从 .一主多从.双主.多主多从 复制的工作原理:要想实现ab复制,那么前提是master上必须 ...
- 【LeetCode】993. Cousins in Binary Tree 解题报告(C++ & python)
作者: 负雪明烛 id: fuxuemingzhu 个人博客: http://fuxuemingzhu.cn/ 目录 题目描述 题目大意 解题方法 DFS BFS 日期 题目地址:https://le ...
- 【LeetCode】922. Sort Array By Parity II 解题报告(Python)
作者: 负雪明烛 id: fuxuemingzhu 个人博客: http://fuxuemingzhu.cn/ 目录 题目描述 题目大意 解题方法 使用奇偶数组 排序 奇偶数位置变量 日期 题目地址: ...
- <学习opencv>绘画和注释
/*=========================================================================*/ // 绘画 和 注释 /*========= ...
- Java基础周测一、二(50题)
一.单选题 (共50题,250分) 1.下列选项不可作为Java语言变量名的是( ). A. a1 B. $1 C. _1 D. 21 正确答案: D 2.有一段Java应用程序,它的类名是a1 ...
- 为什么操作dom会消耗性能
因为对DOM的修改为影响网页的用户界面,重绘页面是一项昂贵的操作.太多的JavaScript DOM操作会导致一系列的重绘操作,为了确保执行结果的准确性,所有的修改操作是按顺序同步执行的.我们称这个过 ...
- Java二、八、十、十六进制介绍
1.说明 在Java中整数有四种表示方式, 分别为十进制,二进制,八进制,十六进制, 其中十进制就是平常最熟悉,使用最多的进制: 二进制是在计算机中使用最多的进制, 八进制和十六进制都是基于二进制的, ...
- Jenkins安装、配置与说明
Jenkins是一个开源的.提供友好操作界面的持续集成(CI)工具,主要用于持续.自动的构建/测试软件项目.监控外部任务的运行. 这么解释很抽象,举个例子,我们开发完一个功能,我们要将项目发布打包好, ...
- 接口调试没有登录态?用whistle帮你解决
页面的域名是 a.com,接口的域名为 b.com,这是跨域的因此不会将 cookie 带过去的,也就没有登录态. 解决方法:利用 whistle 的 composer 功能. whistle git ...
- python 日志logging设置按天进行保存,保存近7天,过期日志自动清理
参考文章(写的很详细):https://www.cnblogs.com/xujunkai/p/12364619.html 前言: 跑接口自动化或者其他程序运行时,如果只能保存一份log文件,可能会存在 ...