工作流引擎
工作流引擎以各种姿态在软件历史上反复出现, 数不胜数:
- Yahoo Pipes
- IFTTT
- Zapier
- Pipedream
- Integromat
- n8n
- Node-RED
- Huggin
- Beehive
- Trigger Happer
- Apache NiFi
- Apache Airflow: 通过Python的运算符重载编写DAG作为工作流程.
- Apache Flink
- Apache ODE
- Apache Airavata
- Dagster: Airflow杀手, DAG能够直接以函数调用形式表示(分支条件通过多返回值实现, 太不直观).
- Perfect: Airflow杀手, 和Dagster很相似, 但给人的感觉不如Dagster.
- Amazon Simple Workflow (SWF)
- Luigi
- Airbyte
- Conductor
- mara-pipelines
- XState: 以执行副作用为主要功能的状态图, 与持久化存储结合后可以作为工作流程使用.
- <<Azure Durable Functions>>:
最早使用生成器做编排, 也最成熟的例子.
底层使用了事件溯源以支持重建状态(从中断中恢复).
底层框架是开源的: https://github.com/Azure/durabletask
为采取生成器+事件溯源机制所做的妥协:
- 为了允许重建状态, 编写代码的约束很多, 每个步骤都需要具有确定性.
- 由于重放, 编排器内相同的代码会重复执行.
算不上大问题, 因为大部份编排代码只是占用极少量内存和CPU时间而已, 这种交换往往是划算的.
- <<Cadence>>: Uber的工作流引擎, 和Azure Durable Functions高度相似.
- CI/CD引擎:
尽管CI/CI引擎可能不提供从中断中恢复的功能, 但它们也属于工作流引擎, 只不过它们的原语更倾向于特定类型的任务. - Deep Research, ChatGPT Agent等建立在LLM上的智能体项目.
- ...
这些工作流引擎都是为了完成某种自动化ETL(Extract, Transform, Load)任务, 或其他类型的长时运行任务.
甚至脚本解释器也可以被视作一种工作流引擎: 从工作流引擎的发展历史来看, 人工编写的脚本是很多工作流引擎的起点.
工作流引擎之间存在很多细节差异, 但在更高的抽象层面, 这些差异基本是可以忽略的, 其本质都高度相似.
甚至脚本解释器也可以被视作一种工作流引擎: 从工作流引擎的发展历史来看, 人工编写的脚本是很多工作流引擎的起点.
工作流引擎之间存在很多细节差异, 但在更高的抽象层面, 这些差异基本是可以忽略的, 其本质都高度相似.
- 容错性, 可中断, 可从中断处继续.
- 可扩展性, 并行化, 分布式, 可以水平扩展.
- 记录错误, 允许之后从错误中恢复任务.
- 主要的流程编排可以于一处完成.
- 能够与现有的其他服务集成.
许多工作流编排框架需要运行一个K8s集群, Hadoop集群, 或是其他分布式集群,
然而这对于99%的ETL任务来说都是不必要的, 单机解决这些任务的速度可能会是集群的几百几千倍.
然而这对于99%的ETL任务来说都是不必要的, 单机解决这些任务的速度可能会是集群的几百几千倍.
在现实世界里:
- GB规模的ETL任务可以通过组合Unix程序解决问题.
- TB规模的ETL任务可以通过一个单机的SQLite方案解决, 通过压缩数据库可以提高处理性能.
一些工作流编排框架明显偏向虚构的用例, 缺乏应用场景, 以至于需要人为创造需求来为其存在提供理由.
典型例子是IFTTT.
典型例子是IFTTT.
缺乏经验的使用者很容易因此陷入产品供应商刻意制造的"有一把锤子后看什么都是钉子"的陷阱.
如果工作流不是围绕实际需求构建出来的, 那么它什么也不是.
如果工作流不是围绕实际需求构建出来的, 那么它什么也不是.
在线服务通常会预先集成与第三方服务的协同, 考虑到一些集成的申请难度和维护成本(例如Twitter API),
使用这些在线服务有时会是达成目的的唯一方法.
使用这些在线服务有时会是达成目的的唯一方法.
像Pipedream, Zapier, Integromat这样的服务基本上是直接以与第三方服务的集成为卖点的.
这类以集成为卖点的服务具有很大的脆弱性, 因为它们依赖于自己不能控制的东西.
由于这类服务的这种显著特征, 它们的角色与其说是一个工作流, 更像是一个向Webhook/邮箱地址发送通知消息的胶水.
这类以集成为卖点的服务具有很大的脆弱性, 因为它们依赖于自己不能控制的东西.
由于这类服务的这种显著特征, 它们的角色与其说是一个工作流, 更像是一个向Webhook/邮箱地址发送通知消息的胶水.
当使用者的目标是与第三方服务集成, 实现一些简单的操作时, 低代码平台通常足够使用.
一旦使用者的需求超出服务供应商的预设时, 情况往往会急转直下, 以至于很难期待低代码平台能够在复杂场景下发挥作用.
一旦使用者的需求超出服务供应商的预设时, 情况往往会急转直下, 以至于很难期待低代码平台能够在复杂场景下发挥作用.
低代码只在需要的时候写代码, 但经常会发现到处都需要写代码, 或者通过不写代码的方式解决问题比写代码还要繁琐.
一些低代码平台会提供脚本语言作为组件, 但通常实现得相当差, 充满局限性.
一些低代码平台会提供脚本语言作为组件, 但通常实现得相当差, 充满局限性.
与一般的认识相反, 这种矛盾实际上是可调和的, 因为矛盾本质上只是一个封装问题:
对于新的问题, 首先通过低级代码来实现它, 然后再将其逐步抽象为高级代码就行了.
但是, 由于低级代码所需要的编程技能, 导致它不可能成为一种流行选项,
这一点从Zapier等服务对自定义代码的支持程度(只提供了最低限度的功能)就不难看出.
对于新的问题, 首先通过低级代码来实现它, 然后再将其逐步抽象为高级代码就行了.
但是, 由于低级代码所需要的编程技能, 导致它不可能成为一种流行选项,
这一点从Zapier等服务对自定义代码的支持程度(只提供了最低限度的功能)就不难看出.
函数链指的是按顺序逐个执行多个子过程.
每个子过程的结果都会被存储, 以便在中断时恢复.
每个子过程的结果都会被存储, 以便在中断时恢复.
// 没有依赖关系的子过程的函数链const result1 = await sub1()const result2 = await sub2()const result3 = await sub3()// 存在依赖关系的子过程的函数链const result1 = await sub1()const result2 = await sub2(result1)const result3 = await sub3(result2)一进多出.
创建数据的副本, 发送给多个子过程进行处理.
多进一出.
接收到所需的多个数据后, 发送给子过程进行处理.
扇出后接扇入, 即分散后重新聚合/收集结果.
面向切面的工作流创建方式.
协同工作流的最低级表示形式, 所有协同工作流都可以转换为此类数据结构.
典型代表是Apache Airflow, 已经逐渐被不需要手动定义DAG的项目取代.
sub1 >> [sub2, sub3] >> sub4{ sub1: [ sub2 , sub3 ], sub2: sub4, sub3: sub4}{ sub1: null, sub2: sub1, sub3: sub1, sub4: [sub2, sub3]}- 面向切面:
- 只要数据类型正确, 任务可以从工作流的中途开始运行.
- 可以图形化.
- 容易大规模并发, 吞吐量高.
- 结构简单, 实现容易.
- 依赖关系需要用代码来显式定义, 这导致代码中依赖关系定义和实现之间的垂直距离太远.
图形界面可以在相当程度上缓解这一问题, 但却可能会因此失去灵活性和扩展性. - 无法表示有环的工作流.
- 难以表示扇入.
- 中断任务的难度大.
流水线本质上是用来创建DAG的语法糖, 在表示上有意朝同步的流处理代码靠拢.
pipeline( from('source'), fanOut( [sub1] , [sub2] ), to('destination'))- DAG的一切优点.
- 可读性好于DAG.
- 难以表示有环的工作流.
- 难以表示扇入.
- 中断任务的难度大.
编排工作流的最低级表示形式, 表达力极差, 所有编排工作流都可以转换为此类数据结构.
稍大一点的状态机和状态图就会变得难以维护.
一种用生成器和事件溯源来表示工作流程的模式.
子过程产生的中间状态会以事件的形式保存进事件存储(Event Store),
配合生成器可以实现工作流程的状态重建, 从而能够从中断处恢复.
配合生成器可以实现工作流程的状态重建, 从而能够从中断处恢复.
除了使用生成器描述工作流程外, 实现还有几种不同的工作模式:
- 不中断模式(空间换时间):
工作流会一直存在于内存里, 总是在同步等待它所需的事件, 除非发生故障重启, 否则直到任务结束前都不会被回收.
这种模式下, 无谓的计算资源占用最小, 内存资源占用最大. - 中断模式(时间换空间):
工作流一旦开始等待, 就立即回收掉内存中的工作流, 并将自己注册到事件中心(Event Hub).
当事件中心收到对应事件时, 就根据事件存储重建工作流, 继续运行直到遇到下一个等待事件或工作流运行完成.
这种模式下, 内存资源占用最小, 无谓的计算资源占用最大.
这种模式通常会更有吸引力, 因为无谓的计算资源占总计算资源的比例通常小到可以忽略.
如果编排器需要验证每一个输入和输出对(以验证工作流程够不够纯), 深匹配算法可能会意外成为性能瓶颈. - 半中断模式:
结合不中断模式和中断模式, 预估等待事件所需的时间, 只在等待超时的情况下回收内存中的工作流.
具体代码表示形式可参考Azure Durable Functions和Cadence.
甚至也可以参考redux-saga, 因为它本质上也是用生成器编写的可执行状态图.
甚至也可以参考redux-saga, 因为它本质上也是用生成器编写的可执行状态图.
workflow(function* () { yield fanOut(data, [ sub1 , sub2 ])})workflow(function* () { const [result1, result2] = yield fanOut(data, [ sub1 , sub2 ])})支持async/await特性的语言可以用async/await替代生成器.
这是因为async/await特性本身就属于生成器的一种应用, 且实现编排器所需的特性非常少, async/await往往可以轻易满足.
这是因为async/await特性本身就属于生成器的一种应用, 且实现编排器所需的特性非常少, async/await往往可以轻易满足.
使用async/await的一些额外优点:
- 大部分已有的第三方库只会支持async/await, 而不支持生成器, 因此async/await可能会更好用.
- 实现起来比生成器容易, 因为async/await更高级.
- 表达力接近同步代码, 一个工作流程可以较快地从一个非分布式的编排式项目转换而来.
- 可以表示有环的工作流.
- 不是面向切面的:
- 任务不能从中途开始运行, 不能创建从中间状态起始的任务.
在技术层面上, 的确可以模拟出需要插入的中间状态之前的事件, 或者让编排器能够针对某一特定类型的输入进行跳过.
但这种hack背离了使用这种模式的初衷, 并降低了模式本身的表达力. - 事件的存储方式决定了, 中间状态在变更工作流程时会报废.
- 无法图形化.
- 较难大规模并发, 吞吐量低.
- 在中断模式下运行时, 总会产生非必要的I/O流量和用掉非必要的计算资源.