分布式系统的事务探讨
一、分布式系统
如今,越来越多的公司进入全球化进程,它们在不同地域上部署了成千上万的计算机,数据存储在不同的数据中心,而计算任务则运行在多台计算机上,计算机系统正在朝着分布式和微服务方向发展,这样做的好处很明显:我们需要多核处理器或计算机集群来加快运算速度,数据库也需要备份在不同的机器以免丢失,数据也要复制到不同的机器上以方便获取、减少延迟,但是也带来很麻烦的协调问题,在获得了效率的同时我们牺牲了一定的可靠性,并且这两者往往还是不可兼得的,这里面就有作为理论支撑的CAP原则。
CAP图CAP原则又称CAP定理,指的是在一个分布式系统中,Consistency(一致性)、 Availability(可用性)、Partition tolerance(分区容错性),三者不可得兼。
- 一致性:在分布式系统中的所有数据备份,在同一时刻是否同样的值。(等同于所有节点访问同一份最新的数据副本)
- 可用性:在集群中一部分节点故障后,集群整体是否还能响应客户端的读写请求。(对数据更新具备高可用性)
- 分区容错性:以实际效果而言,分区相当于对通信的时限要求。系统如果不能在时限内达成数据一致性,就意味着发生了分区的情况,必须就当前操作在C和A之间做出选择。
既然 CAP 不可同时满足,则设计系统时候必然要弱化对某个特性的支持。
弱化一致性
对结果一致性不敏感的应用,可以允许在新版本上线后过一段时间才更新成功,期间不保证一致性。例如网站静态页面内容、实时性较弱的查询类数据库等,CouchDB、Cassandra 等为此设计。
弱化可用性
对结果一致性很敏感的应用,例如银行取款机,当系统故障时候会拒绝服务。MongoDB、Redis 等为此设计。Paxos、Raft 等算法,主要处理这种情况。
弱化分区容忍性
现实中,网络分区出现概率减小,但较难避免。某些关系型数据库、ZooKeeper 即为此设计。实践中,网络通过双通道等机制增强可靠性,达到高稳定的网络通信。
二、分布式事务
分布式系统下的事务也是变得更为复杂,举两个业务场景的例子:
- 当我们的单个数据库的性能产生瓶颈的时候,我们可能会对数据库进行分区,这里所说的分区指的是物理分区,分区之后可能不同的库就处于不同的服务器上了,这个时候单个数据库的ACID已经不能适应这种情况了,db可能会变成一个大的集群。
- 跨银行转账,可能A账户在农行而B账户在工行,A通过网银向B转账,在事务层面就是两个原子事务的组合,A扣钱的同时B能加钱,需要保证两个事务的最终一致性。
在分布式的环境中不能保证所有的节点都是可靠的,相互之间的网络通信也是存在着丢失或异常情形,这种情况下如何保证分布式环境下的事务的实施,核心问题之一就是对结果一致性的解决方案。
1.XA协议
该方案最早由oracle提出用于解决跨数据访问的事务问题,是一种强一致性的解决方案,由事务协调器和本地资源管理器共同完成。事务协调器和资源管理器间通过XA协议进行通信。XA协议实现的原理入下图所示,共分为两个阶段,也就是我们常说的两阶段协议。
两阶段提交协议两阶段提交需要有一个协调者,来协调两个操作之间的操作流程。当参与方为更多时,其逻辑其实就比较复杂了。而参与者需要实现两阶段提交协议。Pre commit阶段需要锁住相关资源,commit或rollback时分别进行实际提交或释放资源,看似还不错,但是考虑到各种异常情况那就比较痛苦了:
- 考虑prepare预备阶段的响应(因为请求阶段和执行阶段都可以在最后响应中体现出来),对于分布式环境中,任意时刻考虑3种状态:成功、失败、超时。
- 考虑commit阶段,同样考虑成功失败超时3种状态。
状态 | 预备阶段(prepare) | 提交阶段 (commit) |
---|---|---|
成功 | 不必处理,执行后续行为commit | 整个事务成功执行 |
失败 | 这是执行阶段出错,执行后续行为rollback。 | 提交出错,假设此时前面的B已经提交成功了,则同样面临需要回滚B却无法回滚的问题,因为B已经提交成功了。 |
超时 | 这可能是执行阶段太慢,也可能是网络阶段太慢或丢包,但是保守处理,超时可以当做出错。 | 同左处理 |
还有一种例外情况,即prepare阶段完成后协调器挂了,则两个DB则进入不知所措的状态。可以看出,在2PC中事务无法做到像单机一样安全,只不过降低了出问题的概率。
针对如何解决2PC中的例外情况,出现了3阶段提交协议。3阶段的主要改进是把2阶段的prepare再分为canCommit和preCommit两个阶段。但是3PC仍然存在类似2PC的问题,即最后阶段失败或超时同样有可能出现数据不一致的问题。所以3PC仍然只是降低了发生概率,并没有真正解决问题。
2.TCC(Try/Confirm/Cancel)
TCC方案应用是目前呼声最高,也是落地最多的一个方案。当前也有一些开源的TCC框架实现,如TCC-Transaction、ByteTCC。TCC方案其实是两阶段方案的一种改进,其将本地资源管理器的功能融入到了业务实现中。其将整个业务逻辑显示的分成了Try、Confirm、Cancel三部分。try部分完成业务的准备工作,confirm部分完成业务的提交,cancel部分完成事务的回滚。基本原理如下图所示:
事务开始时,业务应用会向事务协调器注册启动事务。之后业务应用会调用所有服务的try接口,相当于XA的第一阶段。如果有任何一个服务的try接口调用失败会向事务协调器发送事务回滚请求,否则发送事务提交请求。事务协调器收到事务回滚请求后会依次调用事务的confirm接口,否则调用cancel接口回滚,这相当于XA的第二阶段。如果第二阶段接口调用失败,会进行重试。
TCC方案通过通过三个接口很好的规避了长时间数据加锁的问题,业务表在每个接口调用完毕即可释放,这很大程度上提高了业务的并发度,这也是TCC方案最大的优势。所以在SOA时期,TCC方案被很多金融、电商的业务系统大量使用。
当然TCC方案也有不足之处,集中表现在以下两个方面:
- 开发工作量大。它将部分资源管理器的功能融入到每个服务的开发中,导致服务的每个接口都需要实现try、confirm、cancle,还需要实现事务协调器,开发量不只翻了一倍。
- 实现难度大。系统需要记录每个应用的服务调用链路,由于网络状况、系统故障等调用失败被视为常态,必须按照不同的失败原因实现不同策略的回滚。为了满足一致性的要求,二阶段不管调用confirm还是cancle都必须调用成功,如果一次调用不成功,事务协调器必须尝试重试。这就要求confirm和cancle接口必须实现幂等。
很多文章说到幂等定律,数学上:f(f(x)) = f(x),x被函数f作用一次和作用无限次的结果是一样的。幂等性应用在软件系统中,可定义为:某个函数或者某个接口使用相同参数调用一次或者无限次,其造成的后果是一样的,在实际应用中一般针对于接口进行幂等性设计。举个栗子,在系统中,调用方A调用系统B的接口进行用户的扣费操作时,由于网络不稳定,A重试了N次该请求,那么不管B是否接收到多少次请求,都应该保证只会扣除该用户一次费用。
3.消息事务一致性方案
消息一致性方案是通过消息中间件保证上、下游应用数据操作的一致性。基本思路是将本地操作和发送消息放在一个事务中,保证本地操作和消息发送要么两者都成功或者都失败。下游应用向消息系统订阅该消息,收到消息后执行相应操作。
消息事务消息方案从本质上讲是将分布式事务转换为两个本地事务,然后依靠下游业务的重试机制达到最终一致性(重试可以是pull模式,也可以是push模式)。相对TCC方案来讲,消息方案技术难度相对低,落地较容易,如果对一致性不敏感的应用也是一个不错的选择。美国著名电商e-bay以及国内的蘑菇街都做过尝试。消息一致性方案的不足之处是其对应用侵入性较高,应用需要基于消息接口进行改造,而且需要建设专门的消息系统,成本较高。
4.GTS--微服务分布式事务解决方案
GTS是一款分布式事务中间件,由阿里巴巴中间件部门研发,可以为微服务架构中的分布式事务提供一站式解决方案。GTS方案的基本思路是:将分布式事务与具体业务分离,在平台层面开发通用的事务中间件GTS,由事务中间件协调各服务的调用一致性,负责分布式事务的生命周期管理、服务调用失败的自动回滚。
GTS方案有三方面的优势,首先它将微服务从分布式事务中解放出来,微服务的实现不需要再考虑反向接口、幂等、回滚策略等复杂问题,只需要业务自己的接口即可,大大降低了微服务开发的难度与工作量。将分布式事务从所谓的“贵族技术”变为大家都能使用的“平民技术 ”,有利于微服务的落地与推广。 其次,GTS对业务代码几乎没有侵入,只需要通过注解@TxcTransaction界定事务边界即可,微服务接入GTS的成本非常低。第三,性能方面GTS也非常优秀,是传统XA方案的8~10倍。
GTSGTS和微服务集成后的结构图如上图所示。GTS Client需要和业务应用集成部署,RM与微服务集成部署。当业务应用发起服务调用时,首先会通过GTS Client向TC注册新的全局事务。之后GTS Server会给业务应用返回全局唯一的事务编号xid。业务应用调用服务时会将xid传播到服务端。微服务在执行数据库操作时会通过GTS RM向GTS Server注册分支事务,并完成分支事务的提交。如果A、B、C三个服务均调用成功,GTS Client会通知GTS Server结束事务。假设C调用失败,GTS Client会要求GTS Server发起全局回滚。然后由各自的RM完成回滚工作。
4.1 基本原理
GTS中间件主要包括客户端(GTS Client)、资源管理器(GTS RM)和事务协调器(GTS Server)三部分。GTS Client主要完成事务的发起与结束。GTS RM完成分支事务的开启、提交、回滚等操作。GTS Server主要负责分布式事务的整体推进,事务生命周期的管理。
其本质上还是2PC(实际上如果引入3PC会多2n次网络交互,在量大时反而更加不安全)。GTS引入事务协调者的server部分,实际上是一个大集群,以配置的方式接入各种需要分布式事务的业务,集群由专门的团队维护,保证其可用性和性能;而协调者的client部分则通过发起方调用,prepare阶段时,先通过client将本次事务信息发送到server,落库,然后即时推送prepare请求到A、B和C,当收到三者的响应时把他们状态入库,如果正常,则做commit提交;否则会用定时任务去推送未完成的状态直到完成。上文提到的prepare之后事务协调者挂了这种情况,在server集群的保证下,几乎很少会发生。而上文提到的所有超时的情况,都可以通过定时任务推送拿到一个确定的状态而不是盲目的选择回滚或者提交。另外由于微服务都是集群,很少会发生多次请求过去无响应的情况。直到最后一种情况就是commit时AB成功了C失败了,或者反过来B失败AC成功,这种情况成为悬挂事务,最终等待人工来解决,据说每天都有几笔到几十笔。
4.2 使用方式
GTS对应用的侵入性非常低,使用也很简单。下面以订单存储应用为例说明,订单业务应用通过调用订单服务和库存服务完成订单业务,服务开发框架为Dubbo。在业务函数外围使用@TxcTransaction注解即可开启分布式事务。Dubbo应用通过隐藏参数将GTS的事务xid传播到服务端。
@TxcTransaction(timeout = 1000 * 10)
public void Bussiness(OrderServiceInterface os,StockServiceInterface ss)
{
//获取xid
String xid = TxcContext.getCurrentXid();
//1:调用订单服务,创建订单
//通过dubbo的隐形参数将txcid传到服务端
RpcContext.getContext().setAttachment("xid",xid);
int ret = os.setOrder(new Order(pid,num,new Date()));//调用订单服务
//2:调用库存服务,扣库存
RpcContext.getContext().setAttachment("xid",xid);
stockService.updateStock(orderDO);
}
更新库存方法
public int updateStock(OrderDO orderDO) {
//通过dubbo获取全局事务ID,并绑定到上下文
String xid = RpcContext.getContext().getAttachment("xid");
TxcContext.bind(xid,null);
//执行自己的业务逻辑
int ret = jdbcTemplate.update("update stock set amount = amount - ? where product_id = ?",
new Object[]{orderDO.getNumber(), orderDO.getProductId()});
TxcContext.unbind();
return ret;
}
GTS作为2PC在工业界的应用,是相当了不起的设计,通过各种方式规避了各种可能的不一致性,在性能,效率等方面做到了平衡。其可应用在涉及服务调用的多个领域,包括但不限于金融支付、电信、电子商务、快递物流、广告营销、社交、即时通信、手游、视频、物联网、车联网等,详细介绍可以阅读 《GTS--阿里巴巴分布式事务全新解决方案》一文。
《分布式事务?No, 最终一致性》
《zookeeper入门系列-理论基础-分布式事务》
《一行代码,保障分布式事务一致性—GTS:微服务架构下分布式事务解决方案》
《聊聊分布式事务,再说说解决方案》