4. 任务依赖图建模:将实际任务抽象为节点、依赖关系抽象为边、权重与状态属性

好,咱们进入第四章。前面几章我们把可视化工具和基础概念过了一遍,现在该聊聊最核心的问题了——怎么把现实世界里乱七八糟的任务,变成一张计算机能理解的图。

说白了,任务依赖图建模就是做三件事:找节点、画边、贴属性。听起来简单吧?但我见过太多人在这上面翻车了。我刚开始做调度系统时,就犯过一个低级错误——把“编译代码”和“运行测试”拆成了两个节点,但忘了加依赖边。结果呢?测试跑在旧代码上,白白浪费了半小时。

4.1 节点抽象:把“事”变成“点”

先聊节点。现实中的任务千奇百怪:编译一个模块、发送一封邮件、训练一个模型、审批一份合同……但到了图里,它们都得变成一个个矩形或圆形节点。

我个人习惯,把节点抽象成三个层次:

  • 原子任务:不可再拆的最小执行单元。比如“执行SQL查询”。
  • 复合任务:由多个原子任务组成,但对外表现为一个节点。比如“数据清洗流程”。
  • 子图:整张图里嵌套的另一张图。比如“微服务A的完整部署流程”。

嗯,这里要注意:不要过度拆分。我曾经把一个“发送通知”拆成了“查收件人列表→拼接模板→调用API→记录日志”四个节点。结果图变得密密麻麻,根本看不清。后来我学乖了——粒度控制在“一个人/一个脚本一次能完成”的程度就够了。

我的经验:节点数量控制在 20~50 个之间最舒服。少于 20 个,信息量不够;多于 50 个,视觉上就开始打架了。如果任务真的很多,考虑用子图分层。

4.2 边抽象:依赖关系的三种类型

节点有了,接下来就是连线。依赖关系不是只有“A做完才能做B”这一种。我整理了一下,实际项目中常见的依赖分三类:

依赖类型 含义 例子
顺序依赖 B 必须等 A 完成才能开始 先编译,再运行测试
资源依赖 B 需要 A 产生的数据/文件 先下载模型权重,再加载推理
条件依赖 B 是否执行取决于 A 的结果 如果测试失败,则触发回滚任务

你想想看,如果只把“顺序依赖”画成边,那条件依赖怎么办?我见过有人硬把条件依赖画成虚线,结果团队里一半人看不懂。我的做法是:在边上加标签。比如用 type="conditional" 属性,或者直接在边上写“if failed”。

避坑:我曾经在一个项目里,把资源依赖和顺序依赖混在一起画。结果排查故障时,根本分不清“任务卡住是因为上游没跑完,还是因为上游跑完了但文件没传过来”。后来我强制要求:资源依赖用实线+箭头,顺序依赖用虚线+箭头,一目了然。

4.3 权重与状态:给节点和边“贴标签”

光有结构和关系还不够。你得知道每个任务要跑多久、占多少资源、现在是什么状态。这些信息,就是权重和状态属性。

4.3.1 权重属性

权重可以理解成任务的“代价”。常见的权重有:

  • 时间权重:预计执行时长(秒/分钟)
  • 资源权重:CPU/内存/GPU 消耗量
  • 优先级权重:任务的重要程度(1~10)
  • 失败概率权重:历史失败率(0~1)

我个人习惯把权重放在节点下方,用小字标注。比如一个编译任务,我会写成:

节点: 编译前端模块
  时间权重: 120s
  资源权重: 4核CPU / 8GB内存
  优先级: 8

这样调度器一看就知道:这任务重,得优先分配好资源。

4.3.2 状态属性

状态是动态的。任务跑起来之后,你得知道它现在是死是活。我常用的状态集是:

状态 含义 可视化颜色(我习惯用)
PENDING 等待执行 灰色
RUNNING 正在执行 蓝色
SUCCESS 执行成功 绿色
FAILED 执行失败 红色
SKIPPED 条件不满足被跳过 黄色

为什么强调颜色?因为故障定位时,你扫一眼图,红色节点扎堆的地方就是重灾区。我有个习惯:把 FAILED 节点周围两层的邻居节点也高亮,因为故障往往会扩散。

4.4 完整建模示例:一个 CI/CD 流水线

光说不练假把式。咱们拿一个真实的 CI/CD 流水线来建模。假设流程是这样的:

  1. 代码提交触发
  2. 并行执行:代码检查 + 单元测试
  3. 如果两者都通过,执行集成测试
  4. 集成测试通过后,构建镜像
  5. 构建成功,部署到测试环境
  6. 部署完成后,运行冒烟测试
  7. 冒烟测试通过,标记为“可发布”

对应的图模型,我用 SVG 画出来:

CI/CD 流水线依赖图模型 代码提交触发 代码检查 单元测试 集成测试 构建镜像 部署测试环境 冒烟测试 依赖说明 • 代码检查和单元测试 是并行关系 • 集成测试需要两者都通过 • 冒烟测试通过=可发布

你看,这张图里:

  • 节点:每个步骤都是一个矩形,颜色区分角色(触发/检查/构建/部署/测试)
  • 边:箭头表示顺序依赖,并行任务用分叉表示
  • 权重:每个节点可以附加时间、资源等属性(图上没写,但建模时要有)
  • 状态:运行时,每个节点会变成灰/蓝/绿/红/黄
核心要点:建模不是画图,是抽象。你抽象得好,故障定位时一眼就能看出“哪个节点卡住了上下游”;抽象得不好,图就是一张废纸。我每次建模前都会问自己一个问题:“如果这个任务挂了,我想在图上第一眼看到什么?” 答案就是你要重点建模的东西。

4.5 代码实现:用 Python 定义图结构

最后,咱们看看怎么用代码把模型写出来。我个人喜欢用字典加列表,简单直接:

# 节点定义
tasks = {
    "trigger": {"name": "代码提交触发", "type": "trigger", "weight": {"time": 5, "priority": 10}},
    "lint":    {"name": "代码检查",     "type": "check",   "weight": {"time": 30, "priority": 7}},
    "utest":   {"name": "单元测试",     "type": "test",    "weight": {"time": 60, "priority": 8}},
    "itest":   {"name": "集成测试",     "type": "test",    "weight": {"time": 120, "priority": 6}},
    "build":   {"name": "构建镜像",     "type": "build",   "weight": {"time": 90, "priority": 5}},
    "deploy":  {"name": "部署测试环境", "type": "deploy",  "weight": {"time": 45, "priority": 4}},
    "smoke":   {"name": "冒烟测试",     "type": "test",    "weight": {"time": 20, "priority": 3}},
}

# 边定义(依赖关系)
edges = [
    ("trigger", "lint",    {"type": "顺序依赖"}),
    ("trigger", "utest",   {"type": "顺序依赖"}),
    ("lint",    "itest",   {"type": "条件依赖", "condition": "lint == SUCCESS"}),
    ("utest",   "itest",   {"type": "条件依赖", "condition": "utest == SUCCESS"}),
    ("itest",   "build",   {"type": "顺序依赖"}),
    ("build",   "deploy",  {"type": "顺序依赖"}),
    ("deploy",  "smoke",   {"type": "顺序依赖"}),
]

# 状态初始化
for task_id in tasks:
    tasks[task_id]["status"] = "PENDING"

这段代码跑起来,你就有了一个完整的图模型。后面章节我们会讲怎么把它画出来、怎么更新状态、怎么定位故障。但今天先把地基打牢——节点、边、权重、状态,一个都不能少。

小技巧:我习惯在节点 ID 里带上类型前缀,比如 check_lint、test_unit、build_docker。这样看日志时,光看 ID 就知道它是什么任务,不用来回查字典。