Skip to content

Latest commit

 

History

History
382 lines (273 loc) · 30.4 KB

tcc-using-seata.md

File metadata and controls

382 lines (273 loc) · 30.4 KB

使用Seata来实现TCC

TCC的交互过程

       TCCTRY-CANCEL/CONFIRM的缩写(以下简称为TCC),它是一种柔性事务的代表技术,相关描述可以在 https://weipeng2k.github.io/hot-wind/book/compensation-and-tcc.html 找到。TCC本质上仍是一种两阶段提交(以下简称为2PC)的变体,也就是在TRY阶段将资源的变更已经做完,达到万事俱备只欠东风的状态,而之后所有的事务参与者如果对此无异议,则事务发起者将会请求整体提交,也就是触发CONFIRM,反之会执行CANCEL

       TCC需要事务协调者的参与来完成CANCELCONFIRM的触发工作。TCC的全局事务由事务参与者发起,所涉及的事务参与者都会创建本地分支事务,这点同2PC类似,而本地事务的提交和回滚操作就分别对应于CONFIRMCANCELTRY-CONFIRM的过程如下图所示:

       可以看到全局事务由应用程序发起,一般应用程序也是一个事务参与者,它承担全局事务管理角色,负责全局事务的提交或者回滚。由应用程序在事务逻辑中请求不同的事务参与者,收到请求的事务参与者会将本地事务作为一个分支事务和全局事务形成关联,同时也会将上述信息注册至事务协调者。如果应用程序事务逻辑执行完成,各事务参与者均响应正常,代表全局事务可以提交,应用程序则会通知事务协调者提交全局事务,事务协调者随后触发各个参与者的确认(CONFIRM)逻辑。

       如果事务逻辑执行出错,则会执行TRY-CANCEL过程,该过程如下图所示:

       该过程和TRY-CONFIRM过程相似,在事务逻辑中调用事务参与者出现错误,则应用程序会通知事务协调者对当前全局事务进行回滚,事务协调者随后触发各个参与者的取消(CANCEL)逻辑。

TCC的主要优势

       TCC的主要优势在于有较高的吞吐量。以电商业务的商品订购场景为例,买家订购商品生成订单,同时会进行商品库存扣减,这个过程需要保证如果库存满足订购的数量,订单有效,反之则订单无效,也就是说订购过程是一个事务。简单起见,整个过程涉及三个事务参与者,分别是:交易前台、订单和商品库存三个微服务系统,交易前台会调用订单和商品库存两个微服务完成订购。

       如果使用2PC来确保该分布式事务,假设:订购过程中,交易前台微服务调用订单微服务生成了订单,可随后调用商品库存微服务出现错误(库存不足或调用超时),该全局事务需要进行回滚。从资源占用的角度出发,上述过程如下图所示:

       可以看到参与到该分布式事务的三个微服务,会将参与事务的资源(比如:订单数据和商品库存数据等)进行锁定,且锁定时间会横跨两个阶段。商品库存微服务反馈中止,全局事务需要中止,订单微服务依旧要等待协调者的通知才能继续,这使得订单资源被长时间锁定。在2PC模式下,整个系统的吞吐量存在短板,如果事务参与者(或某个参与者)存在比较耗时的操作,将会拖慢整个系统的响应时间。

       如果换用TCC来处理这个场景,TCC事务参与者会在接受到请求后即刻提交本地事务,事务参与者之间不会由于对方处理耗时过长而相互影响,该过程对资源的占用如下图所示:

       从TCC的交互过程可以看到各个事务参与者所负责的本地事务在接收到调用请求后就会开始处理,一旦完成就会提交。订单微服务在接受到交易前台微服务的调用后就会进行订单创建,不会等待商品库存微服务的处理结果。当事务协调者发送取消事件给订单微服务时,订单微服务会根据通知中的事务上下文(比如:订单ID)来取消对应的订单,需要注意,取消订单的操作也是一个本地事务的提交。

       TCC对资源的锁定占用时间会比2PC短很多,呈现出一种对资源离散且短时占据的形态,而非2PC在整个事务周期内都会整块长时间的锁定资源。由于资源锁定时间变短,单位时间处理本地事务数量自然增多,使得TCC模式下,整个系统的吞吐量会有显著的提升。

在微服务架构下,可以通过适当提升TCC链路上较为耗时的微服务实例数量,使的整个系统的吞吐量进一步提升。

TCC的使用代价

       TCC对资源锁定时间的减少无疑会提升系统的吞吐量,有更好的性能表现,但任何好处都会有交换的代价,而这些代价主要体现在以下两个方面。

产品交互方式的改变

       在之前的商品订购场景中,2PCTCC模式的不同之处除了在资源锁定上,在数据的可见性上也有非常大的不同。2PC在处理该场景时,当订单由于库存不足生成失败,用户(或买家)在后台是无法看到订购失败的订单,并且在数据库层面也不会出现订购失败的订单,原因是2PC追求强一致性,数据被回滚了。用户就是感觉订购失败,可能是网络或者系统不稳定,那接下来再试一下就好。

       TCC在处理该场景时,订单和商品库存之间没有强依赖,虽然在一个全局事务中,但是订单数据会生成,虽然可以通过状态位等技术手段使用户无法查看到该失败订单,可是它确实在数据库中生成了,只是在等待随后对生成的数据做取消或确认操作,这个过程是一种最终一致性的体现。当然可以在随后的CANCEL事件处理中将该订单删除,但是这些特殊的处理逻辑已经侵入到了系统实现中,并不是一个好的选择。

       适当改变产品的交互方式以适应TCC模式是一个更好的选择。由于TCC是两段异步的处理模式,产品需要一定程度上的面向失败设计,将订购失败认为是一种正常的情况,用户不仅可以看到失败的订单而且还能看到失败的原因。这样刚生成的订单,可以展示给用户系统在处理中的提示信息,一旦CONFIRM或者CANCEL通知完成处理后,就可以反馈给用户最终的处理结果。

       产品交互方式的适当改变,增加些许面向失败和容错的设计,会使得TCC模式使用的更加自然,同时也能够获得更好的用户体验,最终为业务产品和技术实现做到了对齐。

技术实现方式的改变

       2PC本质是在数据层面做分布式事务,它不需要应用代码做改造,而TCC实质是应用层面的2PC,它需要应用代码做改造来满足TCC所需要的语义。

       微服务接口定义需要做出改变以适应TCC,以订单微服务的订单生成接口为例,在2PCTCC模式下的不同如下图所示:

       可以看到在2PC(上图左半部分)模式下,应用对于接口的定义不会受到约束,这点是2PC的优势,事务协调者同数据源进行协作实现分布式事务,一定程度上对应用透明。而TCC(上图右半部分)模式下,应用成为分布式事务中的主角,它需要同事务协调者进行交互,所以在接口定义上需要定义出数据的创建、取消和确认三个不同的方法来分别应对TCC中的TRYCANCELCONFIRM逻辑。

       对于2PC而言,如果准备阶段有事务参与者反馈了中止,则在后续的提交阶段,数据源会将数据进行回滚。TCC没有依靠数据源来完成这个工作,而是需要用户编写取消的逻辑来处理之前TRY阶段生成的数据,因此TCC的取消对于数据源而言,又是一次新的提交。

       在上图中的TCC模式下,对于订单生成服务OrderCreateService定义了三个方法createOrdercancelOrderconfirmOrder分别应对订单生成过程中的TRYCANCELCONFIRM逻辑。TCC除了对应用接口定义产生了侵入,对于这些方法的实现也有隐性的要求,也就是方法实现需要做到幂等。以cancelOrder为例,在取消订单时需要先获取订单,根据订单的数据做出判断(比如:订单是新生成、没有被取消且没有被确认),符合要求的情况下才能够进行取消处理,这么做的原因在于事务协调者对于应用的通知可能会由于网络(或其他)原因出现延迟或重复通知,所以需要由应用自身的代码逻辑保证幂等。

Seata支持TCC

       TCC依赖事务协调者来完成对全局事务(和分支事务)的状态维护与驱动。事务协调者接受事务参与者(也就是微服务应用)本地分支事务的注册,同时在全局事务提交或回滚时调用各个事务参与者相应的确认或取消接口。

       TCC事务协调者的开源实现目前在业界有多个,其中使用广泛、功能完备且稳定可靠的参考实现当属Seata

本文使用的Seata版本是2021年4月发布的1.4.2版本,由于讲述内容主要涉及其TCC功能的使用,如果需要更详细了解Seata,可以访问seata.io,参考其官方文档。

什么是Seata

       Seata是一款开源的分布式解决方案,支持诸如:AT(类似2PC)、TCCSAGAXA多种事务模式。Seata是基于C/S架构的中间件,微服务应用需要依赖Seata客户端来完成和Seata服务端的通信,通信协议基于Seata自有的RPC协议。微服务应用通过Seata远程调用完成分布式事务的开启、注册,同时该通信链路也接受来自Seata服务端(由于事务状态变更而带来)的回调通知,其架构如下图所示:

       使用Seata之前,需要先部署Seata服务端,服务端会将Seata服务注册到注册中心,目的是当依赖Seata客户端的微服务应用启动时,可以通过注册中心订阅到Seata服务,使Seata服务以集群高可用的方式暴露给使用者。

       Seata的客户端和服务端有许多参数可以配置,比如:事务提交的重试次数或间隔时间,一般情况这些配置可以放在微服务应用或者Seata服务端上,但配置项过多带来了较高的维护成本。Seata支持将配置存放在配置中心上,通过配置中心上统一的管理起来,方便使用者进行运维。

       Seata服务端可以通过依赖外部的数据存储将事务上下文等信息持久化存储起来,使Seata服务端无状态化,从而进一步提升稳定性。Seata可以选择多种开源的注册和配置中心以及数据存储,如下表所示:

类型 可选产品 功能描述
注册中心 文件、ZooKeeperRedisNacosETCD Seata服务端注册Seata服务,Seata客户端进行服务发现
配置中心 文件、ZooKeeperNacosETCDSpringCloud Config 统一管理和维护Seata的配置信息
数据存储 文件、关系数据库和Redis 持久化存储全局事务、分支事务以及事务上下文信息

       微服务应用通过依赖Seata客户端与Seata服务端进行通信,Seata客户端通过AOP以及对主流RPC框架的扩展来完成对微服务应用间远程调用的拦截。在远程调用前开启(或注册)分布式事务,当Seata服务端发现事务状态变化时,再回调部署在微服务应用中的Seata客户端来执行相应的逻辑。

Seata如何支持TCC

       在TCC模式中,由事务管理器(一般也是事务参与者)开启全局事务,在事务逻辑执行过程中,该链路上所有节点(微服务应用)的分布式调用都会注册相应的分支事务,全局事务和分支事务会产生关联。当事务逻辑执行成功,代表全局事务可以提交,事务协调者则会回调各个事务参与者的确认逻辑,反之,回调其取消逻辑。

       可以看到事务的开启和(节点之间的)传播是实现TCC的关键,Seata利用了AOP以及对主流RPC框架进行扩展来提供支持,接下来会简单介绍一下Seata对全局事务开启以及事务传播的相关逻辑。

       在需要全局事务控制的方法上,通过添加注解GlobalTransactional将其标识为全局事务方法,该方法中的逻辑即为事务逻辑,在方法中的远程调用也会被全局事务所管理,其主要接口和类(以及部分主要方法)如下图所示:

       可以看到Seata客户端通过实现spring-aop的方法拦截器来拦截用户的方法执行。Seata将全局事务抽象为GlobalTransaction,它和普通事务一样具备开始、提交和回滚等方法,当拦截到用户方法的调用(或异常)时,会触发全局事务对应的方法。Seata客户端与服务端通信底层基于netty,传输的自有RPC协议为RpcMessage,当事务管理器TransactionManager被调用时,会将相关事务操作远程通知到Seata服务端,可以认为在微服务之间进行业务远程调用的拓扑结构下还对应存在着一层Seata远程调用拓扑结构。

       通过AOP以及远程调用的方式,可以让应用透明的开启全局事务,但在微服务架构下,如何能够做到当前事务在微服务之间传播呢?答案是,扩展应用使用的RPC框架。以Apache Dubbo为例(以下简称Dubbo),可以看到Seata通过扩展Dubbo过滤器的方式,使微服务之间具备了传播事务的能力,部分关键代码如下所示:

@Activate(group = {DubboConstants.PROVIDER, DubboConstants.CONSUMER}, order = 100)
public class ApacheDubboTransactionPropagationFilter implements Filter {

    @Override
    public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
        String xid = RootContext.getXID();
        BranchType branchType = RootContext.getBranchType();

        String rpcXid = RpcContext.getContext().getAttachment(RootContext.KEY_XID);
        String rpcBranchType = RpcContext.getContext().getAttachment(RootContext.KEY_BRANCH_TYPE);
        boolean bind = false;
        if (xid != null) {
            RpcContext.getContext().setAttachment(RootContext.KEY_XID, xid);
            RpcContext.getContext().setAttachment(RootContext.KEY_BRANCH_TYPE, branchType.name());
        } else {
            if (rpcXid != null) {
                RootContext.bind(rpcXid);
                if (StringUtils.equals(BranchType.**TCC**.name(), rpcBranchType)) {
                    RootContext.bindBranchType(BranchType.**TCC**);
                }
                bind = true;
            }
        }
        try {
            return invoker.invoke(invocation);
        } finally {
            // 略
        }
    }
}

       Dubbo提供了对调用链路扩展的能力,这也说明它是一款非常成熟的RPC框架。可以看到在上述代码逻辑中,Seata的扩展点先尝试获取本地事务信息(包括:事务ID和事务模式),然后尝试获取Dubbo请求上下文中对应的远程事务信息。

       如果能够获取到存储在ThreadLocal中的本地事务信息,表明当前代码运行在一个全局事务中,则尝试将事务信息放置到Dubbo请求上下文中,使之能够传递到下一个微服务节点。

       如果本地事务信息没有获取到,但存在远程事务信息,这表明本次调用是Seata事务调用,则需要恢复远程事务信息到当前ThreadLocal中,将全局事务能够连接起来。

       通过扩展DubboFilter,使得Seata的全局事务具备了击鼓传花般的远程传输能力,事务逻辑中所有的分布式远程调用,均会在请求中“沾染”上事务信息,而这些信息也会被Seata服务端所掌握,最终在事务完成时,发起对所有事务参与者的回调。

一个基于Seata的参考示例

       以商品订购场景为例,基于SpringBootDubbo来实现该功能,同时依靠Seata确保分布式事务。示例中的部分业务代码仅打印了参数或结果,目的是方便读者观察执行的过程,由于示例代码(含单元测试)超过1400行,所以接下来仅针对关键代码进行介绍,应用全部代码可以在:https://github.com/weipeng2k/seata-tcc-guide 找到。

部署Seata

       在运行示例前需要部署Seata服务端,Seata服务端一般以集群的方式进行部署,依赖注册和配置中心以及外部存储做到高可用。由于本文主要介绍微服务应用如何使用Seata实现TCC,简单起见采用单节点的方式进行部署。

       可以选择在官网下载Seata服务端,解压后运行seata-server.sh启动,如下图所示:

       默认Seata服务端(注册和配置中心以及外部存储)依赖的是本地文件。

       当然也可以使用Docker进行部署,在安装了Docker的机器上运行如下命令:

docker run --name seata-server -p 8091:8091 -d seataio/seata-server:latest

       该命令在当前机器上启动了Seata服务端,同时暴露了Seata服务端的(默认)端口。

如果不在本机部署Seata服务端,需要记录部署了Seata服务端机器的IP,并且能够确保之后部署的微服务应用能够访问该IP。微服务应用中的配置项seata.service.grouplist.default需要配置为服务端的IP和端口。

应用代码简介

       本示例中商品订购场景涉及三个微服务应用,其相关信息如下表所示:

应用 前台交易微服务 订单微服务 商品微服务
名称 trade-facade order-service product-service
领域实体 订单 商品库存
库存占用明细
接口服务 TradeAction,商品下单接口 OrderCreateService,订单创建服务 ProductInventoryService,商品库存服务
功能描述 接收前端请求,调用OrderCreateService创建订单,同时调用ProductInventoryService扣减对应商品的库存 实现并发布OrderCreateService,维护订单模型与数据 实现并发布ProductInventoryService,维护商品库存相关模型与数据

       用户订购请求通过trade-facade进入,首先会调用order-service生成订单,此时订单的是否可用状态为false,然后trade-facade调用product-service进行库存扣减,如果库存充足则减少商品预扣库存数量同时生成库存占用明细,以上为TRY阶段,相关部分代码如下:

@Component("tradeAction")
public class TradeActionImpl implements TradeAction {

    @DubboReference(group = "dubbo", version = "1.0.0")
    private OrderCreateService orderCreateService;
    @DubboReference(group = "dubbo", version = "1.0.0")
    private ProductInventoryService productInventoryService;

    // fake id generator
    private final AtomicLong orderIdGenerator = new AtomicLong(System.currentTimeMillis());

    @Override
    @GlobalTransactional
    public Long makeOrder(Long productId, Long buyerId, Integer amount) {
        RootContext.bindBranchType(BranchType.**TCC**);
        CreateOrderParam createOrderParam = new CreateOrderParam();
        createOrderParam.setProductId(productId);
        createOrderParam.setBuyerUserId(buyerId);
        createOrderParam.setAmount(amount);
        Long orderId;
        try {
            orderId = orderIdGenerator.getAndIncrement();
            orderCreateService.createOrder(createOrderParam, orderId);
        } catch (OrderException ex) {
            throw new RuntimeException(ex);
        }

        OccupyProductInventoryParam occupyProductParam = new OccupyProductInventoryParam();
        try {
            occupyProductParam.setProductId(productId);
            occupyProductParam.setAmount(amount);
            occupyProductParam.setOutBizId(orderId);
            productInventoryService.occupyProductInventory(occupyProductParam, orderId.toString());
        } catch (ProductException ex) {
            throw new RuntimeException(ex);
        }

        return orderId;
    }
}

       可以看到makeOrder方法上标注了GlobalTransactional注解,表示该方法需要事务保证,同时通过RootContext设置当前的事务模式为TCC

       对于OrderCreateServiceProductInventoryService,也需要增加Seata的注解,使得之后的CANCELCONFIRM通知能够调用到对应的逻辑,以OrderCreateService为例,代码如下:

@LocalTCC
public interface OrderCreateService {

    /**
     * 根据参数创建一笔订单
     *
     * @param param   订单创建参数
     * @param orderId 订单ID
     * @throws OrderException 订单异常
     */
    @TwoPhaseBusinessAction(name = "orderCreateService", commitMethod = "confirmOrder", rollbackMethod = "cancelOrder")
    void createOrder(CreateOrderParam param,
                     @BusinessActionContextParameter(paramName = "orderId") Long orderId) throws OrderException;

    /**
     * <pre>
     * 根据订单ID确认订单
     * </pre>
     *
     * @param businessActionContext 业务行为上下文
     * @throws OrderException 订单异常
     */
    void confirmOrder(BusinessActionContext businessActionContext) throws OrderException;

    /**
     * <pre>
     * 根据订单ID作废当前订单
     * </pre>
     *
     * @param businessActionContext 业务行为上下文
     * @throws OrderException 订单异常
     */
    void cancelOrder(BusinessActionContext businessActionContext) throws OrderException;
}

       可以看到接口声明需要标注LocalTCC注解,同时在TRY阶段(也就是createOrder)方法上标注TwoPhaseBusinessAction注解,而其中commitMethodrollbackMethod分别对应CONFIRMCANCEL阶段方法。通过TwoPhaseBusinessAction注解的声明,Seata会知晓在全局事务提交或回滚时调用该接口的哪个方法。

LocalTCCTwoPhaseBusinessActionBusinessActionContextParameter注解需要标注在接口上才能被Seata所识别,这也是为什么TCC模式对应用的侵入性较强的一个原因。

       如果订购成功,全局事务可以提交,Seata服务端会回调参与事务微服务的CONFIRM逻辑。本示例中,order-serviceconfirmOrder方法会被调用,订单的可用状态会被更新为trueproduct-serviceconfirmProductInventory方法会被调用,真实库存会被扣减,库存占用明细状态会更新为成功。

       如果订购失败,全局事务需要回滚,失败的原因可能是调用order-serviceproduct-service服务出现业务异常,比如:生成订单失败或库存不足,也有可能是系统异常,比如:调用超时或网络传输异常等,Seata服务端会回调参与事务微服务的CANCEL逻辑。本示例中,order-servicecancelOrder方法会被调用,订单可用状态会被更新为falseproduct-servicecancelProductInventory方法会被调用,预扣库存会被增加,库存占用明细状态会更新为取消。

Seata服务端会回调参与事务的微服务,这个参与代表着业务远程调用已经发起,如果没有调用则不会发起相应的回调。比如:在makeOrder方法代码中,逻辑上的事务参与者有trade-facadeorder-serviceproduct-service,但如果makeOrder方法在实际执行中,调用到order-service就抛错了,则CANCEL回调只会通知到trade-facadeorder-service

订购示例演示

       订购示例的逻辑比较简单,先初始化一个商品的库存为20,然后本地模拟10个并发请求用于订购商品,每次订购的数量为3,相关代码如下所示:

@SpringBootApplication
@EnableDubbo
@Configuration
public class TradeApplication implements CommandLineRunner {

    private final ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(10, 10, 5, TimeUnit.SECONDS,
            new LinkedBlockingQueue<>());
    @Autowired
    private TradeAction tradeAction;

    public static void main(String[] args) {
        SpringApplication.run(TradeApplication.class, args);
    }

    @Override
    public void run(String... args) throws Exception {
        tradeAction.setProductInventory(1L, 20);
        CountDownLatch start = new CountDownLatch(1);
        CountDownLatch stop = new CountDownLatch(10);
        AtomicInteger orderCount = new AtomicInteger();
        for (int i = 1; i <= 10; i++) {
            int userId = i;
            threadPoolExecutor.execute(() -> {
                try {
                    start.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                try {
                    tradeAction.makeOrder(1L, (long) userId, 3);
                    orderCount.incrementAndGet();
                } catch (Exception ex) {
                    // Ignore.
                } finally {
                    stop.countDown();
                }
            });
        }

        start.countDown();

        stop.await();

        Thread.sleep(1000);

        System.err.println("订单数量:" + orderCount.get());
        System.err.println("库存余量:" + tradeAction.getProductInventory(1L));
    }
}

       先启动order-serviceproduct-service,然后运行trade-facade,可以看到输出:

订单数量:6
库存余量:2

微服务需要依赖注册中心,本示例的注册中心使用的是ZooKeeper

       输出显示成功生成了6笔订单,每笔订单包含3件商品,因此商品库存还剩2件,而这表明有4笔订单被取消。可以观察order-service的标准输出,能够看到TRY阶段的相关(部分)输出:

买家{7}购买商品{1},数量为{3},订单{1631264872732}生成@2021-09-10 17:07:56[DubboServerHandler-192.168.31.133:20880-thread-3] in Tx(172.18.0.3:8091:27191024100888792)
.
.
买家{10}购买商品{1},数量为{3},订单{1631264872731}生成@2021-09-10 17:07:56[DubboServerHandler-192.168.31.133:20880-thread-4] in Tx(172.18.0.3:8091:27191024100888799)

       总共10条记录,可以看到每笔订单均在不同的事务(Tx)中生成,且运行的线程为Dubbo服务端线程(输出内容中的中括号所包含的为线程名)。

       在TRY阶段之后,会出现CANCELCONFIRM阶段的(部分)输出:

买家{7}购买商品{1},数量为{3},订单{1631264872732}启用@2021-09-10 17:07:56[rpcDispatch_RMROLE_1_1_24] in Tx(172.18.0.3:8091:27191024100888792)
.
.
买家{9}购买商品{1},数量为{3},订单{1631264872728}取消@2021-09-10 17:07:57[rpcDispatch_RMROLE_1_8_24] in Tx(172.18.0.3:8091:27191024100888793)

       其中订单启用的输出有6条,订单取消的输出有4条,同时注意到运行的线程为Seata的资源管理器线程。这说明TCC不同阶段的逻辑一般是由不同线程运行的,所以在实际使用过程中,需要注意线程安全问题。