导读本文梳理 51Talk 数仓调度体系从 Azkaban 迁移到 Apache DolphinScheduler 3.2.2、围绕单工作流 DAG 建立生产治理闭环的过程,回答一个核心问题:当核心链路收敛到一个大 DAG 后,如何让它从“能跑”变成“长期可信”?
主要内容包括以下几个部分:
1. 迁移背景:从一层一个 flow 到任务级 DAG
2. 单工作流设计:把依赖变成生产契约
3. 迁移迭代中的三类技术挑战
4. 治理体系:让治理经验进入日常运行
5. 可复用规范:让读者带走一张检查清单
6. 后续演进与总结
迁移背景:从一层一个 flow 到任务级 DAG
51Talk 是在线英语教育平台,其数仓承载用户、订单、课程等核心业务数据。数仓调度跑在多套独立的 Apache DolphinScheduler 集群上,覆盖运营、市场、财务、产品、研发、分析等部门,以及中国港澳台地区和越南、印尼、中东等海外市场;每套集群内部,ODS → DWD → DWS → APP 的核心链路收敛在一个工作流里。多套集群只做调度任务层面的隔离,业务数据按属地规则独立存储;本文只讨论调度系统本身的技术治理,不涉及用户个人数据的跨境传输与存储。
1.1 旧体系的问题不是“老”,而是不可治理
我们的数仓最早跑在 Azkaban 上,任务分 ODS、DWD、DWS、APP 四层。老集群里有 数千个 Shell 脚本、上万张 Hive 表,每天要调度的任务也数以千计。脚本里塞着 Hive SQL、Sqoop、DataX 和邮件通知,任务之间谁依赖谁,主要靠 job 文件、脚本顺序和大家的约定来维护。
任务少的时候这套东西够用,规模一大,问题就从“用起来别扭”变成了“根本没法稳定治理”。
一层一个 flow,失败会“连坐”。 Azkaban 本身是任务级 DAG,没有“层”这个概念,我们把每一层打成了一个 flow。它的默认失败策略是:一个任务失败,这个 flow 就不再启动剩下的节点。于是同一层里跟它毫无关系的任务,也一起停住。影响面由“是不是同一个 flow”决定,跟真实数据流向没关系。一次局部异常被放大成整层阻塞,补数时还得靠人判断哪些能跳。
Shell 把任务在干什么藏起来了。 调度平台只看得到脚本退出码,不知道它读了哪些表、写了哪些分区,更判断不了改一个字段会影响到谁。日期变量、SQL、重试逻辑、外部命令全搅在一起,依赖关系成了“只有作者知道”的暗知识。评估一次变更、判断恢复范围,全靠人翻脚本、凭记忆,漏掉一条依赖就是一次生产异常。
变更查不到。 改了 job 文件、参数或依赖,历史版本经常还原不回来。出了事,团队只能在操作记录、脚本目录和各自的记忆里,拼一条残缺的时间线。
没有链路级的状态巡检。 Azkaban 能告诉你某个任务是成是败,却答不了:实例跑半天为什么不往下派生了、某个中间状态是不是已经超时、整条链路到底还在不在往前走。
所以这次迁移真正的目标,是把藏在脚本和层级顺序里的生产约定一条条摆到明面上。把任务从旧页面搬到新页面,只是顺带的结果。
1.2 为什么选择单工作流
迁到 DolphinScheduler 后,我们并没有把主链路拆成一堆互不相干的小工作流,而是把 ODS → DWD → DWS → APP 这条核心链路装进一个工作流,用任务级 DAG 把上下游关系画出来。
其实更常见的做法是一个项目下建多条小工作流、每条单独发。小工作流确实能让单个 DAG 简单点,可跨链路的依赖就变成了 Dependent 任务,真正的影响面散落在多个工作流定义里,排查起来得来回跳。只有几千个任务的依赖全落进同一张关系表,我们看到的才是完整的数仓血脉,而不是被工作流边界切碎的片段;规模越大,这份“一眼看全”越值钱。
单工作流的价值体现在三个方面。
依赖摆到明面上:上下游之间的边就在同一个 DAG 和关系表里,不用再靠 Shell 的执行顺序,或者猜表名。
影响面收窄:我们遇到过一次,Azkaban 里 ODS 层一个任务挂掉,同层 80 多个任务跟着一起停住;换成任务级 DAG 之后,通常只有真正依赖它的几个下游(我们线上一般是 3–5 个)会停,影响面从“一整层”缩到“一条链”。
恢复更准:补跑、重跑、跳过都能按具体节点来,不用整层重来。
但单工作流不是“越大越好”。它装的是同一条核心链路,不是所有任务都往里塞。临时报表、弱依赖、治理巡检、低优先级实验,这些该留在外面;否则一条临时任务的改动也可能触发主链路整体重建、整体发布。我们就把一张临时报表塞进过主链路,结果它的上游表一变,整条主链路跟着重建,核心产出受了影响。单工作流的边界,是护住核心链路,别让非关键任务的改动波及它。
判断一个任务该不该进主链路,就问三句:上下游稳不稳定,影不影响核心产出,要不要跟主链路共用一套补数和运行规则。有一句答不上,就该拆出去。
1.3 Shell 转结构化任务:让任务成为治理对象
迁移时,我们把这数千个 Shell 脚本里藏着的逻辑拆出来,转成 DolphinScheduler 的 SQL、Sqoop、DataX 这些结构化任务。关键变化在任务本身:它从黑盒脚本,变成了能解析、能版本化、能检查的对象;任务类型叫什么反而是次要的。
结构化之后,任务定义和它产出的数据能对上号了,也方便同步到 Git。不过任务结构化并不会让 DolphinScheduler 自动获得完整血缘;在我们这里,真正能用的治理能力是三块拼起来的:平台给任务级 DAG 和元数据表,自研脚本做批量同步和检查,团队再用变更审核把这些检查塞进发布流程。
这一步也划定了本文的范围:不讲参数替换脚本和 API 调用的细节,只看任务变成治理对象之后,依赖、状态、资源这些问题是怎么一个个冒出来的。
单工作流设计:把依赖变成生产契约
2.1 收敛的不只是节点,还有规则
单工作流一眼能看清依赖,但更关键的是:“失败、跳过、补数”这些动作,在整条链路上第一次有了统一、固定的含义。
Azkaban 那会儿,一个任务挂了,下游哪些要等、哪些能跳过,全靠我们当场判断:同一个任务,这次说能跳、下次说不能跳,两个人补出来的结果都不一样。收敛成单工作流后,谁依赖谁直接写进关系表,“成功、跳过、失败”三种状态在整条链路上同一套规矩:节点挂了,只有真正用到它产出的下游停下来等,其余照跑。补数、重跑、跳过这才第一次能被写成规则,而不是每次都靠人拍脑袋。
规则统一了,排查也顺了。一个工作流实例能一层层往下钻:先看 command 有没有被消费,再看 task instance 有没有生成,再看依赖边和 Worker 上的执行节点,问题到底是卡在“启动之前”“实例生成之后”还是“执行期间”,很快就能定位。后面讲的状态巡检,干的就是这个。
不过,集中也等于风险集中。一张几千节点的大图,关系表、版本号、坐标有一处对不上,整张图就可能加载不出来;一个中间节点状态说不清,也可能带卡后面一串任务。收益和风险是一起来的:依赖看得越清,元数据质量就越要命;链路越集中,边界和状态就越不能含糊。
2.2 任务依赖设计的五条原则
在几次问题复盘后,我们把单工作流建模约束归纳为五条原则:
原则
具体要求
解决的问题
任务级依赖优先
按真实上下游建边,不按数仓层级打包成一个 flow
降低无关任务的阻塞范围
只保留必要依赖
不为了“保险”重复添加相同或无业务意义的边
避免关系表膨胀和排查歧义
复杂依赖下废弃节点要收口
在复杂依赖场景里,废弃多分支节点时改成“空跑成功”任务(跑完立刻返回 success 的轻量任务),或直接重构依赖边,不要只设为禁止执行
避免复杂依赖下下游等待不收敛
跨工作流依赖有等待策略
Dependent 任务配置重试等待和超时时间
避免上游重试时下游提前失败
代码变更可追溯
任务脚本(SQL/Shell 等)同步到 Git
支持脚本级 diff 和回滚
表里的“多分支节点”,指同时承接多个上游、又分叉到多个下游的节点。
五条里最容易被忽略的是“只保留必要依赖”。依赖边多并不更安全;一条没有业务意义的边,可能让任务在上游出事时被无辜拖住,也可能在关系表里留下重复记录。我们为了给一张报表“上个保险”,给它加过 6 条多余的前置依赖,后来其中一条上游失效,排查了两小时才发现,是多余的边导致的构建异常。我们定的建边标准就一句:下游到底用不用上游的产出,而不是“连上显得保险”。
还有一条要在上线前就定死:废弃的多分支节点怎么退役,这条也容易在上线后埋雷。不能只是设成“禁止执行”就留在 DAG 里;只要它还连着好几个上游和下游,就得给它一个能真正返回 success 的轻量任务,或者直接把依赖边改掉。平台对“禁止执行”本身有完成语义,但在多分支汇聚、又要停止收口的场景里,这条链路仍然可能收不了口。3.2 节的 READY_STOP 卡死就是我们实际遇到的一次,原因放在那一节讲。
定这些规矩,是为了让每条边在运行时都只有一个解释,跟 DAG 好不好看没关系。解释不清的依赖,迟早会在状态机里变成等待、重试或告警。
迁移迭代中的三类技术挑战
在一次 DolphinScheduler 生产巡检中,我们第一次对任务关系表(t_ds_process_task_relation)做全量扫描,发现了超过 10000 条重复依赖。与此同时,线上还出现过几种看似无关的现象:工作流画布偶尔空白,实例状态显示 running 却不再派生任务,API 返回启动成功但 command 长时间无人消费,实例停止后卡在准备停止(READY_STOP),多台 Worker 都在线但任务集中到同一台,宽表 SQL 任务甚至会把 Worker JVM 推到 OOM。
这些问题有一个共同点:进程活着、心跳正常,但调度链路已经不可信了。以前我们总是先问 Master 挂没挂、Worker 掉没掉,后来才意识到,大 DAG 的治理得接着往下问:元数据对不对,command 有没有真的被消费,工作流实例(workflow instance)是不是一直在往前走,Worker 到底能不能调度,任务跑完数据靠不靠谱。
3.1 元数据一致性:画布空白只是第一个症状
业务同事第一次反馈,是“工作流打开后画布一片空白”。还有另一个工作流,能正常点启动,实例也显示 running,可等了 20 分钟,一个 task instance 都没派生出来。
误判: 我们一开始试了刷新页面、重新保存工作流、重启 Master。刷新没用,说明不是前端缓存;重新保存有时候“能好”,但解释不了为什么;重启 Master 能缓一阵,问题又会在别的工作流上复现。浏览器缓存、接口超时也都查过,都没法解释“页面正常打开,DAG 却不见了”。
证据链: 真正的突破口在工作流详情接口。在浏览器 DevTools 里看接口返回,任务关系列表(processTaskRelationList)里有异常记录:前置任务 code(接口字段 preTaskCode)有值,对应的任务名却是空的。也就是说,关系表里记了一条依赖边,可它指的前置任务在任务定义表里不存在(或者版本对不上)。顺着 t_ds_process_task_relation、任务定义、工作流版本往下查,我们发现三类异常。
重复依赖: 同一组前置任务 code(库字段 pre_task_code)和后置任务 code(post_task_code),出现多条一模一样的记录。第一次全量扫出 10000 多条,分布在几十个工作流里。它们是批量建任务时并发写出来的:API 层没有幂等键,关系表也没有唯一约束兜底。我们查过同一批关系的写入时间,全部落在同一秒,这就是批量写入留下的直接痕迹。不是每条都会立刻报错,但会把 DAG 构建、依赖遍历、问题定位的复杂度一起拉高。
幽灵依赖: 关系表里的前置任务 code(pre_task_code)指向一个已经不存在任务定义。对前端来说,这是条没地方落脚的边;对后端来说,它可能让实例派生拿到不完整的上游上下文。
版本与坐标不一致: 任务定义、关系表里的版本号和坐标(locations)JSON 没同步。有的任务 code 在坐标(locations)里有、关系表里没有,反过来也有。任务在,页面却找不到它的坐标;关系边在,对应的任务定义却是另一个版本的。前端拿到任务数据,找不到对应的(x,y)坐标,画布就渲染不出来。
图3|元数据不一致的因果链
根因收敛: 这不是单个 bug,是批量操作里元数据没有形成闭环。DolphinScheduler 的 DAG 最终落在数据库里:任务定义表、关系表、版本字段、坐标字段,四者必须一致,任何一处断了,都会影响工作流能不能加载、页面能不能渲染、实例能不能派生。批量建任务、反复编辑保存的时候,这几张表之间的同步没有事务兜底,日积月累就攒出一堆不一致。
应急处理: 先备份,再清重复依赖、删幽灵依赖、恢复必要节点,重新保存工作流,让平台重新生成版本数据。直接改元数据库只能当应急手段,一定要限定范围、留记录、备好回滚。
长期治理: 这是另一套打法。我们把关系表巡检和每日清理做成长效手段:巡检负责发现存量和当天新增,清理程序第二天把当天冒出来的少量重复清掉,不让它攒着。第一次清理之后,按次日巡检的口径,重复记录归零;当天批量写入仍会产生少量新增,第二天被清掉、不累积。关键不在那一次清理,而在清理之后每天还有巡检和清理在兜底。
3.2 状态流转:API 成功不等于调度成功,点停止也不等于收口
子案例 A:command 不消费——Master 进程在,关键线程不推进
第二类问题出在启动链路。通过 API 启动工作流,日志显示 command 已经写入,API 也返回创建成功,可前端就是没有新的工作流实例。
误判: 一开始我们以为是网络延迟或者前端没刷新。等了 5 分钟、刷新页面、重新调 API,command 数在涨,实例数却不动。然后重启 Master,重启后短暂恢复:command 开始被消费、实例开始生成,可跑一段时间又复发:新的 command 又堆在命令表(t_ds_command)里,实例 ID(process_instance_id)长时间是 0。
证据链: 这条链路应该是:
API 创建 command → 写入 t_ds_command → MasterCommandLoopThread 消费 → 创建 t_ds_process_instance(工作流实例表)→ 派生 t_ds_task_instance(任务实例表)。
图4|command 从写入到消费
这五步里,前四步在 command 消费线程上同步走完,最后一步派生任务实例是投递事件后由 WorkflowEventLooper 线程异步执行的;排查时只看消费线程,会漏掉后半段。
现场日志显示 command 长期未消费,Master 日志里出现 StackOverflowError(栈溢出错误),栈顶停在 DagHelper.getFlowNodeListPost(DagHelper.java)。
这足以说明 API 成功只代表 command 入库,不代表调度已经开始。
根因收敛: 对照 3.2.2 源码看,command 的消费由 MasterSchedulerBootstrap 完成(线程名就是 MasterCommandLoopThread),它的运行循环只 catch 了 Exception,没覆盖 Java 的 Error。DAG 依赖遍历走的是 DagHelper.getFlowNodeListPost(),递归实现,去重标记写在递归返回之后:同一条路径上,同一个节点可以被重复进入;图一复杂,递归就可能反复在同一批节点里打转,触发栈溢出。栈溢出是 Error,消费线程被它打断后直接退出,Master 进程却没事,于是形成“进程还活着、调度已经停摆”的静默问题。
不过这只是表层。更深一层的原因是历史脏任务和异常工作流状态一直残留:那些长期异常、反复 failover、状态对不上的老实例,在 Master 重启或恢复执行时会重新进 DAG 遍历,带着被污染的上下文,更容易触发异常。复杂 DAG 只是触发条件,真正把 Master 状态机拖坏的是历史脏数据。
这段递归只在“指定起始节点 + 依赖类型为 TASK_POST”这类启动方式下才走到(失败恢复走的是同一条路径),常规启动整个工作流并不进这段递归;现场那批 command 走的是哪条入口,我们没有留下确凿记录。所以本文不把它当成已经坐实的单一产品缺陷:入口条件明确、机制成立,但这一层是源码分析,最小复现尚未完成。
应急处理: 清理那些已经终态、但 task instance 还停在中间态的脏数据,把异常实例的状态修一致,再重启 Master。之后 MasterCommandLoopThread 没再崩过。
治理启示: API 成功、command 入库,都只说明请求被记下了,调度并没有真的跑起来。进程监控能看出“服务挂了”,看不出“服务还在、调度链路却不往前走”。
子案例 B:READY_STOP 卡死——复杂多分支节点禁止执行的状态闭环风险
一个 2300 多个任务的工作流,跑到 700 个左右就不往下游派了。点停止,进入准备停止(READY_STOP),却长时间收不了口,Master CPU 一路往上走。
误判: 一开始以为是资源不够(Master CPU 到 83%,Worker 线程池满),就去扩容、重启。结果扩容后新实例照样卡死,说明资源紧张更像是卡死的表现,而不是诱因。
证据链: 用 top -Hp 和 jstack 定位,热点落在一直做依赖判定的工作流执行线程上(线程名前缀是 WorkflowExecuteThread-,对应的类是 WorkflowExecuteRunnable)。再查 DAG,发现一个节点,同时接了好几个上游、又分出好几个下游,却被设成了禁止执行(flag=NO)。普通链式节点被跳影响有限;可这种多分支节点一旦被禁止执行,它在依赖判定和停止收口之间的状态就说不清了,后继派生和 READY_STOP 收口在复杂拓扑下都可能迟迟收不住。
根因收敛: 这一条我们只到现象层。查 3.2.2 的 DagHelper.allDependsForbiddenOrEnd() 可以确认:上游节点被禁止执行时会被当作已满足,parsePostNodes() 对禁止执行的节点还会继续向后传递跳过。也就是说,平台对“禁止执行”本身是有完成语义的,它不是缺陷。但在我们这种多分支汇聚、节点又被禁止执行的拓扑里,它和停止收口叠在一起时,整条链路确实收不了口。到底是拓扑的哪个特征触发的,还得拿最小用例复现才能定。
应急处理: 把那个禁止执行节点改成“空跑成功”(跑完立刻返回 success 的轻量任务),让状态链路恢复闭环;同类的 DAG 结构也一并过一遍。
治理启示: 在我们 3.2.2 的场景里,最稳的规则是:复杂多分支节点别直接设禁止执行;要废弃就改成空跑成功任务,或者重构依赖边绕开。对 READY_STOP(准备停止)、SERIAL_WAIT(串行等待)、长时间 running 这些中间态,既要告警,也要在自动停止后再确认一次是不是真进终态了。
3.3 Worker 稳定性:在线不代表可调度
子案例 A:三台 Worker 在线,任务却集中到一台
三台 Worker 的 Zookeeper 注册、Worker group 配置都正常,任务却大多挤在节点 C 上。
误判: 一开始怀疑 Worker group 配错了,查下来 group 的 addr_list 里三台节点都在。又怀疑 A、B 掉线或没注册,可 ZK 里三个 Worker 节点都在,心跳也正常。
证据链: 从心跳数据看,三台节点都出现过 serverStatus=BUSY。奇怪的是,线程池接近 0 的节点 A 也是 BUSY?再查系统资源才发现,节点上混部了 master-server、worker-server、zookeeper,有的还跑着 API 或 Alert。有台节点常驻内存接近 20GB(worker 约 8G、master 约 7G、api 约 5G),系统可用内存只剩 6GB 左右。Worker 的负载保护用的是系统级资源信号,所以别的进程占的内存也会让它被误判成 BUSY;再加上当时集群把 Master 的刷新间隔配成了 5 分钟一次(节点从 BUSY 恢复后,最长要等 5 分钟才被重新放进候选集),候选 Worker 就会在短时间内缩到只剩一台。
根因收敛: 混部(Master/Worker/API/Alert/ZK)+ JVM 堆配得偏大 + Worker 负载保护阈值 + Master 刷新间隔太长,四个因素叠成了一个循环:节点被误判成 BUSY 排除掉,恢复后又要等最长 5 分钟才被 Master 重新纳入候选;这段空窗里候选集常常只剩一台,任务全挤到它身上,它很快也被压成 BUSY,循环重来。图5 里 A、B 被排除、任务全落到 C,就是空窗期的那个状态。
图5|Worker BUSY 误判与任务倾斜
应急处理: 把每台节点上 master-server 的 JVM 堆上限从约 6G 降到 4G(该进程常驻内存约 7G,堆只是其中一部分),腾出系统内存;再依次重启各节点的 master-server 和 worker-server,清掉累计的内存占用和异常状态;把 Master 的 worker-group-refresh-interval 从 5 分钟改回 10 秒(也就是产品默认值)。负载保护阈值我们没动:它按系统内存 70% 判定,调低会误伤真正过载的节点,所以先把系统内存腾出来才是对的。
治理启示: Worker 监控不能只看心跳和注册,还得看系统内存、线程池、serverStatus 和任务分配结果。混部不是绝对不行,但得先做组件内存预算,想清楚系统级负载指标会被哪些进程影响。
子案例 B:行数限制够了,Worker 仍然 OOM
另一类 Worker 稳定性问题,来自一个查宽表的大结果集 SQL 任务。它把查询结果整个装进 Worker JVM,再当邮件附件发出去,构建附件又会进一步放大内存。任务明明限制了最多返回 50000 行,还是 OOM。
证据链: 一开始我们认为 50000 行的限制已经足够,但深入分析发现,查询对象是一张 500+ 字段的宽表,单行序列化后约 16KB。SQL 任务将查询结果全量加载到 JVM Heap,再据此构建 Excel 附件对象,这个“全量加载 + 对象膨胀”的链路在宽表场景下存在巨大的内存放大效应:
行数限制只控制了一个维度,无法限制字段数量、单行大小和对象膨胀。(图6 里的“对象膨胀约 4.5 倍”,是两级系数都取下限相乘的结果。)这个案例最终形成了一个可以复用的估算方法:
预计内存占用 ≈ 字段数 × 单行预估字节 × 返回行数 × 对象膨胀系数 × 同机并发任务数(膨胀系数是经验值,可用一次堆转储按实际对象大小校准;上面 3.6GB 与 8GB 是单任务口径,同机并发跑多个大对象任务时还要再乘并发数)
图6|宽表结果集的内存放大
根因收敛: 行数限制只是大对象保护的一个维度。字段一多,就算行数不多,单行的序列化体积加上 Java 对象膨胀,也能让内存失控。
应急处理: 给这个任务做字段裁剪,只留业务需要的约 20 个核心字段;邮件、导出类任务统一禁止 SELECT *;Worker JVM 参数里加上 -XX:+HeapDumpOnOutOfMemoryError,OOM 时自动导堆,方便事后定位。
治理启示: 在我们这里,资源治理的重点从“机器够不够”转向了“任务形态有没有明确的边界”。
治理体系:让治理经验进入日常运行
4.1 治理总览
治理对象我们分成五类:元数据、状态、依赖、Worker 资源、数据产出。每一类都得回答三个问题:解决什么问题、用什么机制、事后怎么验证。
治理对象
主要问题
机制 → 效果
DAG 元数据
重复依赖、幽灵依赖、版本不一致
关系表巡检、每日清理 → 重复依赖从 10000+ 降为次日归零
实例状态
command 不消费、中间态不收口
状态巡检、超时停止、值班告警 → 实例不派生从每周 2–3 次降为 0
依赖设计
禁止执行的多分支节点
建模规范、上线检查、变更审核 → 无新增 READY_STOP 卡死
Worker 资源
BUSY 误判、任务倾斜、宽表 OOM
混部治理、资源预算、结果集边界 → 邮件类任务 OOM 从每月多次降为 0
数据产出
调度成功但分区为空或波动异常
核心表 DQC、分区回溯 → 累计发现 3 次分区为空异常
表2|五类治理对象、主要问题与机制效果
4.2 元数据治理:把 DAG 健康当成生产质量
元数据巡检每天查 t_ds_process_task_relation、任务定义、工作流版本、坐标之间的关系。重复依赖、幽灵依赖、孤儿任务(存在但不可达)、版本不一致各出一份报告,不混成一个笼统的“DAG 异常”。清理和告警分开:清理脚本每天自动删重复记录,告警脚本发现新增重复就立刻通知值班,这样“清历史存量”和“抓新增异常”就不会搅在一起。
脚本同步到 Git 之后,改动就能从“谁记得昨天改了什么”变成“对比改动前后的 diff”。依赖关系的变化,靠每日关系表巡检兜底,重点抓重复依赖、幽灵依赖这类状态异常。两者配合,几千节点的大 DAG 排查“谁改了什么”时,脚本 diff 和巡检报告才能拼成一条完整证据链。
治理后效果: 第一次全量扫出 10000 多条重复依赖,靠关系表巡检和每日清理,之后按次日巡检口径归零。当天批量写入产生的少量重复,第二天会被清掉、不累积,所以当天有少量新增可以接受。
4.3 状态治理:从进程存活下钻到链路推进
状态巡检不再只问 Master、Worker 活着没有,而是沿着链路一层层查:command 有没有入库并被消费,工作流实例有没有生成,task instance 有没有持续派生,READY_STOP、SERIAL_WAIT 有没有超阈值,关键任务有没有在 SLA 前跑完。
现在的阈值是:command 入库 5 分钟还没有实例关联,告警;实例 running 但 15 分钟没有新的 task instance 派生,也告警;READY_STOP 超 10 分钟、长时间 running 超 12 小时,告警并进入自动停止流程;自动停止后再复查一次,确认实例真进终态,防止“停止操作本身也卡住”的套娃;异常告警通过 IM 渠道 5 秒内触达值班。阈值不是平台写死的常数,要按任务平均时长、业务窗口和重试策略去校准。
状态治理的核心是让告警指向“没有往前走”这件事。告警变多不是目标。进程监控只能回答服务还在不在,状态巡检才开始回答调度还在不在干活。
上线后,这些检查进了同一条发布链路:工作流变更先出任务定义和关系的 diff,再跑关系表健康检查,通过了才允许发调度配置。运行中的状态巡检负责发现发布后的问题,发布前检查负责挡住问题、别让它进生产。一个管“出了问题能不能尽快发现”,一个管“同类问题能不能少发生”。
治理后效果: 实例不派生(command 入库但不生成工作流实例)从治理前的每周 2–3 次降到 0;中间态卡死从每月多次降到偶发,而且每次都能在超过 READY_STOP 阈值后的 10 分钟内完成告警触达。
4.4 依赖设计治理:把问题变成上线约束
依赖设计规范纳入工作流发布前检查。我们从问题中抽象出以下强制规则:
· - 复杂多分支节点(同时接多个上游和下游)不建议直接设禁止执行——要废弃就改“空跑成功”,或者重构依赖边绕开;
· - Dependent 跨工作流依赖要配合理的等待策略——失败策略改成“等待”,至少等 5 分钟,别让下游在上游还在重试时就提前失败;
· - 工作流变更前后,查一遍重复依赖、孤儿节点和画布坐标;
· - 直接修库只当应急手段,必须备份、限定范围、留回滚方案——而且修完要在 UI 上重新保存工作流,让版本机制重新对齐所有元数据。
对历史上必须直接修库的场景,流程定死了:先备份,再限定工作流和记录范围,最后留回滚脚本和复核结果。修库不是常规运维,真正的治理目标是让同类问题以后不用再修库。
治理后效果: 规范落地后,没有再出现因为禁止执行导致的 READY_STOP 卡死;跨工作流依赖的无效告警,从每周十几次降到偶发。
4.5 Worker 资源治理:从行数限制走向大对象边界
Worker 侧加了三种信号:心跳里的 serverStatus、系统级 CPU 和内存、线程池和任务分配结果。三台 Worker 里超过两台持续 BUSY 就告警;多台同时 BUSY 时,先看是不是混部或系统资源压力,再看是不是调度配置的问题。
邮件、导出、附件任务统一做大对象评估,至少看字段数、单行大小、返回行数、对象膨胀和并发数。宽表、附件、Excel、邮件这几段在 Worker JVM 里是串起来的——查询结果先装进 Heap,再构建 Excel 对象,再序列化成 RPC 消息,任何一段内存膨胀都会传导到整个 JVM。超预算的任务,要么裁字段(一般不超过 20 个),要么拆结果,要么改成异步生成文件。这样能在上线前就把风险摁住,而不是等 Worker 没了,再对着残缺日志猜原因。
治理后效果: 邮件类任务 OOM 从每月多次降到 0;Worker 的 BUSY 从常态变成间歇,任务倾斜明显好转。
4.6 从调度可信到数据可信
调度成功,只说明 task instance 返回了成功状态,不代表 Hive 分区里真有数据。我们挑了 16 张核心 Hive 表,每天回溯近 3 天的分区,查行数、主键重复和波动异常,还真发现过“调度成功但分区是空的”。
治理后效果: DQC 上线至今累计发现过 3 次“调度成功但分区数据为空”的异常,都在次日 T+1 的巡检里被逮住,没让脏数据流进下游报表。这层 DQC 不属于 DolphinScheduler 的内部状态治理,但它补上了生产可信的最后一段:平台负责把任务往前推,数据质量检查负责确认产出真的能用。
可复用规范:让读者带走一张检查清单
5.1 单工作流 DAG 建模检查清单
· -
是否存在重复依赖(关系表膨胀、DAG 构建异常):扫描相同前后置 code。
· - 是否存在幽灵依赖(画布空白、实例不派生):校验每条边都能找到任务定义。
· - 是否存在孤儿节点(任务存在但不可达):明确根节点并检查可达性。
· - 复杂依赖下是否存在多分支节点设为禁止执行(状态链路可能无法收口):改为空跑成功或重构依赖。
· - 跨工作流是否有等待策略(上游重试时下游提前失败):配置等待时间和超时动作。
· - 邮件/导出任务是否为宽表(Worker OOM):限字段、限字节,必要时流式处理。
· - 任务脚本是否可追溯(脚本改动无法 diff):发布前确认 Git 版本和审核记录。
和 2.2 的建模原则、4.4 的强制规则不同,这张表只列上线前要动手做的事,每条都能落到一次扫描或一次校验上。它的意义是把那些容易被当成“设计偏好”的问题,变成可执行的上线门槛。尤其是禁止执行、宽表任务这几样,单元测试很难覆盖它们在大 DAG 生产状态里的组合效应。
5.2 巡检信号要从浅到深
完整的巡检链路(我们内部也叫“健康检查”)可以表示为:
图7|健康检查分层模型
每一层都不能被上一层替代:
· - 服务是否存活:进程级监控,最基础的一层,但只能证明进程在,证明不了 command 被消费、实例在推进。
· - command 是否被消费:比服务存活深一层——Master 进程在,但 MasterCommandLoopThread 可能已经静默崩溃。这一层有个现成的探针:ds.master.uncached.exception,它统计的就是未捕获异常(包括 Error)的次数,正好能照出关键线程的静默死亡。
· - 工作流实例是否推进:实例生成了,但可能卡在某个中间态,不再派生 task instance。
· - task instance 是否派生:实例显示 running,但可能因为 DAG 构建失败,一个 task instance 都没建。
· - DAG 元数据是否健康:从运行态深入到数据态——关系表、任务定义、版本、坐标是否一致。
· - Worker 是否可调度:心跳正常只说明注册还在,BUSY 状态会把它排除出候选。
· - 核心数据是否可信:调度成功只覆盖到任务状态,分区里未必有数据。
把这几层落到日常动作上,阈值和处置按当前口径固定如下(具体取值仍要按任务平均时长、业务窗口和重试策略校准):
信号
阈值
动作
command 入库但没关联实例
超过 5 分钟
查消费线程状态,看 ds.master.uncached.exception
实例 running 但没派生 task instance
超过 15 分钟
查 DAG 构建日志和实例状态
READY_STOP / SERIAL_WAIT
超过 10 分钟
进入自动停止,停止后复查是否真进终态
长时间 running
超过 12 小时
同上,并对照任务平均时长复核阈值
Worker serverStatus
超过两台持续 BUSY
先查混部与系统内存,再查调度配置
核心表分区
每日回溯近 3 天
查行数、主键重复、波动异常
表3|巡检信号、阈值与处置动作
这六条信号都通过 IM 值班渠道通知,触达口径与 4.3 一致。
核心观点:调度平台不能只监控“失败”,还要监控“没有向前推进”。
5.3 从问题到规则的固定转化
我们最终采用固定模板复盘生产问题:
现象 → 误判 → 证据链 → 根因收敛 → 应急处理 → 巡检指标 → 设计规范。
前五步对应案例里“现象 / 误判 / 证据链 / 根因收敛 / 应急处理”的走法;后两步是新的产出:把案例末尾的“治理启示”固化成了写进告警的巡检指标、写进规范的约束。
图8|稳定性治理闭环
四组映射可以直接复用:
现场现象
证据链
长期规则
画布空白
关系表存在幽灵依赖或坐标不一致
元数据每日巡检、删除任务同步清理边
command 不消费
command 积压、Master 关键线程异常
消费延迟告警、关键线程健康检查
READY_STOP 卡死
多分支节点与禁止执行叠加后的收口现象
复杂依赖下多分支节点避免直接设为禁止执行
Worker OOM
宽表结果集和附件对象膨胀
大对象估算、禁止 SELECT *
表4|现场现象、证据链与长期规则的映射
如果一次问题只留下“把这个实例修好”的操作记录,那团队得到的只是一次性恢复;如果它还留下一个巡检指标、或一条建模约束,那团队才真正攒下了平台能力。
我们还给自己定了一条表述纪律:讲“治理有效”,不能只说加了脚本、加了告警,得说清它拦住了哪类问题、多久查一次、异常谁处理、在多长的观察窗口里没再犯。机制和观测口径放在一起,治理结果才有复用价值。
后续演进与总结
调度治理回答的是“数据能不能按时、可信地产出”,可数据最终是要交到业务手里的。做完这一轮我们才发现,这条路上还断着两截,于是把精力投到两件后续的事上。
6.1 BI 平台服务化重构:把用数入口和故障域一起收口
以前用数的入口是散的。分析师用 SQL 客户端直连库,开发在调度页面上建任务,导出靠零散脚本,谁也说不清一张表归谁管、能不能用。后端又是单体结构,一条慢查询就可能打满连接池,把登录和看板一起拖下水。
于是我们把数据中台拆成 Portal/BFF、自助提数、数据开发、数据下载、数据地图这些独立服务,让慢查询、导出这类重活各自待在自己的进程里:一个模块出问题只影响自己的功能,不再拖垮整个平台。同时把“找表 → 写 SQL → 跑任务 → 导出 → 看板”收进同一个入口,用统一的元数据和权限边界回答“这张表是什么、归谁管、谁能用”。
图9|BI 平台服务化拆分
6.2 数仓 Serverless 化:把固定资源换成按需资源
数仓原来跑在 EMR 集群上(Hive on Tez),计算上限在集群建好时就定了:凌晨跑批排队、白天算力闲置,扩容得走“申请机器 → 审批 → 入集群 → 重启服务”,以天计。费用却一直按资源总量算,不是按实际用量算。
我们把计算换成 Serverless 引擎、存储换成对象存储,复用原来的 HMS 元数据,数据零搬迁;再用双跑校验兜住一致性,按 APP → DWS → DWD 逐层割接,从最靠消费端的层往上切,先切上层做端到端验证,风险最小。资源从此按需取用,成本也从“为峰值常驻付费”变成“为实际消耗付费”。迁移的量化收益我们还在收数据,这里只讲做法,不下结论。
图10|数仓 Serverless 存算分离
这两件事和本文的调度治理在同一条线上:调度让数据按时产出来,平台让人方便地用上,Serverless 决定这份产出要花多少资源。只把任务跑完,链路还不算真正可信。
6.3 总结
从 Azkaban 迁到 DolphinScheduler,我们一开始关心的是任务能不能迁过来、依赖能不能建起来、工作流能不能跑起来。真进了生产才发现,大 DAG 真正的难处是长期、正确地往前推进,跟“能不能跑起来”是两回事。
先说单工作流。它的价值是让核心链路的任务级依赖看得见;核心强依赖链路可以收敛,临时任务和弱依赖则必须留在外面。
元数据要当生产数据来治理。关系表、任务定义、版本号、坐标字段一起构成调度契约,任何一处对不上,都会影响整条链路的加载和派生。
监控要从进程存活下钻到状态推进。command 消费、实例生成、任务派生、中间态收口,这几层我们都遇到过停住的情况,而每一次进程监控都没有报警。
依赖设计上的错,会被放大成状态机问题。在复杂依赖下,多分支节点禁止执行会拖住收口,跨工作流等待策略不合理会让下游提前失败。
最后是巡检、告警、Git 版本化、资源预算和 DQC,它们都是调度平台的常规部分,不是出事之后才打的补丁。
「迁移只是换了平台,可信度是靠这些日常动作一点点攒出来的。」
往期推荐
企业大规模 AI Coding 落地如何控制大模型成本
基于MCP三步为Agent接入真实企业数据!
本体多Agent实践:AI如何参与企业复杂业务决策?
基于Harness+Skill+Data Agent的Agent Growth实践!
阿里集团2000+数字员工上岗,从试点到规模化的血泪经验
Palantir多Agent实践:AI如何参与企业复杂业务决策?
Semantic Layer不够了,Google开始给Agent补“业务关系”
理想汽车的判断:生产级 Agent 的瓶颈不在模型,在上下文
开源Palantir来了?Utopia开始给企业造世界模型
知鸟 AI 日臻金融人才之道:AI 原生技术体系如何赋能金融企业高质量发展
点个在看你最好看
SPRING HAS ARRIVED

