许多单体应用在修改应用状态时都是依靠事务来保证一致性和隔离性的。要实现这两点很简单:应用通常只和单个数据库交互,使用支持启动、提交和回滚这些事务操作的框架来实现强一致性保证。
在微服务应用中,就没有这么幸运了。服务间的交互可能会失败,导致业务流程受阻,最终使整个系统处于不一致的状态。
1. 2PC 和 3PC
1.1 2PC(tow phase commit)
2PC(tow phase commit)两阶段提交[5]顾名思义它分成两个阶段,先由一方进行提议(propose)并收集其他节点的反馈(vote),再根据反馈决定提交(commit)或中止(abort)事务。我们将提议的节点称为协调者(coordinator),其他参与决议节点称为参与者(participants)。
第一个阶段是「投票阶段」
- 1.协调者首先将命令「写入日志」
- 「发一个prepare命令」给B和C节点这两个参与者
- 3.B和C收到消息后,根据自己的实际情况,「判断自己的实际情况是否可以提交」
- 4.将处理结果「记录到日志」系统
- 5.将结果「返回」给协调者
第二个阶段是「决定阶段」
当A节点收到B和C参与者所有的确认消息后
- 「判断」所有协调者「是否都可以提交」
- 如果可以则「写入日志」并且发起commit命令
- 有一个不可以则「写入日志」并且发起abort命令
- 参与者收到协调者发起的命令,「执行命令」
- 将执行命令及结果「写入日志」
- 「返回结果」给协调者
coordinator根据participant的反馈,提交或中止事务,如果participant全部同意则提交,只要有一个participant不同意就中止。
在异步环境(asynchronous)并且没有节点宕机(fail-stop)的模型下,2PC可以满足全认同、值合法、可结束,是解决一致性问题的一种协议。
可能会存在哪些问题?
- 「单点故障」:一旦事务管理器出现故障,整个系统不可用
- 「数据不一致」:在阶段二,如果事务管理器只发送了部分 commit 消息,此时网络发生异常,那么只有部分参与者接收到 commit 消息,也就是说只有部分参与者提交了事务,使得系统数据不一致。
- 「响应时间较长」:整个消息链路是串行的,要等待响应结果,不适合高并发的场景
- 「不确定性」:当事务管理器发送 commit 之后,并且此时只有一个参与者收到了 commit,那么当该参与者与事务管理器同时宕机之后,重新选举的事务管理器无法确定该条消息是否提交成功。
1.2 3PC(three phase commit)
3PC(three phase commit)即三阶段提交。相对于2PC来说增加了CanCommit阶段和超时机制。如果段时间内没有收到协调者的commit请求,那么就会自动进行commit,解决了2PC单点故障的问题。
第一阶段:「CanCommit阶段」
这个阶段所做的事很简单,就是协调者询问事务参与者,你是否有能力完成此次事务。
- 如果都返回yes,则进入第二阶段
- 有一个返回no或等待响应超时,则中断事务,并向所有参与者发送abort请求
第二阶段:「PreCommit阶段」
此时协调者会向所有的参与者发送PreCommit请求,参与者收到后开始执行事务操作,并将Undo和Redo信息记录到事务日志中。参与者执行完事务操作后(此时属于未提交事务的状态),就会向协调者反馈“Ack”表示我已经准备好提交了,并等待协调者的下一步指令。
第三阶段:「DoCommit阶段」
在阶段二中如果所有的参与者节点都可以进行PreCommit提交,那么协调者就会从“预提交状态”转变为“提交状态”。然后向所有的参与者节点发送”doCommit”请求,参与者节点在收到提交请求后就会各自执行事务提交操作,并向协调者节点反馈“Ack”消息,协调者收到所有参与者的Ack消息后完成事务。相反,如果有一个参与者节点未完成PreCommit的反馈或者反馈超时,那么协调者都会向所有的参与者节点发送abort请求,从而中断事务。
participant如果在不同阶段宕机,我们来看看3PC如何应对:
- 阶段1: coordinator或watchdog未收到宕机participant的vote,直接中止事务;宕机的participant恢复后,读取logging发现未发出赞成vote,自行中止该次事务
- 阶段2: coordinator未收到宕机participant的precommit ACK,但因为之前已经收到了宕机participant的赞成反馈(不然也不会进入到阶段2),coordinator进行commit;watchdog可以通过问询其他participant获得这些信息,过程同理;宕机的participant恢复后发现收到precommit或已经发出赞成vote,则自行commit该次事务
- 阶段3: 即便coordinator或watchdog未收到宕机participant的commit ACK,也结束该次事务;宕机的participant恢复后发现收到commit或者precommit,也将自行commit该次事务
因为有了准备提交(prepare to commit)阶段,3PC的事务处理延时也增加了1个RTT,变为3个RTT(propose+precommit+commit),但是它防止participant宕机后整个系统进入阻塞态,增强了系统的可用性,对一些现实业务场景是非常值得的。
1.3 为什么不推荐
一种常见的方案就是使用二阶段提交(two phase commit,2PC)协议。在这种方案中,系统使用一个事务管理器(transaction manager)来将多个资源(resource)的操作分成两个阶段:准备(prepare)和提交(commit)。
这个方案是有缺陷的。首先,2PC意味着事务管理器和资源方之间采用了同步的通信机制。如果某个资源方不可用,事务就不能提交而必须回滚。这反过来会增加重试的次数并且降低整个系统的可用性。
如果想要支持异步服务交互,就需要在这些服务之间增加一个消息层,然后和这些服务一起来支持2PC,而这会限制我们的技术选型。
- 将重要的编配职责交给事务管理器同样违背了微服务的核心原则:服务自治。
- 最糟糕的情况下,我们的服务都只是默默地对数据进行CRUD操作,系统中最有意义的功能反而完全是由事务管理者来封装完成的。
2. 事务消息方案
2.1 最大努力通知
做过支付宝交易接口的同学都知道,我们一般会在支付宝的回调页面和接口里,解密参数,然后调用系统中更新交易状态相关的服务,将订单更新为付款成功。
同时,只有当我们回调页面中输出了success字样或者标识业务处理成功相应状态码时,支付宝才会停止回调请求。否则,支付宝会每间隔一段时间后再向客户方发起回调情求,直到输出成功标识为止。
重试和回调要考虑幂等的问题。
2.2 本地消息表
写本地消息和业务操作放在一个事务里,保证了业务和发消息的原子性,要么他们全都成功,要么全都失败。
容错机制:
- 扣减余额事务失败时,事务直接回滚,无后续步骤。
- 轮序生产消息失败 和 增加余额事务失败,都会进行重试。
本地消息表的特点:
- 不支持回滚
- 轮询生产消息难实现,如果定时轮询会延长事务总时长,如果订阅binlog则开发维护困难。
适用于可异步执行的业务,且后续操作无需回滚的业务。
2.3 事务消息
在上述的本地消息表方案中,生产者需要额外创建消息表,还需要对本地消息表进行轮询,业务负担较重。
阿里开源的RocketMQ 4.3之后的版本正式支持事务消息,该事务消息本质上是把本地消息表放到RocketMQ上,解决生产端的消息发送与本地事务执行的原子性问题。
事务消息发送及提交:
- 发送消息(half消息)
- 服务端存储消息,并响应消息的写入结果
- 根据消息队里的发送结果,执行本地事务(如果写入失败,此时half消息对业务不可见,本地逻辑不执行)
- 根据本地事务状态执行Commit或者Rollback(Commit操作发布消息,消息对消费者可见)
2.4 总结
- 一般通过本地事务,保证消息表正确写入,也可以发送消息队列里。也可以直接写入到事务消息的消息队列里。
- 一个单独的服务可以轮训消息表,也可以直接消费消息队列。然后调用系统 B 的接口;
- 系统A 调用 系统B 要使用最大努力通知方案,系统B实现要考虑幂等等因素。
3. TCC 补偿
TCC 其实就是采用的补偿机制,其核心思想是:针对每个操作,都要注册一个与其对应的确认和补偿(撤销)操作。它分为三个阶段:
- Try 阶段主要是对业务系统做检测及资源预留
- Confirm 阶段主要是对业务系统做确认提交,Try阶段执行成功并开始执行 Confirm阶段时,默认 Confirm阶段是不会出错的。即:只要Try成功,Confirm一定成功。
- Cancel 阶段主要是在业务执行错误,需要回滚的状态下执行的业务取消,预留资源释放。
举个例子,假入 Bob 要向 Smith 转账,思路大概是:
1、首先在 Try 阶段,要先调用远程接口把 Smith 和 Bob 的钱给冻结起来。
2、在 Confirm 阶段,执行远程调用的转账的操作,转账成功进行解冻。
3、如果第2步执行成功,那么转账成功,如果第二步执行失败,则调用远程冻结接口对应的解冻方法 (Cancel)。
优点: 跟2PC比起来,实现以及流程相对简单了一些,但数据的一致性比2PC也要差一些
缺点: 缺点还是比较明显的,在2,3步中都有可能失败。TCC属于应用层的一种补偿方式,所以需要程序员在实现的时候多写很多补偿的代码,在一些场景中,一些业务流程可能用TCC不太好定义及处理。
4. Saga
4.1 事务编排
order服务负责编配其他服务的行为,调用一系列的功能,最终订单被发布到市场上。如果任何一个步骤出现故障,order服务就负责启动其他服务的回滚操作,比如退回手续费。
在这种方案中,order服务承担了大量重要的职责:它知道需要调用哪些服务以及调用这些服务的顺序;在下游服务出错或者由于不符合业务规则而使下游服务不能正常处理时,它需要知道自己所要做的工作。
事件消息编排
我们可以使用事件消息来重新设计这个场景。每个服务可以订阅它所感兴趣的事件消息,以确定何时必须执行工作。
(1)当用户通过界面发起出售请求时,应用发布一个OrderRequested事件。
(2)order服务接收这个事件后进行处理,然后向事件队列发出一个OrderCreated事件。
(3)transaction和fee服务都会接收到这个事件通知,这两个服务会执行它们相应的操作,然后在执行完成以后分别发出一个通知事件。
(4)market服务等待两个通知事件:收费确认事件和股票预定成功事件。一旦接收到这两个事件,market服务就可以向股票交易市场提交订单了。这步操作完成后,market服务就会向事件队列发送一个最终的事件消息。
这种方式称为编排。每个服务可以在不了解整个流程结果的情况下响应各种事件,独立执行各种操作。这些服务就如同舞蹈演员一般:他们知道每一段音乐的舞步和要做的动作,不需要有人显式地请求或者命令他们,就会按照音乐的变化给出相应的反应。
4.2 编排型Saga(事件+补偿)
- Saga模式一种基本的用法就是采用编排方案。Saga是一组互相协作的本地事务序列;在Saga中,每一步的操作都是由前一个步骤所触发的。
- 在Saga中,我们会用补偿操作来撤销之前的操作,并让系统恢复到更一致些的状态。系统不保证一定会恢复到最初的状态;具体的操作要依赖于业务含义。
Saga中的动作都是编排过的:每个动作TX的执行都是在回应另一个动作,但是这个过程并不需要一个总指挥或者总协调人。我们可以将这个下单出售股票的任务拆分成5个子任务:
T1——创建订单;
T2——account transaction服务预留股票仓位;
T3——fee服务计算和收取相应的费用;
T4——market服务将购买订单提交到市场
T5——更新所提交的订单的状态。
每个任务都可能会失败——在这种情况下,应用需要回滚到一个合理且一致的状态。每个服务都有一个补偿动作:
C1——取消客户创建的订单;
C2——撤销预留的股票仓位;
C3——撤销手续费并退还给客户;
C4——取消发布到市场上的订单;
C5——撤销订单的状态。
如何触发这些动作呢?猜对了,是事件!比如,假设将订单发布到市场上时出现了故障,market服务会发送一个OrderFailed事件来取消这个订单,然后Saga中的其他所有服务都会消费这个事件消息。在收到这个事件后,每个服务会执行相应的行动:order服务会取消客户的订单;transaction服务会取消预留的股票;而fee服务会将撤销收取的费用,依次执行C1、C2、C3的动作。
缺点:
- 一个流程中的每个动作可能有多个对应的补偿动作。这种方式会增加系统复杂度,这种复杂度不仅存在于预测失败场景并提前做好准备上,还体现在编码和测试上。尤其是因为交互牵涉的服务越多,回滚的复杂度可能就越高。
- 编排同样还会引入服务的循环依赖问题:order服务会发出事件消息供market服务消费,但是,反过来,它也会消费market服务发出的事件消息。这种循环依赖会导致在发布阶段服务之间是相互耦合在一起的。
4.3 编配型Saga(需要编配器)
在编配型Saga中,会有一个服务承担编配器或者协调器的功能:它会执行和跟踪跨多服务的Saga及其结果。
编配器可以是一个独立的服务,编配器唯一的职责就是管理Saga的执行。
order服务需要跟踪下单过程中每个步骤的执行情况。为了便于理解,我们可以将协调器类比为一种状态机:一系列的状态以及状态间的转换。协作方的每个响应都会触发协调器的状态发生变化,进而一步步推动协调器达到Saga的结果。
Saga并不总都是成功的。在编配式的Saga中,协调器负责在事务执行失败后启动合适的调解动作,来让受影响的实体恢复到有效且一致的状态。
编配器就会启动补偿动作:向account transaction服务发起请求来撤销之前预留的股票;发起请求取消之前从客户那边收取的费用;修改订单的状态来反映Saga的结果。
将Saga顺序性的业务逻辑集中到单个服务中,能够让开发者更加容易地分析和推断Saga的当前进展和结果,而且更加易于修改Saga的执行顺序,因为只需要修改调度器这一处地方。
这种编配式Saga方案的风险就是将太多的业务逻辑移到协调器中。极端情况下,这会使得其他服务变得越来越“贫血”,每个服务都仅仅是把数据存储包装了一下,不再是那种自治的并且能够独立负责业务功能的服务。
4.4 事件溯源(event sourcing)
事件溯源完全用对象身上所发生的一系列的事件来表示状态,而不是用实体状态信息来表示所发布的事件消息。
如果想要获取某个实体在指定时间的状态,开发者就需要聚合在此之前的所有事件。比如,设想这样的order服务:在传统的持久化方案中,我们一直都是假定数据库存储的是订单最新的状态;
在事件溯源的方案中,我们保存的是订单状态的修改事件,我们可以通过复现这些事件来获取订单当前的具体状态。