任务流建模:任务节点定义、依赖关系建模、边与权重的设计原则

好,咱们进入第二章。任务流建模,说白了就是怎么把一堆乱七八糟的任务,理成一张能跑的图。你想想看,一个复杂系统里可能有几百个任务,谁先谁后,谁依赖谁,哪个任务失败了整个流程就得停——这些不提前定义清楚,后面调度肯定乱套。

我个人习惯,建模之前先想清楚三个东西:节点长什么样、依赖怎么连、边上的权重代表什么。这三件事定下来,DAG 的骨架就有了。

2.1 任务节点定义

节点是 DAG 的最小单元。每个节点代表一个可执行的任务单元。我在项目中遇到过不少团队,把节点定义得特别粗糙,就一个名字加一个脚本路径,结果后期想加重试、超时、资源限制,全得改代码。

一个靠谱的任务节点,至少应该包含以下几类属性:

属性类别 属性名 说明 示例值
标识 id 全局唯一,不可变 task_001
标识 name 人类可读的名称 数据清洗
执行 type 任务类型(脚本/HTTP/子流程) shell
执行 command 实际执行的命令或入口 python clean.py
控制 timeout 超时时间(秒) 300
控制 retry_count 失败重试次数 3
资源 cpu_cores 所需 CPU 核数 2
资源 memory_mb 所需内存(MB) 4096
状态 status 当前运行状态 pending
我的习惯:节点定义里一定要预留一个 extras 字典字段,用来放业务自定义属性。比如某个任务需要挂载特定的配置文件,或者需要指定 GPU 型号。别把节点定义写死了,后面改起来很痛苦。

来看一个实际的任务节点定义示例:

{
  "id": "etl_transform_01",
  "name": "ETL 数据转换",
  "type": "docker",
  "command": "spark-submit --class TransformJob transform.jar",
  "timeout": 600,
  "retry_count": 2,
  "retry_interval": 30,
  "cpu_cores": 4,
  "memory_mb": 8192,
  "extras": {
    "image": "spark:3.3.0",
    "mounts": ["/data:/data"]
  }
}

嗯,这里要注意:retry_interval 很多人会忽略。我曾经见过一个任务,重试间隔设成 0 秒,结果下游依赖还没释放,重试三次全失败,白白浪费资源。

2.2 依赖关系建模

节点定义好了,接下来就是连线。依赖关系是 DAG 的灵魂。没有依赖,那就是一堆并行任务,谈不上「流」。

依赖关系本质上表达的是:一个任务必须在另一个任务完成后才能开始。但实际场景里,依赖没那么简单。我归纳了三种常见的依赖类型:

  1. 全量依赖:所有上游任务都成功,当前任务才能开始。这是最常用的。
  2. 部分依赖:上游任务中只要有一部分成功,当前任务就可以开始。比如数据聚合任务,只要 80% 的分区数据到位了,就可以开始计算。
  3. 条件依赖:根据上游任务的输出结果,决定当前任务是否执行。比如「如果数据校验通过,就走入库流程;否则走告警流程」。

核心原则:依赖关系必须无环。这是 DAG 的基本约束。如果出现循环依赖,调度器会陷入死锁。我见过一个团队,因为配置错误导致 A 依赖 B、B 依赖 C、C 依赖 A,整个流程卡死了一整天。

依赖关系的建模方式,我推荐用 邻接表 或 依赖列表。邻接表适合图遍历,依赖列表适合快速判断前置条件。下面是一个依赖列表的示例:

{
  "task_id": "task_C",
  "depends_on": ["task_A", "task_B"],
  "dependency_type": "all_success"
}

如果是条件依赖,可以加一个 condition 字段:

{
  "task_id": "task_notify",
  "depends_on": ["task_validate"],
  "dependency_type": "conditional",
  "condition": "task_validate.output.status == 'failed'"
}
避坑指南:我曾经在一个金融项目里,把条件依赖写成了字符串表达式,结果上游任务改了输出字段名,条件判断全失效了。后来我改用 JSONPath 或 JMESPath 来解析条件,字段名变更时能自动适配,再也没出过问题。

2.3 边与权重的设计原则

边就是依赖关系的具体载体。但光有边还不够,很多时候我们需要给边加上权重。权重可以表达很多东西:

  • 数据传输量:上游任务输出给下游的数据有多大。调度器可以根据这个决定是否把任务调度到同一台机器上,减少网络开销。
  • 执行优先级:某些关键路径上的任务,权重更高,调度器会优先调度。
  • 延迟容忍度:权重低的边,可以容忍更大的调度延迟。
  • 资源消耗:边上的权重表示下游任务启动时需要从上游获取的资源量。

我个人习惯,边的权重设计遵循三个原则:

  1. 可量化:权重必须是一个可度量的数值,不能是「高」「中」「低」这种模糊描述。
  2. 可累加:路径上的权重应该能累加,用来计算关键路径的总开销。
  3. 可归一化:不同维度的权重,最终要能归一化到同一个量纲,方便调度器做综合决策。

来看一个带权重的边定义:

{
  "from": "task_A",
  "to": "task_B",
  "weight": {
    "data_size_mb": 500,
    "priority": 0.8,
    "latency_tolerance": 30
  }
}

下面这张图,展示了本章的核心知识体系:

任务流建模核心要素 任务节点定义 依赖关系建模 边与权重设计 标识 / 执行 / 控制 / 资源 全量 / 部分 / 条件依赖 数据量 / 优先级 / 延迟 设计原则 可量化 · 可累加 · 可归一化 无环约束 · 预留扩展字段 · 条件表达式标准化

2.4 建模过程中的常见陷阱

做了这么多年分布式系统,我踩过的坑不少。这里列几个典型的,你建模的时候可以留意一下:

  • 过度设计节点粒度:有人把每个函数调用都定义成一个任务节点,结果 DAG 里有上千个节点,调度开销比执行开销还大。我的建议是,节点粒度以「可独立部署、可独立重试」为最小单位。
  • 忽略隐式依赖:比如两个任务都写同一个文件,虽然没有显式依赖,但实际存在资源竞争。这种隐式依赖一定要显式化,否则会出现数据错乱。
  • 权重设置不合理:所有边的权重都设成 1,那权重就失去了意义。权重要有区分度,关键路径上的边权重应该明显高于非关键路径。
  • 循环依赖检测缺失:在提交 DAG 之前,一定要做拓扑排序检测。我习惯在建模工具里内置一个环检测,提交时自动校验,有环直接拒绝。
一个小技巧:建模初期,先用白板画一遍 DAG,把节点和依赖关系画出来。画完之后,从入口节点开始,沿着每条路径走到底,检查有没有遗漏的依赖。这个「白板走查法」我用了十年,至少帮我避免了 80% 的建模错误。