当前位置: 首页 > 科技观察

分布式事务,阿里为何钟爱TCC

时间:2023-03-18 12:52:14 科技观察

本文转载自微信公众号《程序员jinjunzhu》,作者jinjunzhu。转载本文请联系程序员jinjunzhu公众号。在分布式事务的实现方式中,TCC是比较知名的一种模式。但我一直不喜欢这种模式,原因是它有很多问题需要考虑。之前写过一篇文章,说了TCC的很多不足,后来删掉了,因为阿里的一个大佬加我为好友,纠正了我的观点。太感谢了!1TCC概述简单来说,TCC模式就是把整个事务分成两个阶段提交,try阶段预留资源。如果所有分支都保留成功,则进入commit阶段提交所有分支事务,否则执行cancel取消所有分支事务。以电商系统为例,如果有订单、库存、账户三个服务,客户购买一件商品,订单服务增加订单,库存服务扣除库存,账户服务扣除金额。这三个操作必须是原子的,要么全部成功,要么全部失败。try阶段如下图所示:订单服务添加订单,库存服务冻结订单上的库存,账户服务冻结订单上的金额。订单、库存、账户这三个服务作为整个分布式事务的分支事务,都必须在try阶段提交本地事务。上面说的库存和账户冻结,是指这个订单对应的库存和金额不能再被其他交易使用,所以必须提交本地交易。但是这个提交并不是真正的全局事务的提交,而是把资源转移到一个中间状态。这个中间状态需要在try方法的业务代码中实现。例如,从一个账户中扣除的金额可以先存入一个中间账户。try阶段没有提交本地事务会怎样?有可能其他事务在try阶段发现用户账户中的金额足够,commit时发现金额不足,commit阶段扣款只能失败。这时候另外两个如果一个分支事务提交成功但是账户服务分支事务提交失败,最终的数据就会不一致。commit阶段如下图所示:在commit阶段,数据从中间状态转移到最终状态,例如订单金额从中间账户转移到最终账户。cancel阶段和commit阶段类似,比如订单金额从中间账户返回到客户账户。2问题代码下面的代码也可以理解为TCC,在try阶段持有连接,不提交分支事务,然后在commit阶段提交分支事务。代码如下:我们以扣账账户为例,首先定义2个变量来保存连接:privateMapstatementMap=newConcurrentHashMap<>(100);privateMapconnectionMap=newConcurrentHashMap<>(100);try方法的代码如下:publicbooleantry(Stringxid,LonguserId,BigDecimalpayAmount){LOGGER.info("decrease,xid:{}",xid);LOGGER.info("------->尝试扣除账户并启动账户");try{//尝试扣除账户金额,交易未提交Connectionconnection=hikariDataSource.getConnection();connection.setAutoCommit(false);Stringsql="UPDATEaccountSETbalance=balance-?,used=used+?whereuser_id=?";PreparedStatementsstmt=connection.prepareStatement(sql);stmt.setBigDecimal(1,payAmount);stmt.setBigDecimal(2,payAmount);stmt.setLong(3,userId);stmt.executeUpdate();statementMap.put(xid,stmt);connectionMap.put(xid,connection);}catch(Exceptione){LOGGER.error("decreaseparefailure:",e);returnfalse;}LOGGER.info("------>尝试扣除账户端账户");returntrue;}commit方法代码如下:publicbooleancommit(BusinessActionContextactionContext){Stringxid=actionContext.getXid();PreparedStatementstatement=(PreparedStatement)statementMap.get(xid);Connectionconnection=connectionMap.get(xid);try{if(null!=connection){connection.commit();}}catch(SQLExceptione){LOGGER.error("扣减账户失败:",e);returnfalse;}finally{try{statementMap.remove(xid);connectionMap.remove(xid);if(null!=statement){statement.close();}if(null!=connection){connection.close();}}catch(SQLExceptione){LOGGER.error("扣减账户提交交易后关闭连接池失败:",e);}}returntrue;}cancel方法代码如下:publicbooleanrollback(BusinessActionContextactionContext){Stringxid=actionContext.getXid();PreparedStatementstatement=(PreparedStatement)statementMap.get(xid);Connectionconnection=connectionMap.get(xid);try{connection.rollback();}catch(SQLExceptione){returnfalse;}finally{try{if(null!=statement){statement.close();}if(null!=connection){connection.close();}statementMap.remove(xid);connectionMap.remove(xid);}catch(SQLExceptione){LOGGER.error("扣除账户回滚交易后关闭连接池失败:",e);}}returntrue;}这段代码是问题代码,不能用,不能用,有两个不能用的代码问题:2.1阻塞和等待如果当前事务没有提交,比如账户服务,相当于锁定了资源,后续事务只能等待资源释放。2.2服务集群以订单服务为例。如果订单服务是3台机器组成的集群,如下图:协调节点使用注册中心客户端调用订单服务。如果向订单服务1发送try请求,向订单服务2发送commit请求,那么订单服务2上的connectionMap将没有连接xid=123,只能提交失败。3TCC存在的问题上面的问题代码是给大家一个思路。如果真的要保持连接,也算是实现了TCC的思想,但是在系统中,我们是不可能做到的,所以称为问题代码。3.1空回滚如下图,订单服务节点1出现故障。如果不考虑重试,try方法失败:虽然try失败了,但是全局事务已经启动,框架必须把全局事务推到结束状态,这就不得不调用订单服务的cancel方法来roll返回,但是订单服务空手运行了取消方法。为了解决这个问题,可以记录一个事务控制表,保存全局事务xid和分支事务branchId,在try阶段会插入一条记录,表示try阶段已经执行。cancel方法读取记录,如果记录存在则正常回滚;如果记录不存在,则为空回滚。3.2幂等性幂等性是指在commit/cancel阶段,由于TC没有收到分支事务的响应,需要重试,这就需要分支事务支持幂等性。以订单服务为例。如下图:为了支持幂等性,可以记录一个事务控制表,保存全局事务xid、分支事务branchId、分支事务状态,在第二个commit/cancel前检查分支事务状态是否为final阶段。不是,执行第二阶段的逻辑。3.3暂停暂停是指事务的cancel方法先于try方法执行。上面说了在使用seata的过程中会出现空回滚。如果发生空回滚,执行cancel方法后全局事务结束。但是由于网络问题,订单服务收到try请求,执行try方法后保留。资源成功了,这些资源最终无法释放。这个问题的解决方法是在cancel方法中记录xid对应的分支事务回滚记录。执行try阶段时,首先判断分支事务是否已经回滚。如果有回滚记录,直接退出。3.4业务代码侵入TCC的try/commit/cancel,侵入业务代码,每个方法都是本地事务。再加上需要考虑幂等性、空回滚、挂起等,代码侵入性会更高。4、TCC的优势这里我们比较一下seata实现的四种模式,包括XA、SAGA、TCC、AT。效率使用TCC模式时,本地事务在try阶段提交,资源没有被锁定,因此没有额外的性能开销。对比一下,看看其他几种模式:AT模式,需要记录undolog,性能损耗非常大。XA模式下,执行xastart|后数据库|xa结束,在执行commit/rollback之前,资源会被锁定,后续事务需要等待。saga模式更适合流程较长的业务场景。5.性能优化参考[1]5.1异步提交优化思路是try阶段成功后,不立即执行confirm/cancel阶段,而是在系统空闲时异步执行。如下图所示:这样,try阶段结束后,就认为全局事务结束,可以在固定的时间(比如10分钟)异步执行第二阶段,并且性能大大提高。当然,一个问题是,如果全局事务回滚,会出现短期的数据不一致。比如推演场景,每10分钟执行一个异步任务。如果第二阶段取消,客户将在10分钟内无法使用该金额。这个异步执行的时间也可以根据业务来定。比如不需要及时从中间账户转入最终账户的场景,可以设置的时间长一些。5.2同数据库模式先回顾一下TCC中的角色:TM管理全局事务,包括开启全局事务,提交/回滚全局事务RM管理分支事务TC管理全局事务和分支事务的状态先看优化前的通信模型,如下图所示:优化前,TM启动全局事务时,RM需要向TC发送RPC消息进行注册,TC保存分支事务的状态。当TM请求提交或回滚时,TC需要发送RPC消息给RM进行提交或回滚。在这样一个包含两个分支事务的分布式事务中,TC和RM之间有四个RPC。优化后的模型如下图所示:当TM启动全局事务时,不再需要向TC注册分支事务,而是将分支事务的状态保存在本地。当TM向TC发送提交或回滚消息时,TC保存全局事务的状态。RM启动异步线程检测本地记录的未提交分支事务,并向TC发送RPC消息获取整体事务状态,以决定是提交还是回滚本地事务。可以看出,对于优化后的模型,RPC次数减少了50%,性能有了很大的提升。6.总结TCC确实存在很多问题,但是除了入侵业务代码的问题,其他问题都有相应的解决方案。阿里对TCC做了一些优化,包括二阶段异步提交和同数据库模式,性能提升明显。

猜你喜欢