任务模型设计:任务抽象、状态机、优先级与依赖

任务模型是整个调度系统的地基。地基没打好,后面再怎么优化都白搭。我见过不少团队一上来就写调度逻辑,结果任务状态乱成一锅粥,依赖关系理不清,最后只能推倒重来。今天咱们就把这块说透。

任务抽象:一个任务到底长什么样?

说白了,任务就是一个可执行的工作单元。但光有「执行」还不够,你得告诉调度器:这个任务是谁、什么时候能跑、跑完结果怎么处理。

我个人习惯把任务抽象成三个层次:

  • 元信息层:任务ID、名称、创建时间、所属业务线
  • 执行信息层:执行器类型、入参、超时时间、重试策略
  • 调度信息层:优先级、依赖列表、状态、队列归属

来看一个简化版的任务模型定义:

class Task:
    def __init__(self, task_id, name, executor_type, params):
        # 元信息
        self.task_id = task_id
        self.name = name
        self.created_at = time.time()
        
        # 执行信息
        self.executor_type = executor_type  # 'spark', 'flink', 'python'
        self.params = params
        self.timeout_secs = 300
        self.retry_policy = RetryPolicy(max_attempts=3, backoff=2.0)
        
        # 调度信息
        self.priority = Priority.NORMAL
        self.dependencies = []  # 依赖的任务ID列表
        self.state = TaskState.PENDING
        self.queue_name = 'default'
我的经验:千万别把业务逻辑塞进任务模型里。曾经有个项目把「数据清洗规则」直接写进了任务定义,结果每次改规则都要重启调度器。任务模型只负责「调度」,不负责「业务」。

任务状态机:别让状态乱飞

状态机是任务模型里最容易出坑的地方。我见过最夸张的一个项目,任务状态有 17 种,光「等待中」就分了三种。你想想看,调度器每次判断状态都要做一堆 if-else,不出 bug 才怪。

我建议只保留 6 个核心状态:

状态 含义 可流转到
PENDING 已创建,等待调度 READY, CANCELLED
READY 依赖已满足,可执行 RUNNING, CANCELLED
RUNNING 正在执行 SUCCEEDED, FAILED, TIMEOUT
SUCCEEDED 执行成功 (终态)
FAILED 执行失败 READY(重试时), CANCELLED
TIMEOUT 超时未完成 READY(重试时), CANCELLED
CANCELLED 被取消 (终态)

状态流转必须严格遵循这张表。我曾经在一个项目里发现有人直接让 PENDING 跳到了 SUCCEEDED,查了半天是因为某个开发在测试代码里写死了状态。嗯,从那以后我强制所有状态变更都走统一的状态机接口。

避坑指南:状态变更必须是原子操作。如果先更新数据库再发消息,中间崩溃了怎么办?我建议用乐观锁或分布式事务来保证状态一致性。别问我怎么知道的——线上事故的血泪教训。

下面这张图展示了完整的状态流转:

任务状态机流转图 PENDING READY RUNNING SUCCEEDED FAILED TIMEOUT CANCELLED 重试时回到 READY 说明: 实线为正常流转,虚线为重试流转。 PENDING 和 READY 状态可直接取消进入 CANCELLED。 FAILED 和 TIMEOUT 根据重试策略决定是否回到 READY。

任务优先级:谁先跑,谁后跑?

优先级设计看似简单,但实际落地时坑不少。我建议用「优先级队列 + 权重轮询」的组合方案。

先定义几个优先级等级:

等级 数值范围 适用场景
CRITICAL 90-100 线上故障恢复、核心链路
HIGH 70-89 用户实时请求、SLA 任务
NORMAL 40-69 日常批处理、报表生成
LOW 10-39 数据归档、历史清理
BACKGROUND 0-9 预计算、缓存预热

为什么用数值范围而不是固定值?因为这样可以在运行时动态调整。比如某个 NORMAL 任务突然需要加速,你可以把它的优先级从 50 提到 65,而不需要改代码。

核心原则:优先级只决定「谁先被调度」,不决定「谁能抢占资源」。高优先级任务不会中断正在运行的低优先级任务,而是等当前任务释放资源后再插队。

任务依赖:DAG 是核心

任务依赖本质上是一个有向无环图(DAG)。每个任务可以依赖多个前置任务,只有所有前置任务都成功了,当前任务才能进入 READY 状态。

依赖关系有三种常见类型:

  • 强依赖:前置任务必须成功,否则当前任务不执行
  • 弱依赖:前置任务失败时,当前任务可以跳过依赖继续执行
  • 条件依赖:根据前置任务的输出结果决定是否执行当前任务

来看一个依赖检查的伪代码:

def can_execute(task, task_store):
    """检查任务是否可以执行"""
    for dep_id in task.dependencies:
        dep_task = task_store.get(dep_id)
        if dep_task is None:
            return False, f"依赖任务 {dep_id} 不存在"
        
        if dep_task.state == TaskState.SUCCEEDED:
            continue
        
        if dep_task.state in (TaskState.FAILED, TaskState.TIMEOUT):
            if task.dependency_type == 'weak':
                continue  # 弱依赖,跳过
            else:
                return False, f"依赖任务 {dep_id} 失败"
        
        # 依赖任务还在执行或等待中
        return False, f"依赖任务 {dep_id} 未完成"
    
    return True, "所有依赖已满足"
我的建议:依赖关系一定要支持「超时等待」。曾经有个任务依赖一个跑了 2 小时的 ETL,结果 ETL 挂了,依赖任务一直卡在 PENDING 状态。后来我加了依赖超时机制,超过指定时间就自动标记为「依赖超时」,让任务走降级逻辑。

组合设计:优先级 + 依赖的协同

优先级和依赖不是孤立的。举个例子:一个 HIGH 优先级的任务依赖了一个 LOW 优先级的任务。如果 LOW 任务一直排不上队,HIGH 任务就永远无法执行。这就是典型的「优先级反转」问题。

我的解决方案是「依赖传递优先级」:

  1. 当 HIGH 任务依赖 LOW 任务时,LOW 任务的优先级临时提升到 HIGH
  2. LOW 任务执行完毕后,恢复原始优先级
  3. 如果 LOW 任务还有自己的依赖,递归传递

这样做的好处是避免了死锁,坏处是增加了调度器的复杂度。我个人建议只在 CRITICAL 和 HIGH 等级的任务上启用这个机制,NORMAL 及以下的任务不需要。

注意:依赖传递优先级可能导致「优先级膨胀」。如果一个 CRITICAL 任务依赖了 10 个 LOW 任务,这 10 个任务都会变成 CRITICAL,可能会挤占其他真正重要的任务。我一般会限制传递深度不超过 3 层。

好了,任务模型的核心设计就这些。记住三个关键词:抽象清晰、状态可控、依赖有序。下一块咱们聊聊调度策略——怎么把这些任务高效地分配出去。