3、任务依赖管理:DAG(有向无环图)在日志任务编排中的应用

日志任务不是孤岛。你想想看,一条日志从采集、解析、清洗、聚合到最终写入存储,中间要经过好几个环节。这些环节之间有严格的先后顺序——解析必须在采集之后,聚合必须在清洗之后。如果乱序执行,数据就全乱了。

我刚开始做日志平台的时候,用的是简单的「先来后到」队列。后来发现根本不行。有一次上游采集任务挂了,下游的解析任务还在傻等,结果整个管道堵死,数据延迟了整整两个小时。那次事故之后,我彻底转向了 DAG 编排。

为什么是 DAG,而不是别的?

说白了,DAG 就是一张有向无环图。有向,代表任务之间有明确的方向;无环,代表不会出现 A 依赖 B、B 又依赖 A 的死循环。

我见过不少团队用「状态机」或者「硬编码脚本」来管理依赖。结果呢?状态机一复杂就变成意大利面条,硬编码脚本改一次依赖要改三处代码。DAG 的好处在于:

  • 依赖关系可视化——图结构天然适合展示上下游
  • 无环保证——数学上杜绝死锁
  • 并行度最大化——没有依赖关系的任务可以同时跑
  • 容错粒度细——某个节点失败,只影响它的下游,不波及全链路

核心观点:DAG 不是银弹,但在日志任务编排这个场景里,它是最接近「正确解」的方案。我个人的经验是,只要任务数超过 5 个、依赖层级超过 2 层,就别用手动脚本了,上 DAG。

DAG 在日志任务中的典型结构

我们来看一个真实的日志处理 DAG。假设你有一个电商平台的访问日志,需要经过采集、解析、清洗、维度补全、聚合、写入六个阶段。画成 DAG 是这样的:

日志任务 DAG 编排示例 日志处理 DAG 示例 日志采集 日志解析 数据清洗 维度补全 数据聚合 存储写入 说明: 清洗完成后,维度补全、聚合、写入三个任务可以并行执行。聚合需要等待维度补全完成。

这张图里有个关键点:清洗节点之后,维度补全、聚合、写入三个任务没有相互依赖,所以可以并行执行。但聚合又依赖维度补全的结果,所以聚合必须等维度补全跑完才能启动。

我在项目中遇到过类似场景。当时有个日志管道,清洗之后要同时做「IP 地理信息补全」和「用户画像关联」。这两个任务互相独立,但后续的「地域聚合」必须等地理信息补全完成。用 DAG 表达这种「部分并行、部分串行」的关系,比硬编码优雅太多了。

DAG 的核心概念:节点、边、入度

要理解 DAG 调度,先记住三个基本概念:

概念 含义 日志场景举例
节点(Node) 一个具体的任务单元 日志解析任务、数据清洗任务
边(Edge) 任务之间的依赖关系 解析 → 清洗(解析完成后才能清洗)
入度(In-degree) 指向该节点的边的数量 聚合节点入度为 2(依赖清洗和维度补全)

调度器怎么知道哪个任务先跑?看入度。入度为 0 的节点就是「根任务」,没有前置依赖,可以立即执行。执行完后,把它从图中移除,更新下游节点的入度。如此反复,直到所有节点都跑完。

我的小技巧:在实现 DAG 调度器时,我会维护一个「入度表」和一个「就绪队列」。入度表记录每个节点的当前入度,就绪队列存放入度为 0 的节点。每次从就绪队列取一个节点执行,执行完后更新入度表,把新入度为 0 的节点加入队列。这个模式简单、高效,我在生产环境用了好几年。

DAG 调度中的常见问题与避坑

DAG 听起来很美好,但落地时坑不少。我踩过的几个:

  • 循环依赖检测——用户配置依赖时手一抖,A 依赖 B、B 依赖 C、C 又依赖 A。调度器一启动就死锁。我建议在提交 DAG 时做拓扑排序,如果排序失败(节点数少于总数),直接拒绝提交。
  • 节点失败重试策略——日志任务经常因为网络抖动或资源不足失败。我曾经遇到一个场景:清洗节点重试了 5 次都失败,下游的聚合节点一直等,导致整个管道阻塞。后来我加了「失败阈值」和「跳过策略」——如果某个节点重试超过 3 次,就标记为失败,下游节点可以选择「跳过」或者「使用默认值」继续执行。
  • 数据一致性——DAG 中某个节点失败后,已经执行成功的节点数据怎么处理?我个人的做法是:每个节点都设计成「幂等」的,失败重跑不会产生重复数据。日志数据天然带时间戳,配合去重机制,基本能保证一致性。

注意:千万不要在 DAG 中引入「隐式依赖」。比如你觉得「反正解析任务跑得快,清洗任务晚点启动也没事」,就在代码里写了个 sleep。这种隐式依赖一旦任务量变化,就会出问题。所有依赖关系必须显式声明在 DAG 的边上。

DAG 在日志平台中的落地实践

我参与过的一个日志平台,每天处理 50TB 以上的日志数据。DAG 调度器是我们自己写的,核心逻辑大概 300 行代码。给你看看简化版:

// 伪代码:DAG 调度器核心逻辑
class DagScheduler:
    def __init__(self, dag):
        self.graph = dag.nodes          // 节点列表
        self.edges = dag.edges          // 边列表
        self.in_degree = {}             // 入度表
        self.ready_queue = []           // 就绪队列

    def build_in_degree(self):
        for node in self.graph:
            self.in_degree[node] = 0
        for (from_node, to_node) in self.edges:
            self.in_degree[to_node] += 1

    def schedule(self):
        self.build_in_degree()
        // 初始化就绪队列
        for node, degree in self.in_degree.items():
            if degree == 0:
                self.ready_queue.append(node)

        while self.ready_queue:
            node = self.ready_queue.pop(0)
            execute(node)               // 执行任务
            // 更新下游入度
            for (from_n, to_n) in self.edges:
                if from_n == node:
                    self.in_degree[to_n] -= 1
                    if self.in_degree[to_n] == 0:
                        self.ready_queue.append(to_n)

        // 检查是否所有节点都执行了
        if any(degree > 0 for degree in self.in_degree.values()):
            raise Exception("存在循环依赖或未执行节点")

这段代码虽然简单,但核心思想都在里面了。实际生产环境还要加并发控制、失败重试、超时管理、监控告警。不过骨架就是这个。

DAG 与日志任务的生命周期管理

日志任务不是跑一次就完事的。每天都有新的日志产生,DAG 需要支持「周期调度」和「增量处理」。我习惯把 DAG 分成两类:

  • 全量 DAG——用于初始化或数据修复,处理全部历史数据
  • 增量 DAG——按时间窗口(比如每 5 分钟)处理新增数据

增量 DAG 的每个节点都要感知时间窗口。比如「解析节点」只解析最近 5 分钟的日志,「聚合节点」只聚合这个时间窗口内的数据。这样做的好处是:某个时间窗口失败了,只需要重跑那个窗口的 DAG,不影响其他窗口。

我记得有一次凌晨 3 点,某个数据源突然断连了 10 分钟。恢复后,我只需要重跑那 10 分钟的增量 DAG,而不是全量重跑。如果当时用的是全量 DAG,50TB 的数据重跑一遍,想想都头疼。

总结一下

DAG 在日志任务编排中的价值,说白了就是三句话:

  • 依赖关系清晰可见,不会出现「改一处崩一片」
  • 并行执行最大化,能同时跑的任务绝不排队
  • 容错粒度精细,一个节点失败不影响全局

嗯,如果你现在还在用脚本硬编码任务依赖,我建议你试试 DAG。从一个小场景开始,比如把采集、解析、清洗三个任务用 DAG 串起来。跑通了之后,你会发现日志管道的稳定性会上一个台阶。