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 知识体系总览

下面这张图总结了本章的核心逻辑,我画得比较简洁,但每个点都是实战中必须掌握的:

任务模型与生命周期核心结构 Job(作业) 作业ID / 优先级 / 资源配额 Task(任务) 任务ID / 依赖 / 资源需求 Instance(实例) 实例ID / 节点 / 状态 包含 运行 状态机流转 Pending Running Succeeded Failed 分配资源 正常完成 异常退出 重试 DAG 依赖调度 A B C D 就绪队列:入度为 0 的 Task 优先调度

这张图把三层模型、状态机流转、DAG 依赖串在了一起。你仔细看,Job 包含 Task,Task 运行出 Instance,Instance 在状态机里流转,而 Task 之间通过 DAG 决定调度顺序——这就是调度器的核心骨架。

总结一下:

  • 三层模型让调度器能灵活处理不同粒度的异常
  • 状态机必须有超时兜底,防止任务卡死
  • DAG 调度要提前做环检测,关键路径优先

嗯,这一章的内容就到这里。任务模型是调度器的地基,地基打不牢,后面所有优化都是空中楼阁。我建议你动手画一画自己系统的状态机,看看有没有遗漏的状态转换——相信我,画完你会有新发现的。

可可精品资料库