第三章 任务模型与生命周期:任务定义与状态机

说实话,任务调度这块,我见过太多人一上来就写调度策略,结果连任务本身都没定义清楚。嗯,这就像盖楼不打地基——迟早要塌。今天咱们就把任务模型和生命周期彻底聊透。

3.1 任务定义的三种范式

任务定义,说白了就是告诉调度系统「你要干什么」。我个人习惯把任务分成三类:DAG任务、批处理任务、流式任务。这三类在底层实现上差异很大,但上层抽象可以统一。

3.1.1 DAG任务(有向无环图)

DAG任务是我在项目中用得最多的。它描述的是任务之间的依赖关系——A跑完才能跑B,B和C都跑完才能跑D。这种结构天然适合ETL、模型训练这类有严格顺序要求的场景。

举个例子,一个典型的DAG定义长这样:

{
  "dag_id": "etl_pipeline",
  "nodes": [
    {"id": "extract", "type": "source", "timeout": 300},
    {"id": "transform", "type": "compute", "deps": ["extract"], "timeout": 600},
    {"id": "load", "type": "sink", "deps": ["transform"], "timeout": 120}
  ],
  "edges": [
    {"from": "extract", "to": "transform"},
    {"from": "transform", "to": "load"}
  ]
}

你想想看,如果不用DAG,你怎么保证load在extract之前不会跑?用脚本硬写条件判断?我曾经接手过一个项目,就是用shell脚本硬写依赖,结果每次加新任务都要改一堆if-else,维护成本高得吓人。

我的经验: DAG定义时,一定要显式声明timeout。我遇到过最坑的一次,一个任务卡了12小时,下游全部阻塞,整个集群资源被占满。后来我强制要求每个节点必须有超时时间,默认值设为300秒。

3.1.2 批处理任务

批处理任务,就是那种「你给我一堆数据,我一次性处理完」的模式。它没有复杂的依赖关系,但通常对资源消耗很大。

批处理任务的核心参数我列一下:

参数 说明 我的建议值
batch_size 每次处理的数据量 1000-10000条,视内存而定
max_retries 失败重试次数 3次,再多就是代码问题了
resource_profile CPU/内存需求 按峰值预估,别抠门

批处理任务有个坑——你以为它是「一次性」的,但实际生产环境里,它经常被调度系统反复拉起。比如每天凌晨跑一次历史数据修复,本质上就是批处理。我建议把批处理任务设计成幂等的,跑多少次结果都一样。

3.1.3 流式任务

流式任务,嗯,这个有点不一样。它没有明确的「开始」和「结束」,而是一直在运行,持续消费数据源。

流式任务的定义通常包含:

  • source:数据源,比如Kafka topic、文件目录
  • transform:处理逻辑,通常是窗口聚合或状态计算
  • sink:输出目标,比如数据库、消息队列
  • checkpoint:容错机制,记录处理进度

我记得有一次,一个流式任务因为checkpoint配置不当,每次重启都从最早的数据开始重放,结果Kafka积压了上亿条消息。后来我强制要求:流式任务必须配置checkpoint,且checkpoint间隔不能超过60秒。

注意: 流式任务和批处理任务在调度系统里是两种完全不同的生命周期管理方式。批处理任务跑完就结束,流式任务需要持续监控健康状态。千万别用同一套逻辑去管理它们。

3.2 任务状态机

任务状态机,说白了就是任务的一生。从提交到结束,它会经历哪些状态?每个状态之间怎么转换?

下面这张图是我自己总结的标准状态机:

提交 等待 运行 完成 失败 入队 调度 成功 异常 重试 任务状态机流转图 标准五状态模型

3.2.1 提交状态

任务刚被用户或系统创建,还没进入调度队列。这个阶段,调度系统会做两件事:

  • 校验:检查任务定义是否合法,比如DAG有没有环、参数是否完整
  • 持久化:把任务信息写入数据库,防止丢任务

我曾经遇到过一个bug:任务提交后直接消失了。查了半天,发现是校验阶段抛了异常,但异常被吞掉了。后来我加了一条铁律——提交阶段任何异常都必须记录到审计日志。

3.2.2 等待状态

任务通过校验,进入调度队列。但它不一定马上能跑——可能资源不够,可能依赖的任务还没完成。

等待状态是调度系统最核心的优化点。我见过一些系统,等待队列里堆了几万个任务,调度器却不知道先跑哪个。嗯,这里就需要优先级和公平策略了。

关键指标: 等待时间(wait_time)是衡量调度效率的核心指标。如果平均等待时间超过30秒,说明你的调度器需要优化了。

3.2.3 运行状态

任务被分配到资源,开始执行。这个阶段,调度系统要持续监控:

  1. 心跳检测:任务还在不在跑?有没有挂掉?
  2. 资源使用:CPU、内存、IO是否正常?
  3. 进度反馈:跑了多少?还剩多少?

运行状态有个容易忽略的点——任务可能「假死」。就是进程还在,但已经不干活了。我遇到过好几次,任务占着资源不释放,调度器以为它还在跑,结果整个集群被拖垮。解决方案是加一个「活跃度检测」,定期检查任务是否有数据输出。

3.2.4 完成状态

任务正常结束。这个状态看起来简单,但有个细节:完成不代表成功。有些任务跑完了,但结果不符合预期。比如ETL任务跑完了,但目标表里一条数据都没写进去。

我建议在完成状态后加一个「结果验证」步骤。比如检查输出行数、校验数据质量。如果验证不通过,可以自动触发回滚或重跑。

3.2.5 失败状态

任务异常终止。失败的原因五花八门:代码bug、资源不足、数据异常、网络超时……

失败处理是调度系统的「兜底」能力。我的经验是:

  • 自动重试:对于临时性故障(比如网络抖动),重试3次
  • 死信队列:重试3次还失败,丢进死信队列,人工介入
  • 依赖传播:如果A任务失败,依赖A的B、C任务也要标记为「上游失败」
避坑指南: 我曾经把重试间隔设成1秒,结果一个任务失败后疯狂重试,把数据库打挂了。后来我改用指数退避——第一次等1秒,第二次等2秒,第三次等4秒,以此类推。

3.3 状态机的最佳实践

说了这么多,总结几条我踩坑踩出来的经验:

实践 说明
状态持久化 每次状态变更都写数据库,别只存在内存里。不然调度器一重启,任务状态全丢了
状态幂等 同一个任务重复提交,不会产生两个实例。用唯一ID去重
状态超时 每个状态都要有超时时间。比如「等待」超过10分钟,自动告警
状态审计 记录每次状态变更的时间、原因、操作人。出了问题好回溯

嗯,任务模型和生命周期就聊到这儿。记住一句话:任务定义决定了调度系统能做什么,状态机决定了调度系统怎么保证正确性。这两块搞扎实了,后面的调度策略、资源管理才有意义。


公众号:蓝海资料掘金营,微信deep3321