任务流建模:任务节点定义、依赖关系建模、边与权重的设计原则
好,咱们进入第二章。任务流建模,说白了就是怎么把一堆乱七八糟的任务,理成一张能跑的图。你想想看,一个复杂系统里可能有几百个任务,谁先谁后,谁依赖谁,哪个任务失败了整个流程就得停——这些不提前定义清楚,后面调度肯定乱套。
我个人习惯,建模之前先想清楚三个东西:节点长什么样、依赖怎么连、边上的权重代表什么。这三件事定下来,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 的灵魂。没有依赖,那就是一堆并行任务,谈不上「流」。
依赖关系本质上表达的是:一个任务必须在另一个任务完成后才能开始。但实际场景里,依赖没那么简单。我归纳了三种常见的依赖类型:
- 全量依赖:所有上游任务都成功,当前任务才能开始。这是最常用的。
- 部分依赖:上游任务中只要有一部分成功,当前任务就可以开始。比如数据聚合任务,只要 80% 的分区数据到位了,就可以开始计算。
- 条件依赖:根据上游任务的输出结果,决定当前任务是否执行。比如「如果数据校验通过,就走入库流程;否则走告警流程」。
核心原则:依赖关系必须无环。这是 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 边与权重的设计原则
边就是依赖关系的具体载体。但光有边还不够,很多时候我们需要给边加上权重。权重可以表达很多东西:
- 数据传输量:上游任务输出给下游的数据有多大。调度器可以根据这个决定是否把任务调度到同一台机器上,减少网络开销。
- 执行优先级:某些关键路径上的任务,权重更高,调度器会优先调度。
- 延迟容忍度:权重低的边,可以容忍更大的调度延迟。
- 资源消耗:边上的权重表示下游任务启动时需要从上游获取的资源量。
我个人习惯,边的权重设计遵循三个原则:
- 可量化:权重必须是一个可度量的数值,不能是「高」「中」「低」这种模糊描述。
- 可累加:路径上的权重应该能累加,用来计算关键路径的总开销。
- 可归一化:不同维度的权重,最终要能归一化到同一个量纲,方便调度器做综合决策。
来看一个带权重的边定义:
{
"from": "task_A",
"to": "task_B",
"weight": {
"data_size_mb": 500,
"priority": 0.8,
"latency_tolerance": 30
}
}
下面这张图,展示了本章的核心知识体系:
2.4 建模过程中的常见陷阱
做了这么多年分布式系统,我踩过的坑不少。这里列几个典型的,你建模的时候可以留意一下:
- 过度设计节点粒度:有人把每个函数调用都定义成一个任务节点,结果 DAG 里有上千个节点,调度开销比执行开销还大。我的建议是,节点粒度以「可独立部署、可独立重试」为最小单位。
- 忽略隐式依赖:比如两个任务都写同一个文件,虽然没有显式依赖,但实际存在资源竞争。这种隐式依赖一定要显式化,否则会出现数据错乱。
- 权重设置不合理:所有边的权重都设成 1,那权重就失去了意义。权重要有区分度,关键路径上的边权重应该明显高于非关键路径。
- 循环依赖检测缺失:在提交 DAG 之前,一定要做拓扑排序检测。我习惯在建模工具里内置一个环检测,提交时自动校验,有环直接拒绝。