如何利用事件溯源思想实现分布式任务编排的容错?

297 阅读9分钟

在做分布式系统集成的时候,当一个功能涉及到多个平台的时候,通常面对的问题都是如果失败了怎么办?今天就给大家分享一个新思路-基于事件溯源实现分布式协调

我们的挑战

在进行正式开始之前我们需要先介绍下我们的场景是什么,要解决的问题是什么。

场景

在应用管理平台建设中需要整合内部的多个平台,比如容器、虚机、监控、发布、cmdb、负载等多个平台,每个平台都只负责某一部分功能,但是比如我们要做一个虚机扩容、灰度发布等通常就需要操作多个平台;如果是全部都是基于k8s的可能还好一点,但是对于一些公司这种平台建设早于容器平台,这时候就得由应用管理平台来进行协调了 image.png

问题

在做一些业务开发时,比如订单支付通常会为了完成这个功能,多个服务会针对业务进行改造,比如使用tcc、saga等分布式事务模型来进行业务的一致性保障,其核心参考ACID的事务模型。而在应用平台建设中,首先对应的业务方不太会配合你进行改造,其次很多业务场景也不可能实现事务。比如你扩容创建了一台虚机,如果后续流程失败了,你总不能把机器给干掉吧?

思考

既然不能像业务一样通过传统的事务模型进行业务完整性保障,那我们何不换一种思路呢?于是基于稳定性的思考,笔者将设计思路转换成提高系统的容错能力,并尽可能的减小爆炸半径,同时尽可能的提升系统的可扩展性,保障高可用。

扩展

提到容错能力比较典型的场景就是数据处理场景了,这里先给大家介绍一下在分布式数据场景中是如何进行容错的。在分布式数据中,通常由source、process、sink三部分组成,而在很多场景中又要实现准确的exactly once,我们看看再flink里面是如何进行设计的, 这里先给大家介绍相关概念

checkpoint

checkpoint通常用于保存某些记录的位置信息用于方便系统故障后快速恢复,在flink中也利用了checkpoint机制来实现exactly once语义,其会按照配置周期性的计算状态生成检查点快照,然后将checkpoint持久化存储下来,这样后续如果崩溃则就可以通过checkpoint来进行恢复 image.png

barrier

checkpoint只作用于flink内部,那如果要实现从source到sink整个链路的exactly once,则就会涉及到多个组件同时做checkpoint的同步, 这时候就要让多个组件的checkpoint达到一致性, 为了实现这个功能flink里面引入了Barrier用于切分数据流;就类似编程语言中的内存屏障,通过Barrier让多个组件同时进行对于checkpoint的持久化。每个Barrier都会携带一个checkpoint ID,这样整个数据流的多个组件就会同时进行同一个checkpoint的持久化了 image.png

checkpointCoordinator

有了Barrier机制之后则就需要一个触发和管理组件,利用barrier和checkpoit让source、process、sink三者同时进行checkpoint保存,在flink中就引入checkpointCoordinator来协调多个组件, 有了这三个核心的概念,就可以让在flink中的多个分布式组件中实现checkpoint机制了

两阶段提交

前面的设计都是位于flink内部,但是在数据处理中source、sink组件则通常是第三方平台,这个时候如果还要保障exactly once则除了幂等性就需要用到我们这里说的两阶段提交了;要实现两阶段提交,则就需要对应的平台提供事务机制,在preCommit阶段做数据的消费和写入,同时在commit阶段实现事务的提交,由于事务未提交则对应的平台读取不到对应的数据,只有最终都提交成功后,才可以读取到写入的数据

总结

通过上面的我们了解了如何基于利用两阶段提交、checkpoint、barrier结合事务机制实现分布式环境中的exactly once实现机制,后续在数据处理的场景中,我们就可以利用这套机制结合实际业务场景进行落地了

在下一节我们将开始介绍分布式任务编排中的另外一种实现机制,用于实现分布式系统的容错解决上述场景中遇到的问题

基于event sourcing的分布式任务编排

事件溯源

事件溯源保证应用状态的所有改变都保存在事件流中. 这样我们不仅能查询这些事件,我们也可以通过这个事件的日志来重新构建以前的状态, 以些为基础实现自动改变状态来应对追溯过的变化. image.png 其核心关键点:事件、顺序、持久化,通过对持久化存储中的事件按照顺序进行回放,我们就可以得到当前的状态,同理在任务编排的场景下,也可以借鉴类似的思想。

任务编排容错

任务编排的核心是通过编排对应的任务序列实现某个业务功能,在分布式环境中,通常会涉及到workflow任务的编排、task任务分配、运行时数据的存储等。在大多数的任务编排框架中,关注点都是任务调度。而我们今天接下来要介绍的temporal其关键点则是容错,即当对应的workflow、task如果执行失败,系统该如何进行恢复。也是事件溯源利用的主要场景。

任务执行容错语义

在前面的介绍exactly once场景中我们介绍过两阶段对事物机制的依赖,同理在任务编排中的状态,我们这里容错机制实现的语义是at-lease-once,即任务至少被执行一次,并尽可能保障业务不会重复被执行

溯源任务状态

image.png 结合事件溯源介绍下temporal里是如何基于事件溯源来实现容错语义的。在temporal一个workflow的当前状态,是由对应的workflow的事件reply来决定的,即通过回放workflow的所有事件来决定接下来该执行那个任务,在temporal里面的任务事件数据都由history服务统一存储,即事件数据的存储都是transaction的,这样就可以保障即使发生网络分区的情况,一个任务的执行结果也会只有一份, 那当我们要恢复任务状态的时候,就只需要通过事件回放,就知道接下来要执行那个任务,以及当前的状态数据

不变性

前面提到通过事件序列来进行事件回放可以得到当前状态,其实在任务编排场景中还有第二个序列-执行序列,即我们要执行的任务列表一定要是顺序的。只有这样才能顺着正确的道路继续恢复。

例如在go里面对slice的for range遍历是固定的,这里包含两部分:恢复slice和遍历slice, 即我再不同的机器上通过历史数据我可以构建出slice, 然后遍历这个slice这两个操作的结果都是一样的。

但是对map则不一定,我们并不能保证在不同机器上恢复和遍历这两个操作的结果都是一样的。所以workflow里面的逻辑和状态数据一定要是不变的

为什么是temporal

除了上面提到的容错,其实选择temporal更多的是就是易于学习和理解,大家可以看下我们创建虚机的workflow。

  • 如果出现异常则temporal会根据我们的重试策略自动进行重试,代码里面只有正常的业务逻辑
  • 如果我们需要等待任务的执行结果,就像写本地代码一样通过future.Get去获取结果
  • 如果执行能力不足,则就只需要加worker节点即可提高系统的分布式能力
  • 如果对于同一个资源申请单想要保障只有一个workflow,只需要在创建workflow的时候传入配置即可
// 创建虚机工作流
func CreateVMWorkflow(ctx workflow.Context, clientToken string, vmRequest cloud.CreateVMRequest, vmGroup ServerGroupt) (*CreateVMWorkflowResponse, error) {
	var (
		tvmTask     TVM
		response    CreateVMWorkflowResponse
		workflowCtx = workflow.WithActivityOptions(ctx, defaultTaskOptions)
	)

	// 创建虚机
	var createResponse *cloud.CreateVMResponse
	if err := workflow.ExecuteActivity(
		workflowCtx, tvmTask.CreateVMActivity, clientToken, vmRequest).Get(workflowCtx, &createResponse); err != nil {
		return nil, err
	}

	if !createResponse.Success() {
		return nil, errorx.StringError("create vm response error: %v", createResponse)
	}

	// 虚机初始化流程
	var futures []workflow.ChildWorkflowFuture
	for _, host := range createResponse.Data.Instance {
		future := workflow.ExecuteChildWorkflow(workflowCtx, WaitAndBindWorkflow, host, vmRequest.IDC, vmGroup)
		futures = append(futures, future)
	}

	// 等待虚机结果
	for _, future := range futures {
		var resp *AddServerLoadResponse
		if err := future.Get(workflowCtx, &resp); err != nil {
			response.Messages = append(response.Messages, err.Error())
			continue
		}
		if resp.Success {
			response.Success = append(response.Success, resp.IP)
			continue
		}
		response.Failure = append(response.Failure, resp.IP)
		response.Messages = append(response.Messages, resp.Message)
	}

	return response, nil
}

总结

temporal当然也有不足的地方,例如

  • 不支持dsl
  • 兼容性:同时针对Mysql分支版本比如BaikalDB就不太支持(致命bug),
  • 基于一致性hash的分片机制可能会存在任务分布不均
  • 资料太少,生产配置没有可以参考的优化(官方社区比较nice,反馈比较及时)

不过想想基于temporal可以快速实现一个分布式、可扩展、高容错、无状态的任务编排系统,其他都是小事情哈哈。后面有时间在给大家从源码上梳理下temporal的是如何实现上述功能的。包括任务分片、ringpop、信号、状态保存等

参考地址

什么是事件溯源:www.oschina.net/translate/e…

云原生学习笔记地址: www.yuque.com/baxiaoshi/t… 微信号:baxiaoshi2020 公共号: 图解源码

微信号:baxiaoshi2020