2. 任务生命周期模型:从创建到销毁的完整状态机

调度任务这东西,说白了就是一个有状态的过程。你提交一个任务,它不会瞬间完成,中间会经历各种状态变化。我见过不少团队,一开始只定义了「成功」和「失败」两种状态,结果线上出问题的时候,连任务到底卡在哪一步都说不清楚。

所以这一章,我们来聊聊任务从生到死的完整状态机模型。我个人习惯把状态分成三类:稳定态、过渡态和终态。稳定态就是任务停在那里等你操作,过渡态是正在执行中,终态就是不会再变了。

核心观点:一个设计良好的状态机,能让你的调度系统在异常发生时快速定位问题,而不是靠猜。

2.1 九种核心状态定义

我参与过的几个数据平台项目,最终沉淀下来的状态模型基本一致。下面这张表,你可以直接拿去用。

状态 类型 含义 触发动作
创建 初始态 任务实例刚生成,还没做任何检查 调度器实例化任务
就绪 稳定态 依赖满足、资源够用,等待执行 依赖检查通过
运行 过渡态 正在执行用户代码 调度器分配资源并启动
成功 终态 执行完成,退出码为0 任务正常结束
失败 终态 执行异常,退出码非0 任务异常退出
重试 过渡态 失败后自动重新调度 重试策略触发
暂停 稳定态 人为挂起,不执行也不销毁 运维手动操作
终止 终态 人为强制结束运行中的任务 运维手动操作
废弃 终态 任务不再需要,标记清理 生命周期策略触发

嗯,这里要注意,「创建」和「就绪」虽然看起来差不多,但区别很大。创建只是生成了一个实例,还没做依赖检查。就绪是确认了所有上游都跑完了,资源也预留好了。我曾经在一个项目里把这两个状态合并了,结果上游任务重跑时,下游任务莫名其妙就启动了,数据对不上,排查了两天才发现是状态设计的问题。

2.2 状态流转图

光看表格不够直观,我画了一张状态机流转图。你盯着看一分钟,整个生命周期就清楚了。

任务生命周期状态机流转图 任务生命周期状态机流转图 创建 就绪 运行 成功 失败 重试 暂停 终止 废弃 依赖满足 分配资源 正常结束 异常退出 触发重试 重新排队 手动暂停 恢复 手动终止 手动终止 过期清理 标记清理 图例说明 • 实线箭头:自动流转(系统触发) • 虚线箭头:手动流转(运维触发) • 蓝色节点:初始态 / 过渡态 • 绿色节点:成功终态 • 红色节点:异常终态 • 灰色节点:清理终态

你仔细看这张图,会发现几个关键点。第一,「运行」是唯一能产出「成功」或「失败」的状态。第二,「重试」不是直接回到「运行」,而是先回到「就绪」重新排队。为什么?因为重试时可能需要重新分配资源,或者等待依赖重新跑完。我见过有人把重试直接连到运行,结果资源没释放,任务跑起来就报错。

2.3 状态流转的代码实现

理论说完了,来点实际的。下面是一个简化版的状态机实现,用 Python 写的。你注意看状态转移的校验逻辑。

class TaskStateMachine:
    def __init__(self):
        # 定义合法的状态转移
        self._transitions = {
            'created':    ['ready'],
            'ready':      ['running', 'paused', 'abandoned'],
            'running':    ['success', 'failed', 'paused', 'killed'],
            'failed':     ['retrying', 'killed', 'abandoned'],
            'retrying':   ['ready'],
            'paused':     ['ready', 'abandoned'],
            'success':    ['abandoned'],
            'killed':     ['abandoned'],
            'abandoned':  []  # 终态,不可再转移
        }
    
    def transition(self, task, new_state):
        current = task.state
        if new_state not in self._transitions.get(current, []):
            raise ValueError(
                f"非法状态转移: {current} -> {new_state}"
            )
        # 记录状态变更日志
        task.state = new_state
        task.state_changed_at = datetime.now()
        # 这里可以触发回调,比如发送告警
        self._on_state_change(task, current, new_state)
    
    def _on_state_change(self, task, old, new):
        # 实际项目中,这里会写日志、发消息、更新缓存
        print(f"[状态变更] {task.id}: {old} -> {new}")

我的习惯:每次状态变更都记录完整的时间戳和操作人。线上排查问题时,这行日志能帮你省下至少半小时。

2.4 避坑指南:状态设计中的常见问题

做状态机设计,有几个坑我踩过,你注意避开。

  • 状态爆炸:有人把「重试中」「重试成功」「重试失败」都单独定义成状态。其实没必要,重试只是一个过渡动作,最终还是会落到「成功」或「失败」。状态越多,维护成本越高。
  • 缺少暂停态:我曾经在一个项目里没设计暂停态,结果上游数据延迟,下游任务已经跑起来了,只能手动 kill 掉再重跑。有了暂停态,你可以优雅地挂起任务,等条件满足再恢复。
  • 终态不可逆:记住,「成功」「失败」「终止」「废弃」一旦进入,就不能再变。我见过有人把「失败」的任务手动改成「就绪」重新跑,结果状态审计对不上,数据链路全乱了。正确的做法是:失败后走「重试」流程,或者新建一个实例。
  • 并发状态冲突:同一个任务实例,不能同时处于「运行」和「暂停」。分布式环境下,一定要用乐观锁或分布式锁来保证状态变更的原子性。

特别注意:状态机的实现一定要幂等。比如「成功」状态被重复设置两次,不能报错,也不能产生副作用。你的下游系统可能因为网络重试而重复调用状态变更接口。

2.5 状态持久化与查询

状态数据怎么存?我个人建议用一张单独的状态变更历史表,而不是只更新任务表里的最新状态。你想想看,如果只存最新状态,你怎么知道这个任务重试了几次?每次重试间隔多久?

字段 类型 说明
task_id string 任务实例ID
from_state string 变更前状态
to_state string 变更后状态
operator string 操作人(系统或用户)
reason string 变更原因
created_at datetime 变更时间

有了这张表,你可以轻松回答这些问题:这个任务为什么失败了?谁把它暂停了?重试了几次?每次间隔多久?

好了,状态机的核心模型就这些。你把这个模型吃透了,后面讲调度策略、重试机制、监控告警,都会顺很多。记住一句话:状态设计的好坏,直接决定了你的调度系统能扛多大的复杂度。