4. 核心数据模型设计
数据模型是整个调度系统的骨架。骨架歪了,后面加再多肌肉也跑不稳。我在前两个项目里吃过这个亏——一开始图省事,把任务和调度计划塞到一张表里,结果后来要支持依赖等待、重跑历史、并行调度,改得我差点把键盘吃了。
这一章我们拆开揉碎了讲四个核心模型:Task(任务)、Dependency(依赖)、Schedule(调度计划)、Execution(执行记录)。每个模型我都会给出核心字段、设计理由,以及我踩过的坑。
核心原则:模型之间松耦合,但查询要高效。说白了就是——写的时候各管各的,读的时候一条 SQL 能拉出全貌。
4.1 Task 模型——任务定义
Task 模型描述「一个任务长什么样」。它不关心什么时候跑、依赖谁,只关心任务本身的元信息。
| 字段名 | 类型 | 说明 |
|---|---|---|
| task_id | BIGINT | 主键,自增 |
| task_name | VARCHAR(128) | 任务名称,全局唯一 |
| task_type | VARCHAR(32) | SQL / Shell / Python / HTTP |
| task_config | TEXT | JSON 格式,存具体执行参数 |
| owner | VARCHAR(64) | 负责人 |
| priority | INT | 优先级,1-10,越大越优先 |
| retry_times | INT | 失败重试次数,默认 3 |
| retry_interval | INT | 重试间隔(秒) |
| timeout | INT | 超时时间(秒),0 表示不限制 |
| status | TINYINT | 0=禁用, 1=启用 |
| create_time | DATETIME | 创建时间 |
| update_time | DATETIME | 更新时间 |
我的习惯:task_config 用 JSON 存,而不是拆成几十个字段。因为不同 task_type 的配置差异太大——SQL 任务要配数据源和查询语句,HTTP 任务要配 URL 和请求头。用 JSON 灵活得多,解析时按 type 做校验就行。
举个例子,一个 SQL 任务的 task_config 长这样:
{
"datasource_id": 12,
"sql": "INSERT INTO dws.dws_order_daily SELECT ...",
"engine": "spark",
"queue": "root.etl"
}
一个 HTTP 任务的 task_config:
{
"url": "http://api.example.com/sync",
"method": "POST",
"headers": {"Authorization": "Bearer xxx"},
"body": "{\"date\": \"${yesterday}\"}"
}
看到 ${yesterday} 了吗?这是参数替换。我建议在 Task 层就支持变量占位,调度引擎在运行时注入实际值。这样同一个任务可以跑不同日期,不用为每天建一个新任务。
4.2 Dependency 模型——依赖关系
依赖模型解决「谁等谁」的问题。OLAP 任务之间天然有上下游关系——ODS 层跑完才能跑 DWD,DWD 跑完才能跑 DWS。这个模型就是用来描述这种链条的。
| 字段名 | 类型 | 说明 |
|---|---|---|
| dep_id | BIGINT | 主键 |
| task_id | BIGINT | 当前任务 ID |
| parent_task_id | BIGINT | 上游任务 ID |
| dep_type | VARCHAR(16) | strong / weak |
| offset_day | INT | 依赖偏移天数,0 表示同一天 |
| create_time | DATETIME | 创建时间 |
dep_type 解释一下:
- strong(强依赖):上游必须成功,当前任务才能启动。这是最常见的。
- weak(弱依赖):上游跑没跑、成没成功,都不影响当前任务启动。用于一些「有更好,没有也行」的场景。
我曾经踩过的坑:一开始没设计 offset_day 字段。结果遇到一个场景——T+1 的报表依赖 T 日的离线数据,但 T 日的任务要凌晨 3 点才跑完。没有偏移量,我只能硬编码「等 3 小时」,后来改成 offset_day = -1 才彻底解决。记住:依赖不只是「等同一个批次」,还可能是「等上一个批次」。
举个例子,假设我们有三个任务:
- Task A:ODS 层数据同步,每天 02:00 跑
- Task B:DWD 层清洗,依赖 A,每天 03:00 跑
- Task C:DWS 层聚合,依赖 B,每天 04:00 跑
Dependency 表里会存两条记录:
task_id=2, parent_task_id=1, dep_type='strong', offset_day=0
task_id=3, parent_task_id=2, dep_type='strong', offset_day=0
调度引擎拿到任务 3 时,先查依赖表,发现要等任务 2 的 Execution 状态变成 SUCCESS。任务 2 同理,等任务 1。这样就形成了一条完整的调度链。
4.3 Schedule 模型——调度计划
Schedule 模型回答「什么时候跑」。它把 Task 和具体的时间规则绑定在一起。
| 字段名 | 类型 | 说明 |
|---|---|---|
| schedule_id | BIGINT | 主键 |
| task_id | BIGINT | 关联的任务 ID |
| cron_expr | VARCHAR(64) | Cron 表达式,如 0 0 2 * * ? |
| timezone | VARCHAR(32) | 时区,默认 Asia/Shanghai |
| start_date | DATE | 生效起始日期 |
| end_date | DATE | 生效结束日期,NULL 表示永久 |
| status | TINYINT | 0=暂停, 1=运行中 |
| create_time | DATETIME | 创建时间 |
我建议:一个 Task 可以绑定多个 Schedule。比如同一个数据同步任务,工作日每 1 小时跑一次,周末只跑一次。你只需要建两个 Schedule 记录,指向同一个 task_id 就行。别把任务复制一份,那是给自己挖坑。
Cron 表达式这块,我推荐用 Quartz 的 7 位格式(秒 分 时 日 月 周 年)。虽然比 Linux 的 5 位格式复杂一点,但精度高,而且支持「每月最后一个工作日」这种变态需求。
举个例子:
# 每天凌晨 2 点
0 0 2 * * ? *
# 每周一至周五早上 8 点半
0 30 8 ? * MON-FRI *
# 每月 1 号和 15 号
0 0 0 1,15 * ? *
4.4 Execution 模型——执行记录
Execution 模型是系统的「黑匣子」。每次任务跑完,所有关键信息都要落在这里。排查问题、统计 SLA、回溯历史,全靠它。
| 字段名 | 类型 | 说明 |
|---|---|---|
| exec_id | BIGINT | 主键 |
| task_id | BIGINT | 任务 ID |
| schedule_id | BIGINT | 调度计划 ID,手动触发可为 NULL |
| biz_date | DATE | 业务日期,如 2024-01-15 |
| status | VARCHAR(16) | PENDING / RUNNING / SUCCESS / FAILED / TIMEOUT / CANCELED |
| start_time | DATETIME | 实际开始时间 |
| end_time | DATETIME | 实际结束时间 |
| duration_ms | INT | 执行耗时(毫秒) |
| attempt | INT | 第几次重试,0 表示首次 |
| trigger_type | VARCHAR(16) | schedule / manual / backfill |
| log_path | VARCHAR(256) | 日志存储路径 |
| error_msg | TEXT | 错误信息 |
| create_time | DATETIME | 记录创建时间 |
注意:biz_date 和实际执行日期可能不同。比如 1 月 15 日凌晨跑的其实是 1 月 14 日的数据。biz_date 存的是「数据所属日期」,不是「跑任务的日期」。这个搞混了,回溯历史数据时会乱成一锅粥。
status 的状态流转是这样的:
PENDING → RUNNING → SUCCESS
→ FAILED → PENDING(重试)
→ TIMEOUT
→ CANCELED
trigger_type 我特别加了一个 backfill。为什么?因为 OLAP 场景下,补历史数据是家常便饭。如果补数据和正常调度混在一起,你分不清哪些是「今天该跑的」,哪些是「补昨天的」。有了 trigger_type,查问题的时候一眼就能看出来。
4.5 模型之间的关系
四个模型通过外键关联,但我不建议在数据库层面强约束。原因很简单——调度系统经常要批量导入、迁移、回滚,外键约束会拖慢操作。我习惯在 应用层做校验,数据库只存 ID。
核心关联路径:
- Task → Schedule:一对多。一个任务可以有多个调度计划。
- Task → Dependency:一对多。一个任务可以依赖多个上游任务。
- Schedule → Execution:一对多。一个调度计划每次触发都会生成一条执行记录。
- Task → Execution:一对多。手动触发或补数据时,Execution 不关联 Schedule,只关联 Task。
查询优化小技巧:Execution 表按 biz_date + task_id 建联合索引。这是最频繁的查询模式——「某一天某个任务跑得怎么样」。我见过有人只建了 exec_id 主键索引,结果查历史记录时全表扫描,几百万条数据查一次要十几秒。
嗯,四个模型讲完了。你可能会觉得「就这?几张表而已」。但说实话,调度系统 80% 的坑都出在模型设计上。字段少一个,后面就得用各种 hack 来补。字段多一个冗余,查询性能就降一截。这个度,得靠实际业务场景来打磨。
我个人习惯是先按上面的结构建好,跑一个月看看,再根据查询日志微调索引和字段类型。别想着一步到位,数据模型是长出来的,不是设计出来的。