【橙子老哥】C# 实操分布式事务解决方案

科技   2024-11-18 08:03   广东  

hello,大家好,今天来到橙子老哥的分享时间,希望大家一起学习,一起进步。

欢迎加入.net意社区,第一时间了解我们的动态,文章第一时间分享至社区

社区官方地址:https://ccnetcore.com (上千.neter聚集地)

官方微信公众号:搜索 意.Net

添加橙子老哥微信加入官方微信群:chengzilaoge520

上一篇我们实操了高并发分布式缓存的解决方案, 这篇我们接着分布式的话题,使用c#去实操了一下分布式事务问题的解决方案

相信很多人已经对分布式事务这种面试八股文很熟悉了,说个七七八八不成问题,网上也有很多教程,但是多偏向于理论,没有实操,今天橙子老哥使用c#,带大家把整个流程落地一遍

希望下次遇到这个问题,能回想到橙子老哥的这篇文章,就是这篇文章的意义了

1、事务-ACID

长话短说,理论知识不能少:一个事务有四个基本特性,也就是我们常说的(ACID)。

  1. 1. Atomicity(原子性) :事务是一个不可分割的整体,事务内所有操作要么全做成功,要么全失败。

  2. 2. Consistency(一致性) :务执行前后,数据从一个状态到另一个状态必须是一致的(A向B转账,不能出现A扣了钱,B却没收到)。

  3. 3. Isolation(隔离性):多个并发事务之间相互隔离,不能互相干扰。

  4. 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  {getprivate 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. 1. 2PC (悲观锁)

  2. 2. 3PC (悲观锁)

  3. 3. TCC (乐观锁)

  4. 4. 消息队列 (异步)

其中,3PC是对2PC的补充,2PC和3PC是更针对与资源(多数据库)事务的情况,TCC增对应用接口

接下来,我们实操一下,这几个到底是个啥

3、2PC

想到分布式的一致性,那肯定离不开中心化,我们是否可以将多个服务的事务通过一个中心化的事务协调器 进行统一管理?

每个执行操作,都先问问这个事务协调器 ,所有人说可以,我们就执行,有人执行失败了,其他成功的全部回滚,简单好记,无脑粗暴

那就整一个中心的事务协调器,然后执行的业务的时候,先让各各服务预执行事务,都没问题,再让他们都提交事务即可

流程图:

借图

分为两步:Prepare预执行Commit提交

我们代码实现下:

情况22pc悲观并发控制

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提交

情况33pc


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相关知识内容

与你一起学习,一起进步


DotNet NB
.NET 技术学习分享,社区热点分享,专注为 .NET 社区做贡献,愿我们互相交流学习,共同推动社区发展
 最新文章