简介
开发运维割裂,云原生也不是万金油分布式事务框架都跟两阶段协议有千丝万缕的联系,比如 TCC,也是分成两阶段,第一阶段大家各自预留资源,向协调器反馈结果。第二阶段根据反馈结果执行确认或者回滚。再比如 Saga,进一步把预留资源和确认操作两步合一步,变成了直接执行,失败了再重试或者回滚作为补偿。这其实是一种两阶段协议更进一步的乐观主义的实现。PS: 分布式的几个实现方案跟悲观锁和乐观锁有点异曲同工的意思
Seata:打造行业首个分布式事务产品 未细读。
分布式事务概述与项目实战 未细读。
分布式事务旨在解决多个节点或系统之间的事务处理的一致性和可靠性问题。而分布式事务解决方案的前提都是无节点恶意发送错误消息,分布式解决方案针对的是分布式系统下的非拜占庭问题。根据对一致性的要求分两类:
- 刚性事务,也被称为强一致性事务,指的是需要在分布式系统中确保数据的一致性和完整性,即所有参与的节点要么同时提交事务,要么同时回滚事务。
- 柔性事务,也称为最终一致性事务或弱一致性事务,是相对于刚性事务(强一致性事务)的一种分布式事务处理方式。它不要求所有参与者在同一时间点上保持一致,而是允许在一定的时间内达到一致性。柔性事务更适合那些对数据一致性要求不那么严格,但对系统性能和可用性要求较高的场景。 分布式事务的解决方案有很多种,基本上分为两大类型,一类比如2PC、3PC、TCC模式、Saga模式、Seata AT模式等等都可以看成是遵守XA协议或是XA协议的变种。而另一类则是基于消息通知的分布式事务方案。每种类型的实现或多或少都有着相通之处。
原子提交协议
ACID中的一致性,是个很偏应用层的概念。这跟ACID中的原子性、隔离性和持久性有很大的不同。原子性、隔离性和持久性,都是数据库本身所提供的技术特性;而一致性,则是由特定的业务场景规定的。要真正做到ACID中的一致性,它是要依赖数据库的原子性和隔离性的,但是,就算数据库提供了所有你所需要的技术特性,也不一定能保证ACID的一致性。这还取决于你在应用层对于事务本身的实现逻辑是否正确无误。PS:ACID 中的一致性与事务一致性要解决的问题是不同的。
ACID中的原子性,要求事务的执行要么全部成功,要么全部失败,而不允许出现“部分成功”的情况。在分布式事务中,这要求参与事务的所有节点,要么全部执行Commit操作,要么全部执行Abort操作。换句话说,参与事务的所有节点,需要在“执行Commit还是Abort”这一点上达成一致(其实就是共识)。这个问题在学术界被称为原子提交问题(Atomic Commitment Problem),而能够解决原子提交问题的算法,则被称为原子提交协议(Atomic Commitment Protocal,简称ACP)。2PC和3PC,属于原子提交协议两种不同的具体实现。原子提交问题是共识问题的一个特例。
但是,分布式系统的诡异之处就要体现在这里,一些细节的不同,可能导致非常大的差异。如果你仔细看前文的描述,会发现这样一个细节:当我们描述共识问题的时候,我们说的是在多个节点之间达成共识;而当我们描述原子提交问题的时候,我们说的是在所有节点之间达成共识。这个细微的差别,让这两类问题,几乎变成了完全不同的问题(谁也替代不了谁)。
论文Uniform Consensus Is Harder Than Consensus 进一步澄清了这一问题,原子提交问题被抽象成一个新的一致性问题,称为uniform consensus问题,它是与通常的共识问题(consensus problem)不同的问题,而且是更难的问题。uniform consensus,要求所有节点(包括故障节点)都要达成共识;而consensus问题只关注没有发生故障的节点达成共识。
解决方案
基于Saga的分布式事务调度落地我们常见的分布式事务来保证数据的一致性的方法分为两类:强一致性、最终一致性。
- 采用强一致性的分布式事务的方案:通常采用两段式提交协议2PC、三段式提交协议3PC。在微服务架构中,该种方式不太适合,原因如下:
- 由于微服务间无法直接进行数据访问,微服务间互相调用通常通过RPC或Http API进行,所以已经无法使用TM统一管理微服务的RM
- 不同的微服务使用的数据源类型可能完全不同,如果微服务使用了NoSQL之类不原生支持事务的数据库,业务的事务很难实现
- 即使微服务使用的数据源都支持事务,那么如果使用一个大事务将许多微服务的事务管理起来,这个大事务维持的时间,将比本地事务长几个数量级。如此长时间的事务及跨服务的事务,将为产生很多锁及数据不可用,严重影响系统性能
- 常见的最终一致性的分布式事务解决方案有:事件通知模式(本地异步事件服务模式、外部事件服务模式、MQ事务消息模式、最大努力通知模式)、事务补偿模式(Saga、TCC)。 Saga、TCC是一种补偿性事务的思想,对业务入侵较大,需要业务方实现对应的方法。
- 对于TCC模式来说,要做一个分布式事务,业务中的一个接口需要完成3个逻辑的改造,Try-Confirm-Cancel。对于业务的侵入性比较强,流程比较繁琐。例如:给用户添加积分,我们不直接添加积分,先预处理添加积分。扣物品库存我们也不直接扣库存,先冻结将扣掉的库存。
- Saga是一种纯业务补偿模式,其设计理念为,业务在调用的时候正常提交,当一个服务失败的时候,所有其依赖的上游服务都进行业务补偿操作。
- 本地消息、事务消息和最大努力通知其实都是最终一致性事务,因此适用于一些对时间不敏感的业务。
谈谈对分布式事务的一点理解和解决方案目前业界主流的分布式事务解决方案主要有:
- 多阶段提交方案。常见的有2pc和3pc,需要额外的资源管理器来协调事务,数据一致性强,但是实现方案比较复杂,对性能的牺牲比较大(主要是需要对资源锁定,等待所有事务提交才能解锁),不适用于高并发的场景,目前比较知名的有阿里开源的fescar。
- 补偿事务。一般也叫TCC,因为每个事务操作都需要提供三个操作尝试(Try)、确认(Confirm)和补偿/撤销(Cancel),数据一致性的强度比多阶段提交方案低,但是实现的复杂度会有所降低,比较明显的缺陷是每个业务事务需要实现三组操作,有可能出现过多的补偿方案的代码;另外有很多输完液场景TCC是不合适的。
- 消息事务。这里只谈RocketMQ的实现,一个事务的执行流程包括:发送预消息、执行本地事务、确认消息发送成功。它的消息中间件存储了下游无法消费成功的消息,并且不断重试推送下游消费消息,而生产者(上游)需要提供一个check接口,用于检查成功发送预消息但是未确认最终消息发送状态的事务的状态。
2pc 和 3pc
2PC 和 3PC 是一种强一致性事务,不过还是有数据不一致,阻塞等风险,只能用在数据库层面。
过程
正常情况
异常情况
无论事务提交,还是事务回滚,都是两个阶段
2pc有很多问题,比如单点、同步阻塞等,此处我只讨论数据一致性问题:在二阶段提交协议的阶段二,即执行事务提交的时候,当协调者向所有的参与者发送Commit请求之后,发生了局部网络异常或者是协调者在尚未发送完Commit请求之前自身发生了崩溃,导致最终只有部分参与者收到了Commit请求。于是,这部分收到了Commit请求的参与者就会进行事务的提交,而其他没有收到Commit请求的参与者则无法进行事物提交,于是整个分布式系统便出现了数据不一致的现象。2PC除本身的算法局限外,还有一个使用上的限制,就是它主要用在两个数据库之间(XA是一种数据库实现的、基于2PC协议的规范)。但以支付宝的转账为例,是两个系统之间的转账,而不是底层两个数据库之间直接交互,所以没有办法使用2PC。
正常情况
异常情况
在分布式环境下,分布式系统的每一次请求和响应,存在特有的三态概念:即成功、失败、超时。相对于2pc,3pc处理了timeout问题。但3pc在数据一致性上也有问题:在参与者接收到preCommit消息后,如果出现网络分区,此时协调者所在的节点和参与者无法进行正常的网络通信,在这种情况下,参与者依然会进行事物的提交,就可能出现不同节点成功or失败,这必然出现数据的不一致。3PC并没有解决2PC的根本问题,它只是在2PC的基础上做了一些优化,它增加了一个阶段(也增加了1个RTT)来提高对方可用性的概率,这本质跟TCP的三次握手一样,同样也改为四次握手,五次握手等等。
在代码上的表现
- 声明式事务
- 编程式事务
本地事务处理
Connection conn = null;
try{
//若设置为 true 则数据库将会把每一次数据更新认定为一个事务并自动提交
conn.setAutoCommit(false);
// 将 A 账户中的金额减少 500
// 将 B 账户中的金额增加 500
conn.commit();
}catch(){
conn.rollback();
}
跨数据库事务处理
UserTransaction userTx = null;
Connection connA = null;
Connection connB = null;
try{
userTx.begin();
// 将 A 账户中的金额减少 500
// 将 B 账户中的金额增加 500
userTx.commit();
}catch(){
userTx.rollback();
}
代码的封装跟2pc的实际执行过程有所不同,可以参见JTA与TCC
TCC
聊一聊分布式事务TCC (Try-Confirm-Cancel)事务模型采用的是补偿机制,其核心思想是:针对每个操作,都要注册一个与其对应的确认和补偿操作。相当于XA来说,TCC可以不依赖于资源管理器,即数据库,它是通过业务逻辑来控制确认和补偿操作的,所以它用了’Cancel’而非’Rollback’的字眼。它是一个应用层面的2PC。
TCC借鉴2PC的思路,对比上一小节可以看到,调用方充当了协调者的角色。那么它是如何解决2PC的问题的呢?也就是说,在阶段2,如果Try成功了,那么Confirm阶段异常了就一直重试,直到成功。调用方发生宕机,或者某个服务超时了,不断重试Cancel!不管是Confirm失败了,还是Cancel失败了,都不断重试。这就要求Confirm和Cancel都必须是幂等操作。注意,这里的重试是由TCC的框架来执行的,而不是让业务方自己去做。
分布式事务,阿里为什么钟爱TCC以电商系统为例,假如有订单、库存和账户3个服务,客户购买一件商品,订单服务增加订单,库存服务扣减库存,账户服务扣减金额,这三个操作必须是原子性的,要么全部成功,要么全部失败。订单、库存和账户这三个服务作为整个分布式事务的分支事务,在try阶段都是要提交本地事务的。上面库存和账户说的冻结,就是说这个订单对应的库存和金额已经不能再被其他事务使用了,所以必须提交本地事务。但这个提交并不是真正的提交全局事务,而是把资源转到中间态,这个中间态需要在try方法的业务代码中实现,比如账户扣除的金额可以先存放到一个中间账户。如果try阶段不提交本地事务会有什么问题呢?有可能其他事务在try阶段发现用户账户里面的金额还够,但是commit的时候发现金额不够了,commit阶段扣款只能失败,这时其他两个分支事务提交成功而账户服务的分支事务提交失败,最终数据就不一致了。commit阶段,数据从中间态转入终态,比如订单金额从中间账户转到最终账户。
TCC的try/commit/cancel,对业务代码都有侵入,而且每个方法都是一个本地事务。再加上需要考虑幂等、空回滚、悬挂等,代码侵入会更高。不过除了侵入业务代码这一个问题,其他问题都有对应的解决方案。阿里针对TCC做了一些优化,包括第二阶段异步提交和同库模式,性能提升很明显。
github 案例项目
阿里 Seata 新版本终于解决了 TCC 模式的幂等、悬挂和空回滚问题 未读
Saga
Saga的基本概念:
- saga:长事务,long live transaction
- 每个本地事务有对应的补偿事务
- 执行情况
- 正常:T1 -> T2 -> T3 -> … -> Tn
- 异常:T1 -> T2 -> T3(异常)-> C3 -> C2 -> C1
Saga两种恢复策略:
- backward recovery,向后恢复,补偿所有已完成的事务(回滚操作)
- forward recovery,向前恢复,重试失败的事务,假设每个子事务最终都会成功(重试操作)
Saga事务的优缺点:
- 优点:模型比TCC更简单,只需业务方提供事务执行接口transaction、事务取消补偿接口cancel
- 缺点:直接执行事务执行接口transaction,可能有副作用(无论是否回滚,都会执行事务接口的逻辑,举例:A账号向B账号转账,T1事务对A用户扣款,T2事务对B用户加款,T1执行成功,同时产生了一条扣款记录,T2执行失败需要回滚T2和T1,在这个过程中的副作用是A账号能感知到金额变化和扣款记录)。PS:“A账号能感知到金额变化和扣款记录”是tcc的时候,try-confirm 逻辑的差别
最终一致性+消息中间件
实践丨分布式事务解决方案汇总:2PC、消息中间件、TCC、状态机+重试+幂等一般的思路是通过消息中间件来实现“最终一致性”
错误的方案:网络调用(rpc/db/mq)和更新DB放在同一个事务里面
这个方案有两个问题
- 发送消息失败,发送方并不知道是消息中间件没有收到消息,还是消息已经收到了,只是返回response的时候失败了?
- 把网络调用放在数据库事务里面,可能会因为网络的延时导致数据库长事务。严重的会阻塞整个数据库,风险很大。
最终一致性:第1种实现方式(业务方自己实现)
- 系统A不再直接给消息中间件发送消息,而是把消息写入到消息表中。把DB1的扣钱操作(表1)和写入消息表(表2)这两个操作放在一个数据库事务里,保证两者的原子性。
- 系统A准备一个后台程序,源源不断地把消息表中的消息传送给消息中间件。如果失败了,也不断尝试重传。因为网络的2将军问题,系统A发送给消息中间件的消息网络超时了,消息中间件可能已经收到了消息,也可能没有收到。系统A会再次发送该消息,直到消息中间件返回成功。所以,系统A允许消息重复,但消息不会丢失,顺序也不会打乱。
- 系统A保证了消息不丢失
系统B对消息的消费要解决下面两个问题:
- 丢失消费,系统B从消息中间件取出消息(此时还在内存里面),如果处理了一半,系统B宕机并再次重启,此时这条消息未处理成功,怎么办?答案是通过消息中间件的ACK机制,凡是系统B(向mq)发送ACK的消息,系统B重启之后消息中间件不会再次推送;
- 重复消费。除了ACK机制可能会引起重复消费,系统A的后台任务也可能给消息中间件重复发送消息。为此,系统B增加一个判重表(业务允许的话也可以直接业务判重)
下游服务消费消息成功可以回调一个确认到上游服务,这样就可以从上游服务的本地消息表删除对应的消息记录。如果消费失败了,则可以重试,但还一直失败怎么办?是否要自动回滚整个流程?答案是人工介入。从工程实践角度来讲,这种整个流程自动回滚的代价是非常巨大的,不但实现起来很复杂,还会引入新的问题。比如自动回滚失败,又如何处理?对应这种发生概率极低的事件,采取人工处理会比实现一个高复杂的自动化回滚系统更加可靠,也更加简单。
弱一致性+基于状态的补偿,有点saga 的意思
如果用最终一致性方案,因为是异步操作,如果库存扣减不及时会导致超卖,因此最终一致性的方案不可行;如果用TCC方案,则意味着一个用户请求要调用两次(Try和Confirm)订单服务、两次(Try和Confirm)库存服务,性能又达不到要求。如果用事务状态表,要写事务状态,也存在性能问题。
既要满足高并发,又要达到一致性,鱼和熊掌不能兼得。可以利用业务的特性,采用一种弱一致的方案。
比如对于电商的购物来讲,允许少卖,但不能超卖。
先扣库存再创建订单
扣库存 | 提交订单 | 调用方处理 |
---|---|---|
成功 | 成功 | 返回成功 |
成功 | 失败 | 告诉用户失败,用户重试(扣库存+提交订单),可能会导致多扣库存 |
失败 | 不提交订单 | 告诉用户失败,用户重试(扣库存+提交订单),可能会导致多扣库存 |
先创建订单再扣库存
提交订单 | 扣库存 | 调用方处理 |
---|---|---|
成功 | 成功 | 返回成功 |
成功 | 失败 | 告诉用户失败,用户重试(提交订单+扣库存),可能会导致多扣库存 |
失败 | 不提扣库存 | 告诉用户失败,用户重试(提交订单+扣库存) |
也就是数据库中可能存在 库存记录无法对应订单,但不可能存在订单记录无法对应库存。
库存每扣一次,都会生成一条流水记录。这条记录的初始状态是“占用”,等订单支付成功后,会把状态改成“释放”。通过比对,得到库存系统的“占用又没有释放的库存流水”与订单系统的未支付的订单,就可以回收这些库存,同时把对应的订单取消。类似12306网站,过一定时间不支付,订单会取消,将库存释放。
不管事务,事后处理——对账
岂止事务有状态,系统中的各种数据对象都有状态,或者说都有各自完整的生命周期,同时数据与数据之间存在着关联关系。我们可以很好地利用这种完整的生命周期和数据之间的关联关系,来实现系统的一致性,这就是“对账”。
在前面的方案中,无论最终一致性,还是TCC、事务状态表,都是为了保证“过程的原子性”,也就是多个系统操作(或系统调用),要么全部成功,要么全部失败。但所有的“过程”都必然产生“结果”,过程是我们所说的“事务”,结果就是业务数据。一个过程如果部分执行成功、部分执行失败,则意味着结果是不完整的。从结果也可以反推出过程出了问题,从而对数据进行修补,这就是“对账”的思路!
假定从“已支付”到“下发给仓库”最多用1个小时;从“下发给仓库”到“出仓完成”最多用8个小时。意味着只要发现1个订单的状态过了1个小时之后还处于“已支付”状态,就认为订单下发没有成功,需要重新下发,也就是“重试”。同样,只要发现订单过了8个小时还未出仓,这时可能会发出报警,仓库的作业系统是否出了问题……诸如此类。
AT
分布式事务 GTS 的价值和原理浅析 代码已开源
执行阶段:GTS 的 JDBC 数据源代理通过对业务 SQL 的解析,把业务数据在更新前后的数据镜像组织成回滚日志,利用 本地事务 的 ACID 特性,将业务数据的更新和回滚日志的写入在同一个 本地事务 中提交。这样,可以保证:任何提交的业务数据的更新一定有相应的回滚日志存在。
完成阶段:如果 TM 发出的决议是全局提交,此时分支事务此时已经完成提交,不需要同步协调处理(只需要异步清理回滚日志),完成阶段 可以非常快速地完成。
完成阶段:如果 TM 发出的决议是全局回滚,RM 收到协调器发来的回滚请求,通过 XID 和 Branch ID 找到相应的回滚日志记录,通过回滚记录生成反向的更新 SQL 并执行,以完成分支的回滚。
综上,GTS 创新地基于 SQL 解析实现对业务无侵入的自动补偿回滚机制。说人话就是:由GTS 本身负责 事务过程中间数据的产生(比如undo log table)和处理(比如callback)
微服务事务一致性
分布式事务解决方案汇总:2PC、消息中间件、TCC、状态机+重试+幂等
事件驱动的分布式事务架构设计 在传统的软件架构中,应用逻辑是通过请求、过程驱动的。一个请求执行一段逻辑同步返回一个响应,在业务逻辑中,将要执行的代码按照过程顺序进行编排。而事件驱动架构中,事件消费者会以异步的方式处理事件生产者产生的事件,原来过程当中的逻辑交给事件消费者去处理,解开服务之间的耦合,使应用的逻辑聚焦,应用的职责单一,代码更加简洁,也能提升系统的响应能力。
随着对云原生技术的理解深入,从 Kubernetes Control-Loop 思想中获得灵感,全新设计了高性能、无侵入、事件驱动的 Go 语言分布式事务框架 hptx,以及支持跨语言分布式事务、读写分离、分库分表的 Mesh 方案 DBPack。下图展示了 hptx 和 dbpack 的事务协调逻辑,事务发起者 AggregationSvc 发起全局事务提交、回滚,仅仅是修改 ETCD 中的数据状态,然后立即返回。订单服务和商品服务使用前缀 bs/${appid} Watch 存储在 ETCD 中的分支事务数据,当分支事务的数据发生过变更后,ETCD 马上推送一个变更事件给相应服务,订单服务和商品服务收到变更事件后,将数据加入 workqueue 去执行提交或回滚的逻辑。AggregationSvc 提交、回滚时不会调用 OrderSvc、ProductSvc 的接口,整个过程通过 ETCD 解耦后异步执行。事务分支提交或者回滚失败后,会重新进入到 workqueue 当中继续消费,直至提交、回滚成功,或回滚超时。
在这个架构中,已经没有中心化事务协调者 TC Server,用户只需要关心自身应用的高可用,如果应用多副本部署,hptx 和 dbpack 会通过 etcd 选主,只有选为 master 的副本才能 watch 自身产生的分支事务数据去做提交、回滚,避免了提交、回滚逻辑重复执行的问题。集成 hptx,只需要依赖相应的 sdk,而不需要部署额外的 TC Server,但状态数据的存储由原来的 Mysql 换成了 ETCD。
小结
2018.9.26 补充:《左耳听风》中提到:
-
对于应用层上的分布式事务一致性
- 吞吐量大的最终一致性方案:消息队列补偿等
- 吞吐量小的强一致性方案:两阶段提交
- 数据存储层解决这个问题的方式 是通过一些像paxos、raft或是nwr这样的算法和模型来解决。 PS:这可能是因为存储层 主要是副本一致性问题
- 在实践中,还是尽量在数据存储层解决分布式事务问题。比如TiDB、OceanBase等,类似于一个无限容量的数据库 ==> 无需跨库操作 ==> 减少分布式事务问题。由此可见,一致性问题可以 “转嫁”
以电商系统为例,聊聊分布式事务当一个事件需要在两个地方更新数据时,与两阶段提交相比,最终一致性/SAGA方案是处理分布式事务的更好的方式,主要原因是两阶段提交在分布式环境中不能伸缩。不过最终一致性方案引入了新问题,例如如何以原子方式更新数据库和发出事件,因此采用这种方案需要开发和测试团队改变思维方式。