美文网首页Java
分布式事务的解决方案

分布式事务的解决方案

作者: 最是光阴化浮沫_ | 来源:发表于2022-12-24 00:21 被阅读0次

    前言

    先声明,本文不会介绍诸如ACID、2PC、CAP等概念性的问题(要介绍也是Ctrl CV (:🐶 ),想了解的同学可自行Google~ 本文只记录笔者工作中遇到的事务问题,以及解决方案。

    在工作中,事务问题是比较常见的,同时也是比较危险的,稍一不注意就会背P0事故。那我们在工作中要如何解决事务问题,保证业务安全运行呢?

    试想一个业务场景:用户在电商网站下单某个商品,这时需要进行两个操作:

    1. 更改订单状态
    2. 将商品从购物车中移除

    如果操作1成功了,操作2却失败。这时用户看到商品仍在购物车中,以为没有下单成功,又再次点击下单,造成重复订单。

    如果操作1失败了,操作2却成功。这时显示下单失败,但是购物车中的商品被移除了,用户对此也会产生疑惑。

    综上,操作1、2 只能同时成功,或者同时失败。 否则就会出现各种想象不到的异常情况。

    那要怎么保证呢?这时候就需要事务

    1. 数据库事务

    这应该算是最简单的事务问题了,因为常用的数据库本身也支持事务操作。

    针对以上场景,可以编写伪代码:

    // 开启一个事务
    tx := db.Begin()
    // 1.更改订单状态
    tx.updateOrderStatus(xxxx) 
    // 2.将商品从购物车中移除
    tx.removeItemFromShoppingCart(xxxx)
    // 出现错误,回滚操作 1、2
    if err != nil {
        tx.rollback()
        return
     }
     //操作1、2都运行成功,提交事务
     tx.commit()
    

    通过伪代码不难发现,数据库事务只适用于操作同一个DB,但现实的项目中,往往是以微服务划分各自的职责,订单服务和购物车服务甚至都归属于不同的团队,更别说用同一个数据库了。

    这时候就需要使用分布式事务

    2. 事务消息

    还是上文的场景,虽然订单服务和购物车服务归属于不同的服务,但是服务之间的协作可以通过消息队列实现。

    简单来说,就是当更新订单状态成功之后,发送一条消息给购物车服务,然后购物车服务执行移除操作

    image

    这样,乍一看好像没什么问题,但深入思考之后会有几个疑问:

    1. 订单状态更新成功了,但消息发送失败,要如何处理?
    2. 消息发送成功了,要怎么保证购物车服务一定能消费到?

    概括成一句话:如何保证消息的生产端和消费端的事务性

    2.1 事务消息 - 生产端

    image

    还是以用户下单场景解析事务消息是如何发送的:

    1. producer发送订单状态已更新的消息
    2. 消息发送成功
    3. 执行本地事务,这时真正更新订单的状态
    4. 本地事务执行成功,则提交该事务消息,失败则回滚消息。

    但是考虑到网络的原因,在发送commit或rollback的消息丢失了,broker接收不到信息,无法进行下一步操作。

    对于这个问题很好解决,如步骤5:在Producer端提供一个回查的接口,供Broker定期回查本地事务的状态。然后可以根据反查结果决定回滚还是提交事务。

    2.2 事务消息 - 消费端

    话说大部分文章在介绍事务消息时都只侧重于生产端,对消费端一笔带过甚至提都不提 image

    但其实在实战中,如何实现高效、正确的消费端也是一大难题。

    想问大家一个问题,在消费端,是先消费消息再提交commit?还是先提交commit 再消费消息呢?

    其实这两种方式没有对错之分,只是在不同业务下的选择。

    我们就以这两种方式来介绍如何实现消费端的“事务性”

    1. 先消费消息再提交commit

    err := handle(msg)
    if err!=nil{
        return err
    }
    consumer.commit(msg)
    

    这种消费方式本身也具备事务性了,因为只有消息消费成功,才提交偏移量,如果消费失败,Broker则会重新投递。(多次投递失败,则会发送死信队列)

    image

    但这种方式也有两个缺点:

    1. Broker可能会多次投递,造成重复消费,所以消费者要实现好幂等逻辑
    2. 先消费再提交commit意味着不能异步多线程消费,消费速度较慢

    2. 先提交commit再消费

    consumer.commit(msg)
    go handle(msg)
    

    这种实现方式可以运用多线程异步消费,较于方式1能极大提升消费速度。但是同时也带来了隐患。

    因为Broker只投递一次消息,所以处理失败case只能由业务自己去重试。

    image

    通用的方案是设计重试队列,当业务逻辑处理失败时,交由重试队列去处理,当重试超过一定次数,则需要告警人为干预。

    注:这种实现方式,不能保证最终一致性,在极端情况下仍会出现不一致的情况。

    对于事务消息的消费端,两种实现方式都各有利弊,要深入业务调研,从而做出最好的选择。

    对于分布式事务的解决方案,上文介绍了事务消息,对于这种方案,能保证最终的结果是可靠的,过程也非常简单易理解。但是整个过程完全没有任何隔离性可言。

    对于订单和购物车的场景,对隔离性要求不高,所以使用事务消息来解决该种场景是非常合适的。

    但是对于另一个场景:用户下单某个商品,对应两个操作:

    1. 更改订单状态
    2. 扣减商品库存

    如果使用缺乏隔离性的事务消息来处理该场景,会带来一个显而易见的问题“超卖”。

    因为两个客户完全有可能在短时间内都成功购买了同一件商品,而且他们各自购买的数量都不超过目前的库存,但他们购买的数量之和却超过了库存。

    所以就需要使用隔离性更强的分布式事务方案 -- TCC 事务来处理。

    3. TCC

    在具体实现上,TCC 较为烦琐,它是一种业务侵入式较强的事务方案。要求业务处理过程必须拆分为“预留业务资源”和“确认/释放消费资源”两个子过程。如同 TCC 的名字所示,它分为(Try、Confirm、Cancel)三个阶段。

    图片
    • Try:尝试执行阶段,完成所有业务可执行性的检查(保障一致性),并且预留好全部需用到的业务资源(保障隔离性)。
    • Confirm:确认执行阶段,不进行任何业务检查,直接使用 Try 阶段准备的资源来完成业务处理。Confirm 阶段可能会重复执行,因此本阶段所执行的操作需要具备幂等性。
    • Cancel:取消执行阶段,释放 Try 阶段预留的业务资源。Cancel 阶段可能会重复执行,也需要满足幂等性。

    业务时序图:

    图片
    1. 订单服务发起事务请求,库存服务&积分服务预留业务资源(冻结库存、预添加积分)

    2. Try阶段全部成功,完成业务操作(扣减库存,为会员添加积分)

    3. Try阶段有操作失败或超时,取消业务操作(释放库存、取消添加积分)

    总结

    分布式事务有多种解决方案,同一种方案,根据业务的不同也有不同的实现方式。所以要深入业务,选择一个最合适的方案。

    相关文章

      网友评论

        本文标题:分布式事务的解决方案

        本文链接:https://www.haomeiwen.com/subject/xipiqdtx.html