摘要重启 DolphinScheduler 后 200 补数任务把刚恢复的 ClickHouse 再次打挂串行等待的队伍随之全锁死。而点那下「停止」才是致命一击队首本已是「失败」这个终态被推回「准备停止」这个非终态唤醒下一个的事件从此永不触发重启也没用。UI 无解就下到元数据库清掉两张实例表里 state4/14 的僵尸记录都带 process_definition_code 限定再重新补数。⚠️ 别顺手改成「串行抛弃」——补数这类任务丢一个实例就是丢一段数据。一次 ClickHouse 宕机引发的「血案」200 补数任务把刚恢复的 CK 又打挂串行等待的队伍随之全部锁死。我点了「停止」它从「失败」变成「准备停止」然后就再没动过。本文记录完整排查过程、对照源码讲清这下「停止」为什么反而是致命一击以及 UI 无解时怎么下到元数据库救场。一、事故背景1.1 事故经过时间线2026-02-03ClickHouse 挂了DolphinScheduler 任务随之停止2026-02-04发现问题重启 Dolphin瞬间产生200 补数任务结果任务洪峰直接把刚恢复的 ClickHouse 再次打挂重启 CK 后发现任务全部阻塞界面一片红现象最底部的任务状态 失败点击停止后变成「准备停止」卡住不动后续所有任务状态 串行等待整个工作流死锁彻底动不了机器配置4C 32G资源不是问题问题出在调度策略上。1.2 原因分析罪魁祸首任务执行策略配置了「串行等待」串行等待的坑下面四条对着 DolphinScheduler 3.2.0 源码走一遍新实例进来先被置成SERIAL_WAITstate14。它要能自己转成运行中前提是「比自己 id 小、且状态还落在运行态集合里」的实例一个都查不到。这个集合是{0, 1, 14, 17}——提交成功、正在运行、串行等待、等待运行队伍往前挪只有一个入口实例结束时触发endProcess()由它调checkSerialProcess()给 id 最小的那个排队实例投一条RECOVER_SERIAL_WAIT命令关键就卡在这——endProcess()只在状态事件带着终态进来时才会被调用。而「准备停止」state4不是终态带着它进来的事件走的是另一条分支去killAllTasks()杀任务压根不碰唤醒逻辑于是后面那一串state14的实例没有任何人来唤醒。唤醒是事件驱动的系统里没有定时任务去重扫这条队列→死锁形成。第 1 条那个集合值得多看一眼14 串行等待自己也在里面。所以队伍是一环扣一环的——实例 3 看见实例 2 挂在 14 上就继续等实例 2 又在等实例 1。队首一旦不动整条链条全锁死。⚠️ 更扎心的是点「停止」这个动作本身可能就是压垮队伍的那一下。回看 §1.1 的现象——队首原本的状态是失败我点了停止它才变成「准备停止」。对照上面第 3 条失败6是终态准备停止4不是。也就是说那一点把队首从终态推回了非终态从「本来还有机会触发唤醒」变成「彻底触发不了」。之后它为什么就停在那儿不动了源码里READY_STOP的实例会去killAllTasks()杀任务只有任务真的杀干净、实例转成「停止」5终态之后才轮得到endProcess()。而当时 ClickHouse 还挂着任务卡在那儿杀不干净这一步是推断当时没留现场日志实例就永远停在 4。一个我至今没查实的疑点既然「失败」本身就是终态那在我点停止之前唤醒就该已经发生过、队伍也该往前挪了才对。可当时后面确实全挂在「串行等待」上。可能是我看到界面时队首其实还在跑、「失败」是之后才写进去的也可能是唤醒发过但下一个接着失败、我正好撞见级联中间那一帧。现场日志没留这条只能存疑。但不管起点是哪种点那下停止之后队伍彻底没救了——这一段是确定的。实例3实例2实例1工作流串行等待实例3实例2实例1工作流串行等待人工点「停止」→ 准备停止state4非终态转不到「停止」5→ endProcess() 永远不触发收不到 RECOVER_SERIAL_WAIT又无定时重扫 → 死锁调度运行运行中占住队首到达调度时间串行等待排队到达调度时间串行等待排队执行失败state6终态下游挂着任务杀不干净尝试通过「补数运行」重新执行任务直接报错「已经有一个进行中的任务了」大意如此现场没保留重启 Dolphin没用。再点一次停止它已经卡在「准备停止」了这个按钮此刻只会让它更死。等它自己恢复别做梦了。第一条最反直觉——重启这么重的动作居然不解决问题。因为 Master 起来后并不会去重扫SERIAL_WAIT队列它只等事件而那个事件的产地正是永远不会结束的队首。队首卡一天队伍就排一天。二、解决方案直接改库既然 UI 层面无能为力那就釜底抽薪——直接操作 MySQL 元数据库。这不是我第一次靠改元数据库救活调度了另一次是数据库迁移后 AUTO_INCREMENT 回退导致工作流触发即死同样是 UI 上完全看不出名堂、只能下到表里看。2.1 定位关键表打开 DolphinScheduler 的元数据库共 65 张表本文环境为3.2.0表数量和状态码都随版本变照搬前先对一下自己的版本。根据命名规律t_ds_process_*开头的表与任务执行相关核心表表名用途t_ds_process_instance工作流实例每次运行生成一条记录t_ds_task_instance任务实例工作流中每个节点的执行记录t_ds_process_definition工作流定义2.2 查看异常数据查询t_ds_process_instance表找到那些阻塞的任务实例SELECTid,name,state,start_time,end_timeFROMt_ds_process_instanceWHEREstateNOTIN(7)-- 7 执行成功ORDERBYidDESCLIMIT50;状态码对照3.2.0 的WorkflowExecutionStatus枚举想自查就搜这个类名state含义是否终态0提交成功否1正在运行否2准备暂停否3暂停是4准备停止否← 本次卡死的就是它5停止是6失败是7成功是12延时执行否14串行等待否← 排队中的全是它15 / 16准备阻断 / 阻断否 / 是17等待运行否「是否终态」这一列是本文的关键——§1.2 说过只有实例走到终态才会触发endProcess()去唤醒队伍。4 和 14 都不是终态所以它们凑在一起就是一潭死水。果然一堆state 14串行等待和state 4准备停止的记录知道该盯哪两个状态码之后用这条确认一下规模和积压时长SELECTstate,COUNT(*)AS条数,MIN(start_time)AS最早一条FROMt_ds_process_instanceWHEREstateIN(4,14)GROUPBYstate;2.3 清理阻塞数据方案一直接删除简单粗暴⚠️顺序是「先子表、后主表」两条的过滤范围必须完全一致——理由见本节末的补注别调换。-- ① 先删任务实例子表此时主表记录还在子查询才捞得到DELETEFROMt_ds_task_instanceWHEREprocess_instance_idIN(SELECTidFROMt_ds_process_instanceWHEREstateIN(4,14)-- 准备停止、串行等待ANDprocess_definition_code你的工作流code);-- ② 再删工作流实例主表范围与①逐字一致DELETEFROMt_ds_process_instanceWHEREstateIN(4,14)ANDprocess_definition_code你的工作流code;方案二修改状态相对温和-- 把阻塞实例改成「失败」这个终态让它不再占着队伍UPDATEt_ds_process_instanceSETstate6-- 6 失败WHEREstateIN(4,14)ANDprocess_definition_code你的工作流code;-- ← 千万别省注意操作前务必备份数据生产环境别直接 DELETE先 SELECT 确认。⚠️ 那行process_definition_code不是可选项。省掉它这条 UPDATE 会把整个库里所有工作流的「准备停止 / 串行等待」记录一起刷成失败——包括那些此刻正常排队、马上就轮到自己的实例。一条 SQL 从救一个工作流变成祸害全平台。 还要认清它到底做了什么改库不会让队伍自己重新跑起来。唤醒靠的是 Master 进程里那个endProcess()事件见 §1.2直接 UPDATE 数据库绕过了整条事件链。这一步的作用是把占位的僵尸记录清出去让下一步的「重新补数」能建得起新实例——真正让任务跑起来的是 §2.4不是这条 SQL。⚠️补注2026-08-24 发现2026-09-12 已就地订正方案一这两条 DELETE本文最早发布的版本顺序是反的。早期版本先删主表t_ds_process_instance再用子查询SELECT id FROM t_ds_process_instance WHERE state IN (4,14)去删子表。可主表记录已经没了这个子查询再也查不到那些 id——于是t_ds_task_instance里对应的任务实例一条都删不掉全变成孤儿留在库里。当时两条的过滤范围也不一致第一条带process_definition_code第二条没有。上面的 SQL 已按「先子表、后主表 范围逐字一致」改好照抄即可。如果你抄的是更早的版本、已经按反序执行过了用这条把孤儿记录捞出来再清SELECT*FROMt_ds_task_instance tiWHERENOTEXISTS(SELECT1FROMt_ds_process_instance piWHEREpi.idti.process_instance_id);方案二改 state没有孤儿这个问题——只更新不删除主表记录还在子表自然不会变孤儿。生产环境本来也更推荐方案二。但范围限定那条它一样躲不掉别省process_definition_code。2.4 重新补数清理完数据后回到 DolphinScheduler 界面工作流定义 → 点击运行 → 选择「补数」这次终于不报错了任务顺利执行三、预防措施吃一堑长一智总结几点避坑经验3.1 执行策略怎么选别顺手换成「串行抛弃」策略实际行为适用场景并行多个实例同时运行无状态任务、幂等任务串行等待排队前一个结束才轮到下一个有严格顺序依赖的任务串行抛弃已有实例在跑时把新来的这个置为「停止」丢掉老的照常跑完防止任务堆积串行优先把正在跑的都置为「准备停止」让最新的实例上只关心最新数据⚠️查官方中文文档的话「串行抛弃」那条会把你劝退。它写的是「抛弃后生成的工作流实例并杀掉正在跑的实例」——又抛弃新的又杀掉老的读着像两个都不留。源码里不是这样。3.2.0 的串行分支逻辑很直白发现有实例在跑就把新来的这个置成STOP描述写的就是stop by serial_discard strategy然后直接 return正在跑的那个一根汗毛都没动。官方文档那半句是表述问题不是行为描述。顺带提醒真正会「停掉正在跑的」的是串行优先——它把在跑的实例全置成READY_STOPstate4。眼熟吗就是本文卡死的那个状态。所以串行优先反而更容易踩进同一个坑别拿它当避坑方案。那到底该换成哪个先问一句这个工作流丢一次调度数据会不会缺你的任务选哪个为什么每次跑的是全量 / 最新快照丢一轮下一轮自动补回来串行抛弃丢掉的是新实例队伍永远不会堆也就没有「队首卡死拖垮全队」这回事每个调度时间点都必须跑一遍增量同步、补数、按天分区落库仍然用串行等待换成抛弃 那个时间窗的数据永远不会被同步除非你事后人工补数⚠️本文这个工作流正好是第二类——它是往 ClickHouse 增量同步的补数任务丢一个实例就是丢一段数据。所以对它而言「串行等待」并没有选错错的是没给这个策略配任何兜底。真正该补的是下面两条§3.2 的超时失败给队伍一个自动解锁开关和 §3.3 的队列积压告警队伍不动了要有人知道。别看完这一节就顺手把所有工作流都改成串行抛弃——上面表格第二行那类改完是会丢数据的而且丢得悄无声息。另外改策略不会追溯清理历史积压——存量那些state14还得按 §2.3 手工清一次改策略只保证以后不再堆。3.2 配置任务超时在工作流定义的节点配置里打开「超时告警」并勾上超时失败只勾告警的话任务照样无限期挂着。这一步在本文场景里格外值钱超时失败会把实例推进终态而终态才会触发 §1.2 那个endProcess()唤醒下一个排队实例。换句话说超时时间不只是「别占资源」——它是串行队伍的兜底解锁开关。值填多少串行等待的工作流有个额外约束超时时间必须小于调度间隔否则队伍排干的速度赶不上新实例进来的速度照样越堆越多。所以取值先看两头——下限正常跑完的耗时留足余量别让正常波动也被判超时上限调度间隔小时级任务就别配 90 分钟的超时两头对不上说明这个任务本身就跑得太久了该先去优化任务而不是靠调超时硬扛。3.3 监控告警监控下游服务如 ClickHouse的健康状态任务失败及时告警——这次是隔天才发现 CK 挂了一整晚的任务白跑另外盯住串行队伍本身光有「任务失败」告警不够队伍卡死时可能一条失败都不再产生后面全挂在「串行等待」上根本轮不到它们失败第三条有个现成的信号可以直接抄就是最老那个非终态实例已经挂了多久SELECTprocess_definition_code,COUNT(*)AS排队条数,TIMESTAMPDIFF(MINUTE,MIN(start_time),NOW())AS最老一条已等分钟数FROMt_ds_process_instanceWHEREstateIN(4,14)GROUPBYprocess_definition_codeHAVING最老一条已等分钟数60;有结果就说明有队伍卡住了。这条比「任务失败数」更贴近本文这类故障——任务失败是正常的失败之后队伍不动才是事故。3.4 下游服务容灾这次事故的根源是 ClickHouse 宕机。考虑CK 集群部署避免单点故障任务侧增加重试机制和熔断逻辑限制并发防止重启后任务洪峰最后一条是这次事故的起点——200 补数任务同时压上去把刚喘过气的 CK 又按了回去至于压垮队伍的那一下见 §1.2。怎么给下游 CK 做并发管控和多账号资源隔离我另写过一篇1800 行 INSERT 跑了 16 分钟ClickHouse 并发洪流踩坑 多账号资源隔离方案。四、总结问题本质串行等待的队伍只靠「前一个走到终态」这一个事件往前挪。队首一旦停在「准备停止」这种非终态上就没有任何机制会再来推它一把——不是锁没释放是根本没人来解锁。讽刺的是把它推到非终态的正是我自己点的那下「停止」。解决思路UI 搞不定就下到元数据库把t_ds_process_instance和t_ds_task_instance里state4/14的僵尸记录清掉两张表都要且都带上process_definition_code限定再回 UI 重新补数。核心教训「串行等待」是把双刃剑——它假设队首总会走到终态而这个假设会破。但补数、增量同步这类任务不能改成「串行抛弃」那是拿丢数据换不堵车别对已经失败的实例点「停止」——那是把它从终态推回非终态等于亲手把队伍锁死给任务配「超时失败」等于给串行队伍装了个兜底解锁开关调度系统的元数据库是最后的救命稻草但改库只清占位、不触发调度事件清完记得手动重跑告警要盯「队伍不动了」不能只盯「任务失败了」——这次前者才是事故希望你永远用不上这篇文章的解决方案。但如果用上了记得先备份再动手。如果这篇文章帮你解决了问题欢迎点赞收藏。有更优雅的方案欢迎评论区交流。延伸阅读DolphinScheduler 数据库迁移后工作流触发即死AUTO_INCREMENT 回退引发 selectOne 翻车全程追凶实录 —— UI 搞不定时同样靠直接操作元数据库救活调度DolphinScheduler 任务实例 Host 为空一次内存保护机制引发的调度罢工 —— 另一次凌晨调度罢工从现象到根因完整复盘1800 行 INSERT 跑了 16 分钟ClickHouse 并发洪流踩坑 多账号资源隔离方案 —— 本文正是补数洪峰把 CK 打挂这篇讲怎么给下游 CK 做资源隔离️ 标签DolphinScheduler任务阻塞死锁串行等待策略ClickHouse补数任务调度容灾最后更新2026-09-12对照 DolphinScheduler 3.2.0 源码重写了死锁机制。如果你读过更早的版本这三条务必看一眼§2.3 方案二的UPDATE少了process_definition_code限定照抄会把整个库里所有工作流的 4/14 记录一起刷成失败包括正常排队中的。已补上。§2.3 方案一两条DELETE的顺序反了先主表后子表照抄会让t_ds_task_instance的记录全变孤儿。已改成「先子表、后主表」原处补注保留了孤儿捞回 SQL。§3.1 原来建议「用串行抛弃代替串行等待」对补数 / 增量同步这类任务是错的——抛弃掉的实例意味着那个时间窗的数据永远不会被同步。已改成按「丢一次调度会不会缺数据」分流。结论层面最大的变化「失败任务不释放锁」这个说法被换掉了它是比喻不是机制。真实链条是队伍只靠「前一个走到终态」这一个事件往前挪而我点的那下「停止」恰好把队首从终态推回了非终态。标题也随之改成现在这个。其余为补版本标注、状态码表加「是否终态」列、补监控 SQL 与内链、摘要与封面同步重渲。 2026-09-11正文顶部补引自制封面§1.2 新增「串行等待死锁」时序图mermaid 源码在正文。 2026-08-24首次发现 §2.3 方案一的DELETE顺序问题当时以追加补注的形式给出正确顺序正文 SQL 已于 2026-09-12 就地改正。
