前言
有段时间没写文章了,这段时间一直在看camunda服务编排的一些东西,之前没有接触过工作流引擎和服务编排类似的东西,花了一点时间来熟悉和理解。由于我们需要运用camunda来做服务编排,因此对于一个编排,其中的工作单元是分布在多个微服务内部的业务调用,如何在服务编排中保持分布式事务的支持就成了我的关注点。camunda提供了补偿机制和对应的SAGA 模式来完成分布式事务的支持。本篇文章将对这一块进行描述和探讨。
错误处理机制
对于分布式事务来说,归根结底我们要考虑的就是,微服务调用出错的时候,我们的流程应该如何流转,并在这个流转过程中保证事务的一致性。因此我们第一个考虑的就是camunda本身的错误处理机制是什么。
事务回滚
第一种错误处理方式是事务回滚,要理解这个方式我们首先要看看camunda中事务是怎么组织的。在camunda中,流程的运行是被封装在一个个客户线程(client thread)中。可以这么理解,当一个启动或者继续流程的请求到来之时,在我们的服务器的处理线程中(类似于tomcat中的http-thread-pool开头的那些线程),我们通过调用类似于下面的API
runtimeService.startProcessInstanceByKey(...)
来发起一个流程,这时候整个流程就完全运行在http-thread-pool线程中,这就称之为我们借用了一个客户线程,在这个线程中,camunda始终会开启一个数据库事务,并在这个事务下进行流程的运转。
在这个情况下,流程会一直运转到下一个等待状态(wait status 代表流程会在后面的某个时期继续),然后完成状态的持久化到数据库中,结束事务。在bpmn标准中,有下列几个等待状态:
- Tasks:
- 接受任务(receive task)
- 用户任务(user task)
- 外部服务任务(service task: external task)
- Events:
- 消息事件(message event)
- 定时事件(timer event)
- 信号事件(signal event)
- Gateway
- 基于时间的逻辑门(Event Based Gateway)
我们可以通过下图来理解这个过程:
第一步,我们发起了完成task的请求,然后流程继续运行直到到达了定时等待事件,此时这个客户线程中的工作流运行已经结束,我们完成当前的事务并提交,然后返回控制给调用方,等待定时任务的触发。
这是正常情况,当我们出现异常的时候会怎么办呢?我们通过下图来进行理解:
当进行到发送任务(send invoice to customer)时触发了异常,这时候客户线程里的事务会完全得到回滚,然后把异常信息抛出给调用方,当下次该流程的请求进来的时候,会发现流程的状态点仍然是最开始的那个用户任务(provide shipping address)。这个就类似于游戏中的保存点,玩过游戏的同学都知道,我们在这个保存点到下一个保存点游戏过程中,如果出现什么意外(游戏人物挂了,机器死机了,老妈回来了),我们下次进入游戏,还是会回到最近的一个保存成功的保存点,继续玩。
这个错误处理机制可以说是非常简洁和直观了,它异常处理完全交给了调用方,并保证了状态的回滚,但是并没有做出任何分布式事务一致性的支持。比方说如果一个流程 A -> B-> C,当C抛出异常的时候,我们并没有提供把A和B任务中的操作回滚或者修正的能力,因此这个方案只是一个最初级的方案。
异常捕获和逻辑判断
我们知道我们自己diy的task,实际上就是一段段java代码,因此,我们可以在我们的代码中捕获异常,并且通过设置执行上下文中的参数,来标定我们的程序遭遇异常,一次来作为逻辑判断的依据,如下图所示:
这种方式怎么说呢,是个方案,但是对于我们代码的耦合较高,而且对于我们流程来说会造成比较大的干扰,因为如果你要做细粒度,task级别的错误排查和修正,你就免不了在每个task后面都加上这么一层逻辑判断,会对编排流程本身的逻辑带来困扰,这样做出来的流程图也不够清晰。
BPMN 2.0 错误事件
针对上述方案的局限性,在BPMN 2.0规范中给出了错误事件来作为显式的处理错误的方案。以下图为例:
一个典型的场景是如果我们要针对一个子流程中的错误来做出处理,一个比较简单的方案是在这个子流程上叠加一个错误边界事件(error boundary event),通过定义这样一个事件可以针对子流程中抛出的错误来进行额外的处理。需要注意的是,在这个图中,如果出发了错误边界事件 进入到错误处理流程,那本身的执行过程将会遭到中断(interrupting),因此错误事件也被称作是一种中断事件(interrupting event),关于这一块我在后续文章中会提及。
其实大家仔细看也会发现,错误事件,其实可以用前面的异常捕获和判断来等价完成,比如下图:
因此我们可以知道,错误事件其实可以看做是我们针对错误的一个简化的建模手段,并且从语义上能够给使用者一个清晰的感受,明晰了错误处理流程和正常业务处理流程的边界。
补偿机制
在了解了camunda的错误处理机制之后,我们就会想在错误处理机制之上完成task的修正补偿并最终支持分布式事务,因此BPMN规范提出了补偿事件(compensation event),通过对任务或者子流程定义一个compensation事件 和它对应的处理单元来完成修正补偿。camunda官方也提供了一个demo,github地址在此 。我们可以来分析下:
在上述这个流程中,每个task都有一个补偿边界事件(compensation boundary event),按照补偿事件的定义,补偿事件只会在他标识的task成功执行完毕之后才有可能被触发(A compensation boundary event can only be triggered after the activity to which it is attached completes successfully.)。这个跟之前一般的边界事件是不一样的。然后还有一个需要注意的是,在上面的流程图中有一个虚线框框定的区域,这个区域在BPMN规范中被称为消息驱动的子流程(event-driven subprocess)。这个子流程的用处就是在整个流程中如果出现了某个消息,就会触发这个流程。
在上面这个流程里面,这个消息驱动的子流程的开始事件是一个错误事件(error start event),因此,一旦流程中出现了错误(错误类型需要在错误事件中指定),这个消息子流程将就会启动,而本身的执行过程就会被中断(大家还记得我们之前说错误事件是个中断性的事件)。在错误事件触发消息子流程之后,会抛出一个补偿事件,这个事件会触发每个task下定义补偿事件处理器,并最终结束这个流程。以Book flight任务出错为例,一旦出现了异常,消息子流程启动了补偿机制,这时候由于Book hotel和Reserve car已经执行完成,他们对应的补偿事件处理任务Cancel car和Cancel hotel会被执行(注意执行顺序会反过来)。这就是camunda所描述的对分布式事务一致性进行支持的SAGA 模式。
这个补偿机制提供了一个基本的分布式事务一致性的支持,但是会有个问题,就是通过消息子流程并不能完成对于流程的一个管控(也有可能是我还没学到位)。比方说我在一个任务A执行完成之后继续执行了一串操作然后再这段操作中如果出现了异常,我不仅希望想有补偿机制对这一段操作进行补偿,还要返回我最开始的任务A(相当于之前保存点机制),目前消息子流程就没法做了。因此,我们就需要借助一个更强的工具,叫事务性子流程(transaction subprocess)。
事务性子流程
按照BPMN规范的定义,事务性子流程用来定一个业务事务,当一个业务事务被认定为失败的时候,这个事务中的任务就可以撤销已经完成的操作(通过之前说的补偿机制)。为了表示一个业务事务失败,BPMN提供了一个新的事件:撤销事件(cancel event)。撤销事件有两个形态:
- 当它作为业务事务子流程的终态的时候,表明这个子流程是失败的,需要回滚,继而触发流程中各个任务所定义的补偿处理
-
当它作为业务事务子流程的边界事件的时候,标明这个子流程被回滚了,继而触发外部流程的回滚处理流程,和错误事件一样,撤销事件是中断的,即它会中断外部流程的正常执行路径
在上述概念引入之后,我们就可以做到一个分布式事务和流程的完整控制了,以下图为例:
在这个流程中,在启动流程之后我们会到达一个接收等待任务,作为我们流程的一个保存点,在接收到指定的消息触发流程继续执行进入到了一个事务性子流程中,在这个子流程中我们会经过两个任务,当第二个任务出错的时候,我们会到达子流程的撤销终态,这会触发我们第一个任务的补偿处理,最终保证我们子流程事务的一致性。在此之后,子流程结束并触发边界上的撤销事件,这时候外部的流程会进入我们的撤销处理逻辑,即进入到log cancel任务中,并最终回到了我们的接收消息任务,即回到了我们之前的保存点,完成了一个事务的撤销和逻辑的流转。
结语
本文对于camunda中的错误处理、补偿机制和分布式事务的支持以及具体的流程设计做了说明,有兴趣的同学可以自己下来试验一下。另外关于最后一个demo我已经把代码放到了这里,欢迎大家尝试和交流。