任务调度基础:调度系统核心概念
调度系统,说白了就是一套「谁、什么时候、干什么」的规则引擎。我做了这么多年调度,发现很多人一上来就扎进代码,反而把最基础的概念搞混了。今天咱们就把 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:
任务实例: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 的汇总任务
- 汇总任务依赖实时数据的完整性,用依赖调度确保实时管道跑完才启动
三种策略配合起来,才能把流批一体的优势发挥出来。