几年前曾经写过一点点对于缓存框架设计的体会,这大半年和工作流系统打交道颇为丰富,因此想总结一点关于工作流系统的设计。
首先,明确工作流(workflow)系统的定义。 维基百科 上有极其简单的介绍。我记得以前在文章里面说过,作为大公司里面的小team,为了做一些有趣的东西,从而更好的招人,通常有几个众人皆知的突破口:比如一个更符合业务需求的storage,再比如一个自定义的工作流系统(workflow)。在Amazon内部,我接触过好多个workflow,而且大多以 Amazon SWF 为原型(当时学习的时候还写了一点体会,link 1和link 2),于是宏观上看,60%的东西是一样的,大同小异;但是也有很多重要的元素大不相同,而它们被放到一起比较也是常事。几次折腾之后,我也慢慢在思考,如何去设计一个工作流系统,其中都有哪些重要的需要考虑到的方面。
基本上随便设计什么基础设施,扩展性都是重要的考虑内容。作为workflow来讲,基本上工作节点的水平扩展是考量扩展性的最重要标志。既然工作节点可以水平扩展,那么这就意味着任务(task)必须是以pull的方式由工作节点主动去获取,而不是由pull的方式从调度节点来分配(曾经非常简单地比较过pull和push,但其实二者差异远不止文中内容之浅显)。任务的分配上,需要考虑这样的事情:如果有多个工作节点尝试来pull任务,该分配给谁?具体来说,比如这样的例子:如果每一个task节点允许同时执行5个任务,而现在可同时执行的总任务数只有5个,总共的task节点也有5个,最理想的状态应当是这5个被均匀分配到这5个节点去,但是采用简单的pull机制并不能保证这一点,有可能这5个任务全部跑到一台机器上去了,因为这并不超过一个节点可同时执行任务数量的上限。
另一方面,通常来讲,所有任务都应当是idempotent的,即可以重复提交执行,执行若干次和执行一次的结果是一样的。工作节点的任务执行可以在任意一步发生错误,随着节点数量的增加,这样的错误更多地成为一种常态,而不是“异常”。工作节点的健康状态需要由某种方式来维系和通知,最典型和廉价有效的方式就是“心跳”,我曾经写过一篇文章详细介绍一种心跳系统的设计,感兴趣的话,欢迎移步阅读。
事实上,当考虑到了独立的资源管理功能,异步和同步任务的划分就变得自然而然。
在某些情况下,分布式锁变成一个必选项。比如前面提到的资源管理。有许多资源是要求操作是独占的,换言之,不支持两个操作并发调用,期间可能出现不可以预料的问题;另一方面,一个节点在对资源进行操作时,它需要和别的节点进行协作,从而两个工作节点的操作是有序和正确的,不至于发生冲突。
举个例子来说,工作节点A要查询当前EMR的状态,如果已经空闲10分钟,就要执行操作结束掉这个EMR资源;而工作节点B则查询该EMR的状态,如果没有被结束掉,就要往上面提交新的计算任务。这时候,如果没有分布式锁的协作,问题就来了,可能B节点先查询发现EMR状态还活着,就这这一瞬间,A节点结束了它,可是B不知道,接着提交了一个计算任务到这个已经结束了的(死了的)EMR资源上,于是这个提交的计算任务必然执行失败了。
有很多分布式锁的实现方式,简单的有强一致性的存储系统,当然也有更高效的实现,比如一些专门的分布式锁系统。
之前讲到了性能架构上的可扩展性,在功能层面亦然。
大多数workflow,都采用了去中心节点的设计,保证不存在任何单点故障问题。所有的子系统都是。也保证在业务压力增加的情况下,标志着可用性的latency在预期范围之内。其它的内容不展开,介绍这方面的文章到处都是。
这里既指workflow一次执行的生命周期管理,也指单个task的生命周期管理。
谈论这些必然涉及到这样几个问题:
这是workflow执行的流程图,也是所有task之间依赖关系的表述。我见过多种表达方式的,有XML的,也有JSON的,还有一些不知名的自己定义的格式的。有些workflow的定义可以以一个图形化工具来协助完成这个流程图。这个DSL的设计,一定程度上决定了workflow的使用是不是能够易于理解。另外提一句,这里提到的这个可选的图形化工具,毕竟只是一个辅助,它不是workflow的核心(你可以说这个DSL是核心的一部分,但这个帮助完成的工具显然不是)——我见过一个团队,workflow整体设计得不怎么样,跑起来一堆问题,但是这个工具花了大量的时间精力去修缮,本末倒置。
这也是一个nice-to-have的东西,对于每一个task,都存在input和output,它们可以完全交给用户自己来实现,比如用户把它们存储到文件里面,或者写到数据库里面,而workflow根本不管,每个task内部自己去读取相应的用户文件即可。但是更好的方法是,对于一些常用和简单的input、output,是可以随着execution一起持久化到workflow和task的状态里面去的。这样也便于workflow的definition里面,放置一些根据前一步task执行结果来决策后续执行的表达式。
另外,还有一个稍微冷门的use case,就是input和output的管理。通常workflow是重复执行的,而每次执行的input和output的数据规模往往是很多人关心的内容。关于这部分,我还没有见到任何一个workflow提供这样的功能。许多用户自己写工具和脚本来获取这样的信息。
对于metrics,核心的内容也无非节点的健康状况、CPU、内存,task执行时间分布,失败率等等几项。有些情况下用户还希望自行扩展。
关于日志,则主要指的是归档和合并。归档,指的是历史日志不丢失,或者在一定时间内不丢失,过期日志可以被覆写,从而不引起磁盘容量的问题;而合并,指的是日志能被以更统一的视角进行查询和浏览,出了问题不至于到每台机器上去手动查找。缺少这个功能,有时候会很麻烦。在工作中我遇到过一个资源被异常终止的问题,为了找到那个终止资源的节点,我查阅了几十个节点的日志,痛苦不堪。
把这两个放一起是因为,代码升级是不可避免且经常要发生的。为了保证平滑部署,显然通常情况下,节点上的代码不能同时更新,需要一部分一部分进行。比如,先终止50%的节点,部署代码后,激活并确保成功,再进行剩下那50%的节点。但是在这期间存在新老代码并存的问题,这通常会带来很多奇形怪状的问题。对于这种问题,我见过这样两个解决方式:
无论选用哪一种,这种方式实现起来相对简单,但是也有不少问题,比如这种情况下,外部资源怎么处理?例如在外部EMR资源上执行Spark任务,但是已经有老代码被放到EMR上去执行了,这时候工作节点更新,这些EMR上正在执行的任务怎样处理?是作废还是保留,如果保留的话这些执行可还是依仗着老代码的,其结果的后续处理是否会和刚部署的新代码产生冲突。再比如对于workflow有定义上的改变(比如DAG的改变),对于现有的execution,应当怎样处理,是更新还是保持原样(通常都是保持原样,因为更新带来的复杂问题非常多)。
先说那么多吧,思路不够清晰,欢迎讨论并一起完善。