4. 任务模型与生命周期:任务定义、状态机与依赖关系
各位同学,今天我们来聊聊算力调度器里最核心的抽象——任务模型。说白了,就是调度器到底在调度什么东西?
我刚开始接触分布式调度时,觉得任务不就是一段代码吗?后来踩了坑才发现,任务的生命周期管理才是调度器的灵魂。你想想看,一个任务从提交到结束,中间可能经历几十种状态变化,任何一个环节出问题,整个集群都可能被拖垮。
4.1 任务定义:Job / Task / Instance 三层模型
我个人习惯把任务拆成三层:Job(作业)、Task(任务)、Instance(实例)。这三层各有各的职责,缺一不可。
| 层级 | 含义 | 典型属性 |
|---|---|---|
| Job | 用户提交的完整计算作业 | 作业ID、优先级、资源配额、DAG定义 |
| Task | Job 内的一个逻辑计算单元 | 任务ID、依赖关系、资源需求、重试策略 |
| Instance | Task 的一次具体运行实例 | 实例ID、所在节点、状态、开始/结束时间 |
举个例子:你提交了一个 Spark 作业(Job),里面包含 10 个 Stage(Task),每个 Stage 可能启动 3 个 Executor(Instance)。嗯,这里要注意:一个 Task 可能对应多个 Instance,比如失败重试时,同一个 Task 会生成新的 Instance。
核心原则:Job 是用户视角,Task 是调度视角,Instance 是执行视角。三层解耦后,调度器可以灵活处理各种异常场景。
4.2 任务状态机:从 Pending 到 Succeeded/Failed
状态机是调度器的骨架。我见过不少系统把状态机搞得太复杂,结果维护成本飙升。其实核心状态就这几个:
Pending → Running → Succeeded
↓
Failed → (重试) → Pending
↓
Cancelled
每个状态转换都对应一个事件。比如:
- Pending → Running:调度器分配了资源,实例开始执行
- Running → Succeeded:实例正常退出,返回码为 0
- Running → Failed:实例异常退出,或者节点宕机
- Failed → Pending:触发重试策略,重新调度
避坑指南:我曾经遇到过一个线上事故——状态机里漏了「超时」状态。结果有个任务卡在 Running 状态整整 3 天,占着资源不放。后来我强制要求:每个状态都必须有超时兜底。
实际生产环境中,状态机还会更复杂一些。比如我们系统里加了这些状态:
- Queued:任务排队中,还没轮到调度
- Allocating:正在申请资源,但还没拿到
- Killing:收到取消指令,正在清理
- Lost:节点失联,实例状态未知
你想想看,如果节点突然宕机,Instance 状态从 Running 变成 Lost,调度器需要根据 Task 的重试策略决定是否重新调度。这个逻辑写不好,很容易出现「僵尸任务」。
4.3 任务依赖关系:DAG 调度
大多数计算任务都不是孤立的。比如一个 ETL 流程:先抽取数据,再清洗,再聚合,最后写入。这就是典型的 DAG(有向无环图)依赖。
我习惯用邻接表来表示 DAG:
// 伪代码示例
class DAGNode {
String taskId;
List<String> dependencies; // 依赖的任务ID列表
int priority; // 优先级,用于拓扑排序
ResourceRequest resource; // 资源需求
}
// 拓扑排序示例
List<DAGNode> topologicalSort(List<DAGNode> nodes) {
// 计算入度
Map<String, Integer> inDegree = new HashMap<>();
for (DAGNode node : nodes) {
inDegree.put(node.taskId, node.dependencies.size());
}
// 入度为0的节点先执行
Queue<DAGNode> queue = new LinkedList<>();
for (DAGNode node : nodes) {
if (inDegree.get(node.taskId) == 0) {
queue.offer(node);
}
}
List<DAGNode> result = new ArrayList<>();
while (!queue.isEmpty()) {
DAGNode current = queue.poll();
result.add(current);
// 更新下游节点的入度
for (DAGNode downstream : getDownstream(current)) {
inDegree.put(downstream.taskId, inDegree.get(downstream.taskId) - 1);
if (inDegree.get(downstream.taskId) == 0) {
queue.offer(downstream);
}
}
}
return result;
}
注意:DAG 调度最怕环。我曾经在线上遇到过因为配置错误导致任务 A 依赖 B,B 依赖 C,C 又依赖 A 的死循环。调度器直接卡死,所有任务都动不了。所以 DAG 必须做环检测,而且要在提交时就做,别等到运行时才发现。
实际调度时,DAG 调度器会维护一个「就绪队列」:
- 所有入度为 0 的 Task 进入就绪队列
- 调度器从就绪队列中取 Task,分配资源执行
- Task 完成后,更新下游 Task 的入度
- 入度变为 0 的 Task 加入就绪队列
- 重复直到所有 Task 完成
这里有个性能优化点:就绪队列的优先级。我建议根据 Task 的关键路径长度来排序,关键路径上的 Task 优先调度,这样可以缩短整体作业的完成时间。
4.4 知识体系总览
下面这张图总结了本章的核心逻辑,我画得比较简洁,但每个点都是实战中必须掌握的:
这张图把三层模型、状态机流转、DAG 依赖串在了一起。你仔细看,Job 包含 Task,Task 运行出 Instance,Instance 在状态机里流转,而 Task 之间通过 DAG 决定调度顺序——这就是调度器的核心骨架。
总结一下:
- 三层模型让调度器能灵活处理不同粒度的异常
- 状态机必须有超时兜底,防止任务卡死
- DAG 调度要提前做环检测,关键路径优先
嗯,这一章的内容就到这里。任务模型是调度器的地基,地基打不牢,后面所有优化都是空中楼阁。我建议你动手画一画自己系统的状态机,看看有没有遗漏的状态转换——相信我,画完你会有新发现的。