任务调度基础:调度系统核心概念

调度系统,说白了就是一套「谁、什么时候、干什么」的规则引擎。我做了这么多年调度,发现很多人一上来就扎进代码,反而把最基础的概念搞混了。今天咱们就把 DAG、任务实例、工作流这几个核心概念掰开揉碎,再聊聊三种调度策略到底怎么选。

DAG:有向无环图,调度系统的骨架

DAG 全称 Directed Acyclic Graph,有向无环图。你想想看,一个复杂的作业流,可能有几十个任务,有的要并行跑,有的要等上游完成。用 DAG 来描述,再清晰不过了。

节点就是任务,边就是依赖关系。「有向」表示方向,A→B 就是 A 跑完 B 才能跑。「无环」意味着不能出现 A→B→C→A 这种死循环。我在项目中遇到过有人不小心配了个环,结果调度器死循环,整个集群被打满。嗯,从那以后我每次上线前都会用工具检查一遍 DAG 的合法性。

DAG 的三个核心要素:

  • 节点(Vertex):每个节点代表一个可执行单元,比如一个 Shell 脚本、一个 Spark 任务、一个 SQL 查询。
  • 边(Edge):表示依赖关系,分为「强依赖」和「弱依赖」。强依赖是上游失败下游就不跑,弱依赖是上游失败下游照跑。
  • 入度/出度:入度是依赖多少个上游,出度是被多少个下游依赖。入度为 0 的节点就是根节点,可以最先启动。

下面这张图展示了一个典型的 ETL 作业 DAG:

ETL 作业 DAG 示例 ETL 作业 DAG 示例 数据采集 数据清洗 数据转换 数据加载 数据导出 数据采集完成后,清洗和转换可以并行执行,互不依赖

任务实例:DAG 里的「活」节点

DAG 是静态的蓝图,任务实例才是真正跑起来的「活」东西。每次调度器触发一个 DAG,就会为每个节点生成一个任务实例。我习惯把 DAG 比作菜谱,任务实例就是厨师按照菜谱炒出来的那盘菜。

任务实例有生命周期,一般经历这几个状态:

状态 含义 常见原因
等待 依赖的上游还没完成 上游任务还在跑,或者失败了
就绪 所有依赖满足,等待分配资源 依赖全部完成,但资源池满了
运行中 正在执行 资源已分配,进程已启动
成功 正常结束,退出码为 0 任务跑完了
失败 异常退出或超时 代码报错、OOM、网络超时
跳过 条件不满足,主动跳过 上游失败但配置了「失败继续」

我的经验:任务实例的「重试」机制一定要谨慎配置。我曾经见过一个任务重试了 10 次,每次都在同一个地方报错,白白浪费了 10 倍资源。建议重试次数不超过 3 次,而且重试间隔要指数退避。

工作流:把 DAG 和任务实例串起来

工作流(Workflow)是 DAG 的运行时实例。一个 DAG 定义可以生成多个工作流实例,比如每天凌晨跑一次,每次都是一个独立的工作流实例。

工作流实例包含:

  • 当前 DAG 的版本快照
  • 所有任务实例的状态集合
  • 调度时间、开始时间、结束时间
  • 运行参数(比如业务日期)

我个人习惯把工作流实例 ID 设计成「DAG 名称 + 调度时间」的组合,比如 etl_daily_2025-01-15。这样查日志的时候一眼就能定位到是哪一次运行。

调度策略:定时、依赖、事件触发

调度策略决定了「什么时候开始跑」。三种策略各有适用场景,我分别说说。

定时调度

最传统的方式,Cron 表达式搞定。适合周期固定的批处理任务,比如每天凌晨 2 点跑日报。

# 每天凌晨 2:30 执行
30 2 * * * /usr/bin/python run_daily_report.py

# 每小时执行一次
0 * * * * /usr/bin/python run_hourly_etl.py

定时调度的坑在于「时区」和「夏令时」。我踩过这个坑:服务器时区设的是 UTC,业务方要的是北京时间,结果每天差 8 个小时。后来我统一用 UTC+8 配置 Cron,并在代码里显式转换时间。

依赖调度

上游任务完成后自动触发下游。这种策略在流批一体场景下特别有用。比如实时数据管道,上游 Kafka 消费完了,下游立刻开始清洗。

依赖调度有两种实现方式:

  • DAG 内部依赖:同一个工作流里的任务,靠 DAG 边来触发。
  • 跨 DAG 依赖:不同工作流之间的依赖,比如日报依赖小时报的数据。

注意:跨 DAG 依赖容易形成「依赖地狱」。我曾经维护过一个系统,A 依赖 B,B 依赖 C,C 又依赖 A,结果形成了一个环。调度器直接卡死。解决方案是引入「外部依赖检查」机制,用独立进程定期检查上游状态,而不是让 DAG 互相引用。

事件触发

这是流批一体里最灵活的调度方式。当某个事件发生时(比如文件到达、消息入队、API 回调),立即触发任务执行。

事件触发适合:

  • 实时数据接入:Kafka 消息一到,立刻启动 Flink 任务
  • 文件触发:HDFS 上有新文件写入,触发 Spark 处理
  • API 回调:外部系统通知数据准备好了,开始拉取

事件触发最大的挑战是「幂等性」。同一个事件可能被重复发送,任务必须能处理重复执行。我建议在任务实例里加一个「事件 ID」去重表,确保同一个事件只被处理一次。

三种策略怎么选?

我一般这样判断:

  • 周期固定、数据量稳定 → 定时调度
  • 上下游强依赖、有明确先后顺序 → 依赖调度
  • 数据到达时间不确定、需要低延迟 → 事件触发

实际项目中往往是混合使用。比如我做过的一个实时数仓项目:

  • Kafka 数据用事件触发,实时写入 ClickHouse
  • 凌晨 3 点用定时调度,跑 T+1 的汇总任务
  • 汇总任务依赖实时数据的完整性,用依赖调度确保实时管道跑完才启动

三种策略配合起来,才能把流批一体的优势发挥出来。