调度系统跑得好好的突然有一天 MySQL 那边炸了——准确说是跑在 DolphinScheduler 上的一批写库任务开始在凌晨扎堆报错。我们那个环境里有将近三十条定时工作流大部分是数据同步、报表计算和对账逻辑底层都指向同一个 MySQL 实例。凌晨业务低谷期原本是这帮任务的黄金窗口结果那天零点刚过任务列表里齐刷刷出现红色失败标记报错内容五花八门看下来基本都围绕着锁等待超时、连接获取失败、事务回滚这三类。当时第一反应是 MySQL 挂了登录上去一查实例负载不高QPS 也不离谱但 information_schema 里一大堆线程卡在Waiting for table metadata lock和Lock wait timeout exceeded上连接数已经堆到了几千整个实例接近不可用状态。这篇文章不会绕弯子直接把我处理这个问题的全过程、踩坑细节和最终解决方案写出来重点落在两块一是怎么把 DolphinScheduler 任务在 MySQL 事务处理里的异常问题定位清楚并根治二是这里的“代码热替换”到底怎么落地才能做到不中断调度、不重启集群就能把修复后的脚本和任务逻辑发布出去。这两件事在实际运维里经常碰到一起因为线上出了问题之后没有几个人能接受停机维护大家要的都是最小影响面的快速修复。1.1 现场症状调度任务大面积失败先还原一下现场。我们 DolphinScheduler 集群由一个 Master、三个 Worker 组成任务类型用得最多的是 Shell 和 SQL。某个业务系统的订单表每天夜里要做一次状态归集流程大概是先跑一个 Spark 同步任务把当天数据落到临时表接着几个 SQL 任务做更新和汇总最后 Shell 任务把汇总结果推送出去。这套流程在此之前跑了半年都稳某天开始凌晨批量阶段频繁出现两类报错。第一类是 SQL 任务报Lock wait timeout exceeded而且集中在几个更新量最大的节点上。单独重跑这几条任务能成功但只要所有工作流一起压上来失败概率就非常高。第二类更头疼任务本身没报 SQL 错误而是报连接获取失败提示Data source rejected establishment of connection。当时用的调度数据源是 MySQL连接池用的是 HikariCP报这个错说明池子里已经拿不到可用连接了。任务报错之后又触发重试重试再次失败失败消息又把告警通道刷爆整个过程滚雪球一样最终把 MySQL 的线程数顶到了最大值。这里有个很典型的误区单看某个任务它本身并没有问题单独执行速度也正常但放到整个调度体系里多个任务并发地操作同一批表事务边界又没控制好问题就全暴露出来了。当时团队里有人提议把失败任务的重试次数从 3 次调到 10 次这完全是反方向使劲重试只会让锁竞争和连接堆积愈演愈烈。1.2 根因排查事务、锁与连接池三座大山问题迟迟没解决我花了大半天时间把日志、监控和数据库状态串起来最终把根因收敛在三层。第一层是事务过大。查看慢查询日志发现有一个核心的更新 SQL 每次要在一个大表上做全表扫描式的条件更新执行时间超过三分钟。这个 SQL 包裹在任务里事务从第一条更新语句开始就一直没有提交直到整个任务跑完才统一提交。MySQL 的 InnoDB 引擎在默认的REPEATABLE READ隔离级别下长时间未提交的事务会持有大量行锁还会因为 range 扫描形成间隙锁后续所有涉及相邻范围的操作全都要排队。大事务还带来一个副作用执行时间太长回滚段膨胀undo log 占用越来越多磁盘 IO 上涨明显。第二层是锁冲突叠加。DolphinScheduler 本身是并行调度多个工作流在同一个时间窗口内同时运行如果它们操作的表存在交叉前一个事务没提交后一个事务的更新就会卡在锁等待上。深挖之后发现有好几个任务都在更新同一张汇总表而且更新顺序不一样行锁获取顺序不一致就容易产生死锁。MySQL 检测到死锁后会回滚其中一个事务被回滚的任务在 DolphinScheduler 里直接标记失败触发重试后再次加入竞争形成恶性循环。第三层是连接池被打满。我们的业务数据源连接池最大连接数配置是 50按理说够用。但问题在于HikariCP 默认把连接池的maximumPoolSize设成了 10而且connectionTimeout是 30 秒。高并发下任务里的每个 SQL 都要从池子里取连接如果前面的事务迟迟不释放后面所有任务都只能排队等待连接表面看就是连接被拒绝。更麻烦的是我们有些 Shell 任务里自己写了多个数据库操作每个操作都单独从池子里拿连接但中间没有显式提交导致连接一直被事务占用。到这一步我已经明确了要做的三件事把大事务拆小、把隔离级别降下来、把连接池参数调合理。单纯哪一项都不够必须一起改。2. 整体设计把“写库”改造成可修复的形态设计改造方案时我给自己定了一个原则不改业务语义只用最小改动消除事务风暴。换句话说任务最终写入的数据内容不能变变的只是我们操作 MySQL 的方式。实操下来最有效的是下面三招。2.1 大事务拆小削峰填谷先拿最重的那个更新任务开刀。原来的 SQL 大概是这样的写法UPDATE order_status_summary SET status FINISHED WHERE create_date DATE_SUB(CURDATE(), INTERVAL 30 DAY);这个表上亿行create_date虽然有索引但符合条件的记录量太大InnoDB 会锁住所有命中的索引记录和间隙整个更新过程横跨几分钟中间其他任务全部受阻。我的做法是把他改成循环分批提交每次只处理一万条记录UPDATE order_status_summary SET status FINISHED WHERE create_date DATE_SUB(CURDATE(), INTERVAL 30 DAY) AND id ? ORDER BY id LIMIT 10000;配合一段脚本循环执行直到ROW_COUNT()返回 0。这样每个事务的持锁时间从几分钟降到了秒级其他任务的锁等待几乎消失。这个改动的核心逻辑是“以主键为游标切分数据范围”。用id 上一批最大 id做游标能稳定地推进扫描进度不会重复处理也不会跳过记录。每次LIMIT 10000一次提交失败时最多重跑这一万条修复成本极低。如果表没有主键也可以用唯一索引列做同样处理但前提是必须保证排序唯一否则会有重复或遗漏。2.2 事务隔离与会话参数调整事务拆小之后锁的持有时间大幅下降但隔离级别的问题依然在。REPEATABLE READ下一个事务需要两次读同一范围的数据才能保证结果一致InnoDB 为此会持有较多的共享锁和间隙锁。对于调度型批量任务这种严格的隔离保证其实没必要我们更在意的是写入成功率。所以我把数据源的连接参数做了调整在 JDBC URL 里显式指定jdbc:mysql://127.0.0.1:3306/biz_db?useSSLfalsetxIsolationREAD-COMMITTEDlockWaitTimeout20txIsolationREAD-COMMITTED把会话隔离级别降成了提交读间隙锁在大多数场景下不会再触发只锁真正更新的行。lockWaitTimeout20等价于 MySQL 的innodb_lock_wait_timeout让每条 SQL 等待锁的时间不超过 20 秒超过就直接失败而不是无限排队。这个值不能设太小否则高峰期正常的锁排队也会被误杀也不能太大否则连接堆积的时间窗口太长。我试过 5 秒、10 秒、20 秒最终在 20 秒下运行最稳。这里必须提醒一句隔离级别降级是否影响业务要看场景。如果一个任务在同一个事务里既要查快照又要做汇总且读到的数据必须前后一致那READ-COMMITTED可能出问题。我们的订单状态归集场景每次都只做独立的更新不依赖同一事务内的重复读所以这样做是安全的。改之前最好把任务清单过一遍确认没有事务内多语句读一致性依赖。2.3 重试机制与幂等控制DolphinScheduler 里的 SQL 任务和 Shell 任务都支持失败重试配置。我一开始把重试次数统统调成了 3 次重试间隔 60 秒这个做法本身没错但忽略了幂等性设计。比如某个任务向 MySQL 插入汇总数据如果没有唯一键约束失败重试会导致重复插入又比如状态更新类任务重试时如果条件没变化重复执行可能把已经推进的状态又覆盖回去。要给每个任务设计幂等逻辑。最常见的做法是把更新条件限定在“当前状态等于某值”或者带版本号UPDATE order_status_summary SET status FINISHED, version version 1 WHERE id ? AND status PROCESSING;这样即使同一个任务被重试多次最多只有一次能把PROCESSING改成FINISHED后面的重试因为WHERE条件不满足而影响 0 行不会造成数据错乱。对于插入类任务则在目标表上加唯一索引用INSERT ... ON DUPLICATE KEY UPDATE保证重复执行的结果一致。重试间隔也需要注意DolphinScheduler 支持配置“失败重试间隔”单位是分钟太短会让失败任务立即再次冲击数据库太长则让下游任务长时间处于等待状态。我们统一设成了 1 分钟这样既给了 MySQL 缓冲时间又不至于让整个批次延误太久。如果任务失败原因明确是事务锁等待重试 1 到 2 次就够了超过这个次数基本说明根因没解决。3. 核心实操DolphinScheduler 中的代码热替换事务问题处理完MySQL 的锁竞争和连接堆积很快平稳下来。但不光要把存量问题改掉还要让后续的修复能做到快速发布。这就引出了第二个核心话题在 DolphinScheduler 里怎么做代码热替换才能不打断线上调度。3.1 什么是我们需要理解的“热替换”DolphinScheduler 本身是一个分布式任务调度平台它的“代码”分好几层。第一层是工作流定义包含节点之间的关系和任务参数这层数据存在数据库里修改后保存即可生效不需要重启任何服务。第二层是任务脚本比如 Shell 任务里写的 Shell 脚本、Python 任务里的 Python 文件、SQL 任务里的 SQL 语句这些可以被统一托管在资源中心也可以直接写在任务定义里。第三层是跑在 Worker 节点上的依赖程序比如自定义的 jar 包、公共函数库、数据库驱动等。所以热替换的关键在于到底替换的是哪一层。如果只是改工作流定义或脚本内容那么 DophinScheduler 的调度器本来就是按任务定义生成新任务实例的新的调度周期自然就会用新逻辑这个“热”是天然的。但很多人踩坑的是改了资源中心里的脚本之后发现任务还是跑旧逻辑或者改了 Worker 上的 jar 包之后总是有个别 Worker 还在用旧版本。原因在于执行器在启动任务实例的时候会把资源文件拉取到本地的临时目录如果临时目录缓存了旧的副本脚本更新可能不会被感知。3.2 热替换的完整操作路径我以一次典型的“修改 Shell 脚本并立即生效”为例讲清楚完整操作路径。前提是脚本已经上传到了 DolphinScheduler 的资源中心并且任务节点通过资源引用的方式关联它而不是把一堆代码直接写在节点参数里。第一步在资源中心上传新版本的脚本注意文件名或版本号建议带上时间戳例如order_sync_v20240520.sh。第二步编辑对应的工作流节点把引用的资源从旧版本切换成新版本保存工作流。第三步如果需要立即验证对失败的任务实例点击“重跑”或者手动启动一次工作流如果当前工作流已经在运行中不要强行终止等它跑完下个周期就会加载新资源。这整个过程中DolphinScheduler 的 Master 和 Worker 都没有重启调度的定时触发也从来没有中断这就是最基础也最实用的“热替换”。它的前提是任务逻辑完全由脚本和 SQL 承载不依赖 Worker 节点上的 JVM 进程内部状态。凡是满足这个前提的任务都可以用这种线上编辑、保存、生效的方式发布。实际操作中有个细节必须注意任务实例启动时拉取资源文件到 Worker 临时目录如果你用资源中心上传同名文件覆盖可能有缓存不刷新的问题。保险的做法是每次更新都用新文件名并在节点参数里更新资源路径而不是覆盖原文件。虽然 DolphinScheduler 资源中心后续版本已经优化了缓存策略但线上环境还是尽量避坑。3.3 依赖 jar 更新的无损发布方式如果改动不止是脚本而是 Worker 节点上依赖的 jar例如自定义的 UDF 函数、JDBC 驱动或者某个公共工具包情况就没这么简单了。直接scp覆盖 jar 文件后Worker 进程不一定重新加载因为 JVM 默认不会重新从磁盘读取已经载入的类。原地重启单个 Worker 可以解决但会有短暂的调度中断如果 Worker 上有正在运行的长任务还可能造成任务失败。我用的无损方案是滚动替换利用 DolphinScheduler 自己的 Worker 状态管理来做。做法并不复杂准备一台新的 Worker 节点部署好新版本的 jar 和环境配置注册到同一个集群。在 DolphinScheduler 的 Worker 分组里暂时把旧的 Worker 节点状态设为“停止接受任务”等它把当前执行的任务跑完后自然空闲。确认新 Worker 已经正常接收任务并运行稳定后再对另一台旧 Worker 做同样操作。最后一台旧 Worker 退出后统一把旧节点的注册信息清理掉。整个过程对调度端是透明的定时任务不会中断长任务也能跑完用户侧几乎无感知。严格来说这算“蓝绿发布”或“滚动替换”但它满足了我们生产上对“热替换”的核心诉求不中断业务、不丢失任务、新代码快速生效。你要是不想加新机器也可以把旧 Worker 停掉、清空目录、更新 jar再重新启动让它重新跟 Master 注册只要保证同一 Worker 分组里有其他节点兜底影响也可控。3.4 热替换验证与回滚方案不管用哪种方式做热替换验证和回滚必须提前想好。我习惯把每次发布拆成三步验证。第一个层面是语法验证。脚本或 SQL 改动后先在测试环境跑一次确认执行计划正常没有低级拼写错误。DolphinScheduler 支持在资源中心里直接查看脚本内容但不会帮你检查语法所以这一步要人工或借助外部工具完成。第二个层面是灰度验证。在目标工作流里增加一个并行分支新逻辑跑一份数据到临时表旧逻辑跑原逻辑对比结果是否一致。这个做法对数据同步类任务特别有效两边都跑完后再核对行数和关键字段。如果一致再把旧分支摘掉。第三个层面是回滚预案。每次发布前把当前生效的工作流定义导出为 JSON 备份。DolphinScheduler 支持工作流导入导出旧版脚本也保留在资源中心不删除。万一新逻辑上线后发现问题只需要把工作流定义重新导入旧版本或者把节点的资源引用切回旧脚本再重跑失败任务即可。回滚速度往往决定了故障时长。我用这个方案几次出问题都在几分钟内切了回去没有一次需要重启集群。需要特别强调的是回滚也要走热替换的同一套路径不要在生产环境手工改数据库里的工作流定义表那属于危险操作一旦格式不对会造成解析失败反而扩大故障面。4. 故障排查手册与经验速查表写到这里把我在这个项目里遇到的高频问题整理成一个速查表。这些问题不是理论推演全是实际运维中踩出来的每条后面都附上排查思路和处置建议后面再遇到相似情况可以少走很多弯路。4.1 高频问题与处置对照现象可能根因优先检查项推荐处置SQL 任务报Lock wait timeout exceeded大事务持锁过久、锁范围重叠SHOW ENGINE INNODB STATUS\G看锁等待源拆分事务、降低隔离级别、错峰运行任务报连接获取失败连接池参数不合理、连接被长事务占用查看 HikariCP 活跃连接数、MySQL processlist调整maximumPoolSize、排查未提交事务任务实例始终跑旧脚本资源中心文件覆盖后缓存未刷新检查 Worker 临时目录资源副本改用带版本号的新文件名重新引用更新 jar 后部分任务仍旧逻辑Worker 未重启JVM 类未重新加载检查各 Worker 的进程启动时间和 jar 文件时间戳滚动替换 Worker 或重启对应节点任务偶发死锁回滚多任务加锁顺序不一致查看死锁日志中的事务 SQL 顺序统一更新顺序、优化索引、缩小事务范围调度任务排队不执行Worker 分组配置异常或资源不足检查 Master 日志、Worker 注册状态扩容 Worker、调整任务分发策略这里单独提一下SHOW ENGINE INNODB STATUS的用法。很多人不知道怎么从一大段输出里快速定位锁问题我一般直接看LATEST DETECTED DEADLOCK和TRANSACTIONS两个段落。死锁段落里会清晰地列出两个事务各持有哪把锁、等待哪把锁、执行的是哪条 SQL对照 SQL 内容基本就能判断出是哪两个任务在打架。如果死锁频繁而且总是同样的几条 SQL 参与那就不是运气问题是加锁顺序设计有缺陷。4.2 排错过程中的关键日志DolphinScheduler 的日志分散在多个位置遇到问题先搞清楚去哪里看能省大量时间。Master 节点负责工作流调度和任务分发它的日志在logs/master/下文件名包含日期和日志级别重点关注master-task-*.log和master-server-*.log。Worker 节点负责实际执行任务日志在logs/worker/下任务实例日志会按工作流实例 ID 存放在独立的目录里例如logs/worker/20240520/taskInstanceId-1234.log。常见的一个坑是任务报错信息很短比如只有一句Task execution failed但真正的堆栈在 Worker 日志里。DolphinScheduler 的任务实例日志和 Worker 进程日志不是完全同步的有些异常只打印在标准输出或进程日志里。排查时不要只看界面上的失败摘要一定要把对应时间点的 Worker 日志拉出来看完整堆栈。另外如果遇上连接池耗尽MySQL 的processlist是判断现场的第一手资料。执行下面这条 SQL能快速看到哪些连接占用了多长时间SELECT id, user, host, db, command, time, state, info FROM information_schema.processlist WHERE command ! Sleep ORDER BY time DESC;重点看time字段特别大的连接如果长时间处于Updating或Sending data状态基本可以确定是慢 SQL 或大事务拖住了连接。配合information_schema.innodb_trx查询未提交事务能够定位是哪条会话开启了事务一直没结束SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx ORDER BY trx_started;trx_started越早的事务越可能是连接泄露或未提交的元凶。定位到线程 ID 后可以通过KILL方式清理异常会话但这属于应急手段业务任务本身还会重试不能指望靠 kill 解决问题。4.3 从一次事故中沉淀的改进清单处理完这次事故后我把改动沉淀成了一份长期执行的检查清单现在每上线一个新的调度任务之前都要过一遍。这里把它分享出来。第一所有写 MySQL 的任务必须有明确的“单事务数据量”概念。预估单批次影响行数超过十万行就拆批不允许任务里出现一条 SQL 更新几十万甚至上百万记录的情况。第二每个任务都要写幂等条件。更新类任务用状态机或版本号插入类任务用唯一键不让重试产生副作用。第三数据库连接串统一通过参数指定txIsolationREAD-COMMITTED和lockWaitTimeout并确保所有环境一致。某些环境之间参数不一致导致同样任务一个环境稳定另一个环境频繁超时这种问题很难一眼看穿。第四凡是可进入资源中心的脚本一律走资源中心任务节点里不要内联大段代码。这样热替换才真正可操作否则每次改一段几百行的脚本都去重新编辑节点参数极易出错且不可审计。第五对线上任务做分级。核心链路任务失败时立刻告警非核心任务失败只记录不报警避免故障时告警风暴掩盖真正的根因。告警频次也要控制任务失败重试期间不要连续重复报警只在确定失败后报一次。第六DolphinScheduler 的工作流定义定期导出备份并把备份文件纳入版本管理。我们在实践里吃过一次亏某次正常编辑工作流时不小心误删了一个节点等发现问题时数据库里又没有备份后来从导出的旧 JSON 里恢复才避免了重新搭建整套流程。这套清单看起来平淡无奇但每一条都对应着一个实实在在的线上事故。很多时候调度系统的故障修复不是靠灵光一现而是靠这些反复验证过的基础动作。最后再分享一个小技巧对于 MySQL 侧的锁等待我习惯在 DolphinScheduler 的告警通知里带上SHOW ENGINE INNODB STATUS的简短输出这样任务一失败运维同学不用登录数据库就能先看到锁竞争的现场。具体做法是在 Shell 任务末尾加一段逻辑检测失败后执行诊断命令并把结果追加到任务日志再通过 DolphinScheduler 的邮件或 Webhook 告警发出去。这个技巧在实际运维中很管用相当于给每个失败任务都配了现场快照。我个人在反复处理这类问题后最深的体会是DolphinScheduler 的调度机制本身是稳定的多数故障的源头都在我们交给它执行的业务代码和数据库交互方式上。把事务边界控制好、把幂等设计好、把发布路径理顺比任何花哨的集群调优都更有效。事务和热替换这两件事一个是让系统在正常时期更健壮一个是让出问题时能快速恢复它们本质上都在回答同一个问题你的调度系统能不能承受变化而不只是能不能按时跑完任务。能用代码热替换解决的事尽量别去重启集群这是我在这次实战里学到的最实用的一条经验。
阅读完成 · 觉得有帮助?