4. 核心数据模型设计

数据模型是整个调度系统的骨架。骨架歪了,后面加再多肌肉也跑不稳。我在前两个项目里吃过这个亏——一开始图省事,把任务和调度计划塞到一张表里,结果后来要支持依赖等待、重跑历史、并行调度,改得我差点把键盘吃了。

这一章我们拆开揉碎了讲四个核心模型:Task(任务)、Dependency(依赖)、Schedule(调度计划)、Execution(执行记录)。每个模型我都会给出核心字段、设计理由,以及我踩过的坑。

核心原则:模型之间松耦合,但查询要高效。说白了就是——写的时候各管各的,读的时候一条 SQL 能拉出全貌。

OLAP 调度系统核心数据模型关系图 Task 任务定义 Dependency 依赖关系 Schedule 调度计划 Execution 执行记录 Task 定义「做什么」,Dependency 定义「等谁」,Schedule 定义「何时做」,Execution 记录「做得怎样」 四个模型通过 task_id 和 schedule_id 串联,形成完整的调度闭环

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 来补。字段多一个冗余,查询性能就降一截。这个度,得靠实际业务场景来打磨。

我个人习惯是先按上面的结构建好,跑一个月看看,再根据查询日志微调索引和字段类型。别想着一步到位,数据模型是长出来的,不是设计出来的。