工作流引擎

工作流引擎以各种姿态在软件历史上反复出现, 数不胜数:
  • 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任务来说都是不必要的, 单机解决这些任务的速度可能会是集群的几百几千倍.
在现实世界里:
  • GB规模的ETL任务可以通过组合Unix程序解决问题.
  • TB规模的ETL任务可以通过一个单机的SQLite方案解决, 通过压缩数据库可以提高处理性能.
一些工作流编排框架明显偏向虚构的用例, 缺乏应用场景, 以至于需要人为创造需求来为其存在提供理由.
典型例子是IFTTT.
缺乏经验的使用者很容易因此陷入产品供应商刻意制造的"有一把锤子后看什么都是钉子"的陷阱.
如果工作流不是围绕实际需求构建出来的, 那么它什么也不是.
在线服务通常会预先集成与第三方服务的协同, 考虑到一些集成的申请难度和维护成本(例如Twitter API),
使用这些在线服务有时会是达成目的的唯一方法.
像Pipedream, Zapier, Integromat这样的服务基本上是直接以与第三方服务的集成为卖点的.
这类以集成为卖点的服务具有很大的脆弱性, 因为它们依赖于自己不能控制的东西.
由于这类服务的这种显著特征, 它们的角色与其说是一个工作流, 更像是一个向Webhook/邮箱地址发送通知消息的胶水.
当使用者的目标是与第三方服务集成, 实现一些简单的操作时, 低代码平台通常足够使用.
一旦使用者的需求超出服务供应商的预设时, 情况往往会急转直下, 以至于很难期待低代码平台能够在复杂场景下发挥作用.
低代码只在需要的时候写代码, 但经常会发现到处都需要写代码, 或者通过不写代码的方式解决问题比写代码还要繁琐.
一些低代码平台会提供脚本语言作为组件, 但通常实现得相当差, 充满局限性.
与一般的认识相反, 这种矛盾实际上是可调和的, 因为矛盾本质上只是一个封装问题:
对于新的问题, 首先通过低级代码来实现它, 然后再将其逐步抽象为高级代码就行了.
但是, 由于低级代码所需要的编程技能, 导致它不可能成为一种流行选项,
这一点从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, 因为它本质上也是用生成器编写的可执行状态图.
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更高级.
  • 表达力接近同步代码, 一个工作流程可以较快地从一个非分布式的编排式项目转换而来.
  • 可以表示有环的工作流.
  • 不是面向切面的:
    • 任务不能从中途开始运行, 不能创建从中间状态起始的任务.
      在技术层面上, 的确可以模拟出需要插入的中间状态之前的事件, 或者让编排器能够针对某一特定类型的输入进行跳过.
      但这种hack背离了使用这种模式的初衷, 并降低了模式本身的表达力.
    • 事件的存储方式决定了, 中间状态在变更工作流程时会报废.
    • 无法图形化.
    • 较难大规模并发, 吞吐量低.
  • 在中断模式下运行时, 总会产生非必要的I/O流量和用掉非必要的计算资源.