hello,大家好,今天来到橙子老哥的分享时间,希望大家一起学习,一起进步。
欢迎加入.net意社区,第一时间了解我们的动态,文章第一时间分享至社区
社区官方地址:https://ccnetcore.com
(上千.neter聚集地)
官方微信公众号:搜索 意.Net
添加橙子老哥
微信加入官方微信群:chengzilaoge520
上一篇我们实操了高并发分布式缓存的解决方案, 这篇我们接着分布式的话题,使用c#去实操了一下分布式事务问题的解决方案
相信很多人已经对分布式事务
这种面试八股文很熟悉了,说个七七八八不成问题,网上也有很多教程,但是多偏向于理论,没有实操,今天橙子老哥使用c#,带大家把整个流程落地一遍
希望下次遇到这个问题,能回想到橙子老哥的这篇文章,就是这篇文章的意义了
1、事务-ACID
长话短说,理论知识不能少:一个事务有四个基本特性,也就是我们常说的(ACID)。
1. Atomicity(原子性) :事务是一个不可分割的整体,事务内所有操作要么全做成功,要么全失败。
2. Consistency(一致性) :务执行前后,数据从一个状态到另一个状态必须是一致的(A向B转账,不能出现A扣了钱,B却没收到)。
3. Isolation(隔离性):多个并发事务之间相互隔离,不能互相干扰。
4. Durablity(持久性) :事务完成后,对数据库的更改是永久保存的,不能回滚。
以上这些特征相信大家在使用数据库的时候,已经了如指掌了,这里也不再过多赘述。
2、不处理分布式事务
通常的,如果是在单体架构中,为了保持数据的一致性,只需要在批量执行数据库操作的时候,开启事务,在最终完成操作的时候,再提交事务即可
但是如果各各操作是分布在不同的程序/数据库/服务器上,我们还按照原先的方式会怎么样呢?
废话少说,我们直接实操,准备代码:
这里我们模拟一个经典场景,订单服务
和库存服务
,用户创建订单,订单数+1,库存数量-1,像这种场景,我们必须要确保数据的一致性,如果出现了订单加的多了,库存减的少了,那不就产生了超卖的严重生产事故?
//情况1,无分布式事务处理
//订单服务客户端
var orderServiceClient =new OrderServiceClient();
//库存服务客户端
var storeServiceClient =new StoreServiceClient();
//模拟执行10次下单
var i = 10;
while (i > 0)
{
try
{
//入口,用户进行创建订单
orderServiceClient.CreateOrder(storeServiceClient);
}
catch(Exception e)
{
Console.WriteLine(e.Message);
}
finally
{
//打印数据库订单和库存数量
Console.WriteLine($"当前订单数量:{OrderServiceClient.Order},库存数量:{StoreServiceClient.Store}");
i--;
}
}
Console.WriteLine("完成");
class OrderServiceClient
{
public static int Order {get; private set;}=10;
private Action? _tran;
//新增订单到数据库(不会真正执行,返回委托,事务预处理,执行委托就是提交事务)
Action AddOrderToDb()
{
return ()=> Order+=1;
}
//业务代码
public void CreateOrder(StoreServiceClient storeClient)
{
//订单服务开启事务
_tran = AddOrderToDb();
storeClient.UpdateStore();
//订单提交事务
_tran.Invoke();
}
}
//同理
class StoreService Client
{
public static int Store{get;privateset;}=20;
private Action? _tran;
//新增订单
Action DecreaseStoreToDb()
{
return ()=> Store-=1;
}
public void UpdateStore()
{
//库存服务开启事务
_tran = DecreaseStoreToDb();
_tran.Invoke();
}
}
在上面的例子中,我们在订单服务,调用了自己的数据库,同时又远程调用了库存服务,双方各自执行事务操作
当没有一方出现错误、网络完美、服务器稳定、内存够用,好像怎么执行也不会有任何问题
CAP:我又来了,分布式中要满足分区容错,一致性和可用性就不能同时抓
上面执行中,很明显有个地方容易出问题,如果在库存服务事务已经提交,返回的时候,网络波动订单服务没有收到结果
,订单报错了,取消事务,库存执行完了,新增库存,导致数据不一致
//业务代码
public void CreateOrder(StoreServiceClient storeClient)
{
//订单服务开启事务
_tran = AddOrderToDb();
storeClient.UpdateStore();
//情况1
thrownew Exception("订单服务网络波动,无法收到库存服务的回应,或者收到回应,但是事务没有提交宕机");
//订单提交事务
_tran.Invoke();
}
//初始数据
当前订单数量:10,库存数量:20
//结果
当前订单数量:10,库存数量:10
为了解决这种分布式事务问题,行业内提出了非常多的方案,比较经典常用的是以下4个
1. 2PC (悲观锁)
2. 3PC (悲观锁)
3. TCC (乐观锁)
4. 消息队列 (异步)
其中,3PC是对2PC的补充,2PC和3PC是更针对与资源
(多数据库)事务的情况,TCC增对应用接口
接下来,我们实操一下,这几个到底是个啥
3、2PC
想到分布式的一致性,那肯定离不开中心化,我们是否可以将多个服务的事务通过一个中心化的事务协调器
进行统一管理?
每个执行操作,都先问问这个事务协调器
,所有人说可以,我们就执行,有人执行失败了,其他成功的全部回滚,简单好记,无脑粗暴
那就整一个中心的事务协调器
,然后执行的业务的时候,先让各各服务预执行
事务,都没问题,再让他们都提交事务
即可
流程图:
分为两步:Prepare预执行
,Commit提交
我们代码实现下:
情况2,2pc悲观并发控制
var orderServiceClient =new OrderServiceClient();
var storeServiceClient =new StoreServiceClient();
//我们引入一个新的客户端,专门来协调各各服务之间的事务
var tranServiceClient =new TranServiceClient();
var i =10;
while(i>0)
{
try
{
//用户触发事务动作,通过事务协调者统一调度
tranServiceClient.CreateOrder(orderServiceClient, storeServiceClient);
}
catch(Exception e)
{
Console.WriteLine(e.Message);
}
finally
{
Console.WriteLine($"当前订单数量:{OrderServiceClient.Order},库存数量:{StoreServiceClient.Store}");
i--;
}
}
Console.WriteLine("完成");
class OrderServiceClient
{
public static int Order{get;private set;}=10;
private Action? _tran;
//订单服务预执行方法
public bool Prepare()
{
_tran =AddOrderToDb();
return true;
}
//订单服务提交方法
public bool Commit()
{
_tran.Invoke();
return true;
}
//落库
Action AddOrderToDb()
{
return ()=> Order+=1;
}
}
//同理
class StoreServiceClient
{
public static int Store{get;private set;}=20;
private Action? _tran;
public bool Prepare()
{
_tran =DecreaseStoreToDb();
return true;
}
public bool Commit()
{
_tran.Invoke();
return true;
}
//新增订单
Action DecreaseStoreToDb()
{
return ()=>Store-=1;
}
}
//事务协调者
class TranServiceClient
{
public void CreateOrder(OrderServiceClient client1, StoreServiceClient client2)
{
//调用两个服务接口,预执行,只有都成功才走下一步
if(Prepare(client1, client2))
{
//预执行都成功了,再全部统一提交
if(Commit(client1,client2))
{
Console.WriteLine("事务全部完成提交");
}
else
{
throw new Exception("执行Commit存在有一方失败,成功一方进行回滚");
}
}
else
{
throw new Exception("准备失败存在失败,不执行Commit");
}
}
//预先执行事务内容
bool Prepare(OrderServiceClient client1, StoreServiceClient client2)
{
var res1 = client1.Prepare();
var res2 = client2.Prepare();
return res1 && res2;
}
//真正提交事务
bool Commit(OrderServiceClient client1, StoreServiceClient client2)
{
var res1 = client1.Commit();
var res2 = client2.Commit();
return res1 && res2;
}
}
这里,我们探究下原理,是靠什么保持的一致性?在事务协调者分别去调用两个服务的方法,只有等2个服务都返回了结果,才能进入下一个阶段!
答案,就在等字,意味着事务协调者
认为任何请求都可能失败,不相信他们会成功,只有锁住了,都返回了结果,才允许走下一步,这就是悲观锁
,虽然能保持一致性,但会一定程度降低吞吐量
同时,上面也可以看出,太依赖了这个中心
的事务协调者,如果它蹦了,那全玩完了
另外,2pc还有个严重的问题,因为我们只有2个阶段,当我们预提交的时候,虽然没有实际提交,但是也很消耗资源
如果我们分布式的2个服务,库存其实早就已经没有了,每次都去预执行事务,最后又不提交回滚,对订单服务会有一个连带效应,它也要每次去预执行事务,特别是微服务中,拆的非常细,很浪费资源
4、3PC
3PC,本质上就是为了解决上面浪费资源的问题
相比于2pc,它多了一个步骤,先把各各服务询问一下,准备好了没有,这里只是做一个基础的校验,如果库存都没有,那后面事务也不需要去提交再回滚的操作
分为三步:CanCommit准备
,Prepare预执行
,Commit提交
情况3,3pc
var orderServiceClient =new OrderServiceClient();
var storeServiceClient =new StoreServiceClient();
var tranServiceClient =new TranServiceClient();
var i =10;
while(i >0)
{
try
{
tranServiceClient.CreateOrder(orderServiceClient, storeServiceClient);
}
catch(Exception e)
{
Console.WriteLine(e.Message);
}
finally
{
Console.WriteLine($"当前订单数量:{OrderServiceClient.Order},库存数量:{StoreServiceClient.Store}");
i--;
}
}
Console.WriteLine("完成");
class OrderServiceClient
{
public static intOrder{get;private set;}=10;
private Action? _tran;
public bool CanCommit()
{
return true;
}
public bool Prepare()
{
_tran =AddOrderToDb();
return true;
}
public bool Commit()
{
_tran.Invoke();
return true;
}
//新增订单
Action AddOrderToDb()
{
return ()=>Order+=1;
}
}
classStoreServiceClient
{
public static int Store{get;privateset;}=20;
private Action? _tran;
public bool CanCommit()
{
return true;
}
public bool Prepare()
{
_tran =DecreaseStoreToDb();
return true;
}
public bool Commit()
{
_tran.Invoke();
return true;
}
//新增订单
Action DecreaseStoreToDb()
{
return ()=>Store-=1;
}
}
class TranServiceClient
{
public void CreateOrder(OrderServiceClient client1, StoreServiceClient client2)
{
//多了一步是否能执行的步骤
if(CanCommit(client1, client2))
{
//预执行事务
if(Prepare(client1, client2))
{
//真正去执行事务
if(Commit(client1, client2))
{
Console.WriteLine("事务全部完成提交");
}
else
{
thrownew Exception("执行Commit存在有一方失败,成功一方进行回滚");
}
}
else
{
thrownew Exception("准备失败存在失败,不执行Commit");
}
}
else
{
thrownew Exception("是否能提交阶段存在失败,通知之后,啥也不做");
}
}
bool CanCommit(OrderServiceClient client1, StoreServiceClient client2)
{
var res1 = client1.CanCommit();
var res2 = client2.CanCommit();
return res1 && res2;
}
//预先执行事务内容
bool Prepare(OrderServiceClient client1, StoreServiceClient client2)
{
var res1 = client1.Prepare();
var res2 = client2.Prepare();
return res1 && res2;
}
//真正提交事务
bool Commit(OrderServiceClient client1, StoreServiceClient client2)
{
var res1 = client1.Commit();
var res2 = client2.Commit();
return res1 && res2;
}
}
上面的代码,和2pc区别不大,只是对了一个判断是否能执行的询问阶段
5、TCC
2pc和3pc都是悲观锁的实现,而TCC是乐观锁的实现,它的全称是Try-Confirm-Cancel
,看到这个是否很熟悉?对,这个跟数据库的事务一样
TCC认为大部分请求都是ok的,直接通过,不进行等待,如果小部分请求出现问题,那我们去回滚取消它就好了
分为三步:Try尝试
,Confirm确认
,Cancel取消
//情况4,TCC ,Try、Confirm、Cancel
var orderServiceClient =new OrderServiceClient();
var storeServiceClient =new StoreServiceClient();
var i =10;
while(i>0)
{
try
{
orderServiceClient.CreateOrder(storeServiceClient);
}
catch(Exception e)
{
Console.WriteLine(e.Message);
}
finally
{
Console.WriteLine($"当前订单数量:{OrderServiceClient.Order},库存数量:{StoreServiceClient.Store}");
i--;
}
}
Console.WriteLine("完成");
class OrderServiceClient
{
public static intOrder {get;private set;}=10;
private Action? _tran;
public void CreateOrder(StoreServiceClient storeClient)
{
//订单服务开启事务-如果这里失败,直接拦截
_tran =AddOrderToDb();
try
{
//如果这里失败,catch进行回滚
storeClient.TryUpdateStore();
}
catch(Exception e)
{
storeClient.Cancel();
//当前事务也取消,所有操作回滚
_tran =null;
return;
}
//如果没有任何异常,确认执行
try
{
storeClient.Confirm();
}
catch(Exception e)
{
_tran =null;
return;
}
//上方如果库存服务执行成功,没报错,订单服务也提交
_tran.Invoke();
//订单提交事务
}
//新增订单
Action AddOrderToDb()
{
return ()=> Order+=1;
}
}
class StoreServiceClient
{
publicstaticintStore{get;privateset;}=20;
private Action? _tran;
//新增订单
Action DecreaseStoreToDb()
{
return ()=>Store-=1;
}
public void TryUpdateStore()
{
//库存服务开启事务
_tran =DecreaseStoreToDb();
}
public void Confirm()
{
_tran.Invoke();
}
public void Cancel()
{
_tran =null;
}
}
上述代码我们2个服务交互,可以不需要中心服务共用,本质上原理是将自己服务的事务和另一个服务的事务绑定在一块,我们都先去尝试
,有一方失败
了,我们都去回滚
。
5、消息队列
另外,还有一种方案也很常见,大部分分布式中出问题,都是网络出的问题,导致多个请求响应不一致,那我们如何去避免这种问题?答案还是一个重试
前面的方案给的答案是算执行失败,全部回滚
如果业务允许,只是网络这种偶发性问题,我们通过将消息存放到消息队列中,反复重试,直到成功,能确保一定成功,确保最终一致性
就不需要回滚操作了
还是前面的例子,我们即将开启双11的秒杀活动,先把库存总数缓存放到订单服务中,每次下单全无脑塞给消息队列,一个消息算一个任务,库存服务去消费,就算有网络中断等意外情况,那就重试,最后减少了库存再通知用户
这种方案,库存服务虽然存在短暂时间不一致,但能够确保最终一致性,就算是消费者出了问题,反正我们消息持久化了,自己搂出来,人工处理都行
(主要还有一点,简单~)
都说到这里了,不得不提我们.net一个著名的解决分布式事务问题的开源项目
CAP:https://github.com/dotnetcore/CAP
最后的最后 - 意.Net 小程序即将上线 啦!各位敬请期待!--爱你们的橙子老哥
.Net意社区,高频发布原创有深度的.Net相关知识内容
与你一起学习,一起进步