凌晨两点四十监控群突然炸了。订单超时关闭任务整整半小时没跑后台堆了五六万个未关单。我登录服务器一看老调度服务进程还活着但它的数据库连接池被打满调度线程全卡在锁等待上一个任务也没发出去。那天我一边手动补单一边下定决心定时任务这块必须重做。这就是 ax调度 的由来。ax调度 是我内部自研的一套分布式任务调度平台代号 AX全称可以理解为 Any eXecution意为“任何任务都能按计划执行”。它不打算跟大厂中间件比功能目标很明确在尽量少依赖的前提下解决中小团队最痛的那几件事——定时任务没人管理、单点一挂就全挂、任务跑飞了没人知道。下面我把它从需求、设计、落地到排坑的完整过程写一遍希望能给正在纠结自研还是选型的同学一点参考。1. 需求是怎么来的事故剖析与选型纠结1.1 那次事故暴露的四个问题回看那半小时的故障本质上不是服务器宕机而是老调度框架扛不住了。第一单点问题。调度服务只有一台进程在但内部一个关键线程被数据库连接卡死整个调度就停了。这种“半死不活”的状态比磁盘挂掉更难受进程存活检查发现不了监控告警也不触发。第二不可观测。任务有没有跑、跑了多久、结果如何全都没有完整记录。我翻日志只能看到零零散散的 debug 输出根本拼不出一个任务从触发到执行完成的完整链路。出问题的时候连“它为什么没跑”都说不清楚。第三无法干预。想临时停掉某个出问题的任务只能改配置重启服务。更尴尬的是老框架重启之后错过的调度不会补偿所有本该在这段时间执行的任务全部静默丢失。第四没有隔离与权限。任务全部混在同一个进程里一个任务死循环就能拖垮整个服务其他任务跟着遭殃。业务方想新增一个定时任务还得来我这里排队改配置。1.2 开源方案真不少为什么还自研当时市面上能用的方案很多Quartz、XXL-JOB、ElasticJob甚至直接上Airflow、DolphinScheduler。我也花了一周多时间做选型评估不是没考虑过直接用现成框架。方案优势主要顾虑Quartz轻量、可靠、无中心化管理能力弱、无控制台、分布式部署配置繁琐XXL-JOB功能全、有控制台、社区活跃依赖较多扩展定制需要适应它的设计语言ElasticJob分片能力强、基于ZooKeeper运维ZooKeeper有成本中小团队略重自研定制自由、依赖最少、完全可控开发周期长需要有人持续维护最终选自研核心原因是三笔账依赖账。团队当时最熟悉的中间件就是 MySQL 和 Redis不想为一个调度系统再引入 ZooKeeper 或强依赖xxl-job-admin那套服务端体系。自研调度器只需要一张MySQL表加一点Spring Boot知识就能跑起来。定制账。内部很多任务需要跟工单系统、告警平台深度打通比如任务失败自动创建工单、跑完回写数据质量报表。用开源框架也能做但改起来总有边界自研则可以放心大胆地改。人力账。团队当时后端加运维也就十人左右没有专人维护新的中间件。自研的调度器后续由我顺带维护只要保证简单、稳定、不吃资源人力成本是最低的。当然自研最大的代价是开发与踩坑周期。这个账也必须承认后面第四章的排坑内容就是代价的一部分。1.3 需求清单先定边界再动手想清楚“要什么”之后我把需求列成清单避免在开发中无限发散。功能性需求支持 cron 周期任务、固定频率任务、一次性延迟任务支持任务的启停、临时触发、参数修改支持失败重试、超时控制、执行结果记录支持多执行器节点路由节点故障自动转移提供最小可用的管理界面和告警通知非功能性需求调度服务可用性目标 99.9% 以上单集群支撑 5000 个任务触发延迟不超过 2 秒新增外部依赖尽量少运维成本尽量低调度器为无状态设计支持水平扩展这个清单看起来朴素但每一条都对应一个具体的线上痛点。后来我经常提醒自己调度平台是基础组件功能可以不多但稳定性和可观测性必须第一位。2. 整体架构与核心设计原理2.1 最小的分布式调度怎么组织ax调度 只划分了三个角色没有引入额外组件。调度器scheduler负责扫描任务表、生成任务实例、投递任务并接收执行结果。调度器本身无状态可以部署多个节点节点之间通过数据库锁协调谁抢到任务谁处理。执行器worker是真正跑业务逻辑的角色独立部署在业务服务中。业务方通过实现一个接口来定义任务逻辑worker 启动后自动注册到调度器。控制台admin提供任务配置、实例查询、监控告警的页面。控制台在实际实现中并不强制最初我用一个简单Web界面后来业务方要求不高甚至直接维护数据库配置也行但为了安全还是保留了admin服务。三个角色的交互方式是调度器主动投递、worker执行后回调。这里我特意选择了“调度器推”而不是“worker拉”。主要原因在于调度器拥有全局信息能够统一判断路由、重试、超时worker拉取模式虽然削峰效果好但每个worker不知道全局状态超时和重试逻辑容易散落各处。推模式的缺点是需要控制线程池和队列这个我后面会展开讲。2.2 数据库表设计两个表各司其职调度系统最关键的存储就是任务元数据和执行实例。我把它们拆成两张表这是整套设计的基石。任务表ax_task保存任务的静态配置CREATE TABLE ax_task ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, name VARCHAR(128) NOT NULL COMMENT 任务名称, trigger_type TINYINT NOT NULL DEFAULT 1 COMMENT 1:cron 2:fixed_rate 3:one_time, cron_expr VARCHAR(64) DEFAULT NULL COMMENT cron表达式, executor_app VARCHAR(64) NOT NULL COMMENT 执行器应用名, route_strategy VARCHAR(20) NOT NULL DEFAULT ROUND COMMENT 路由策略, timeout_ms INT NOT NULL DEFAULT 30000 COMMENT 超时毫秒数, retry_times TINYINT NOT NULL DEFAULT 0 COMMENT 失败重试次数, task_param TEXT COMMENT 任务参数, status TINYINT NOT NULL DEFAULT 1 COMMENT 0:禁用 1:启用, next_run_time DATETIME NOT NULL COMMENT 下次触发时间, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_next_status (status, next_run_time) ) ENGINEInnoDB;执行实例表ax_instance保存每一次运行的状态CREATE TABLE ax_instance ( id BIGINT NOT NULL COMMENT 分布式ID, task_id BIGINT NOT NULL COMMENT 任务ID, idempotent_key VARCHAR(128) NOT NULL COMMENT 幂等键防重复执行, trigger_time DATETIME NOT NULL COMMENT 理论触发时间, schedule_time DATETIME NOT NULL COMMENT 实际调度时间, start_time DATETIME DEFAULT NULL, end_time DATETIME DEFAULT NULL, status VARCHAR(20) NOT NULL COMMENT RUNNING/SUCCESS/FAILED/TIMEOUT/CANCELED, worker_addr VARCHAR(64) DEFAULT NULL COMMENT 执行节点地址, result_msg TEXT COMMENT 执行结果摘要, PRIMARY KEY (id), UNIQUE KEY uk_idempotent (task_id, idempotent_key), KEY idx_status_time (status, schedule_time) ) ENGINEInnoDB;为什么拆两张表而不是合一张因为两边的数据模型和增长方式完全不同。任务表数据量稳定是配置实例表数据量随着执行次数快速增长是流水。如果合并到一张表任务配置的更新会被执行流水干扰清理历史实例时还会误删配置。分开之后后续做归档、清理、分析都顺手很多。这个设计原则也完全适用于其他类似系统配置与运行态分离。2.3 可靠触发MySQL行锁 SKIP LOCKED调度器的核心难点是“多节点抢任务但互不冲突”。我选用的方案是基于 MySQL 行锁实现分布式抢占核心SQL长这样SELECT * FROM ax_task WHERE status 1 AND next_run_time NOW() ORDER BY next_run_time ASC LIMIT 200 FOR UPDATE SKIP LOCKED;这个SQL是整套调度器的定海神针它同时解决三个问题。第一防止重复触发。多个调度器节点同时执行这条SQL行锁保证同一时刻只有一个事务能锁定某一行其他事务只能等或跳过。配合事务提交在任务实例写入ax_instance表之后才提交事务确保任务不会在“锁已释放但实例未生成”的窗口期被另一节点再次捞取。第二避免锁阻塞。SKIP LOCKED是MySQL 8.0提供的特性遇到被其他事务锁定的行直接跳过而不是等待。如果没有这个关键字一个调度器节点卡在慢事务里其他节点只能阻塞等待调度延迟会被无限放大。用了SKIP LOCKED多节点扫描任务表是真正并行的。第三限制扫描范围。LIMIT 200防止一次性把整个表捞出来。任务表即使只有几千行全表扫描加锁也不是好习惯批量处理可以平滑控制对数据库的压力。值得注意的是如果业务方环境还在用 MySQL 5.7SKIP LOCKED不可用。这时可以采用另一套方案在ax_task表增加一个lock_owner字段使用 CAS 方式更新抢占UPDATE ax_task SET lock_owner ?, lock_time NOW() WHERE id ? AND lock_owner IS NULL;但这种方案需要额外处理锁超时和过期复杂度更高。我的建议是调度系统对数据库版本有要求不丢人MySQL 8.0 已经是绝对主流为了可靠触发值得升级。2.4 高可用“谁挂了都不怕”的三种场景调度器节点挂掉由于调度器无状态剩余节点继续运行扫描SQL不会有任务被遗漏。如果一个节点在“锁定了任务但还没生成实例”时宕机事务回滚锁释放其他节点会在下一轮扫描中接管。执行器节点挂掉调度器投递到该节点的任务会在超时后被判定为失败然后根据重试策略重新投递给其他节点。这就要求 worker 必须是多节点的否则单点 worker 挂了任务必然失败这一点我在设计之初就跟业务团队强调过。任务本身跑挂任务进程还在但业务逻辑死循环或线程阻塞导致回调迟迟不来。调度器侧依靠超时机制兜底超过timeout_ms就会主动标记该实例为 TIMEOUT并按重试策略重新投递。超时阈值需要按任务实际耗时设置不能全局一刀切所以我把它放到了任务级别。这套高可用设计没有依赖任何外部中间件但能覆盖绝大多数故障场景。原理很简单把状态放到数据库里节点只做无状态的计算和转发。3. 从零落地核心实现细节与参数计算3.1 环境与依赖选择ax调度 的服务端基于 Java 17 Spring Boot 3 实现数据库使用 MySQL 8.0。Redis 不是必须的我只在要做实时在线节点统计时才用了它。也就是说最小部署只需要 MySQL 和两个Java服务。这个选择很大程度上降低了业务方接入的抵触情绪国内大多数后端团队对Spring Boot和MySQL都是最熟的。调度器和执行器之间我用 HTTP JSON 通信没有引入 RPC 框架。任务投递频率不算高HTTP 的简单和透明是最大优势出问题时抓包就能定位。回调接口也做成普通 POST 接口业务方甚至可以用 curl 手工模拟回调排查问题非常方便。3.2 调度端核心代码骨架调度器的核心循环非常简单核心思想是“轮询扫描 批量派发 异步回调”。while (schedulerRunning) { ListTask readyTasks lockAndFetchTasks(BATCH_SIZE); for (Task task : readyTasks) { String instanceId createInstance(task); dispatchExecutor(instanceId, task); } Thread.sleep(SCAN_INTERVAL_MS); }这里的lockAndFetchTasks执行的就是上一节那条SELECT ... FOR UPDATE SKIP LOCKED并且在同一个事务内完成实例的插入。dispatchExecutor不是直接调业务代码而是把任务封装成消息丢给派发线程池避免扫描线程被网络IO阻塞。我再强调一下扫描线程和派发线程池必须分离。第一版我偷懒直接在扫描线程里做 HTTP 调用结果下游接口慢的时候扫描线程被拖住整张任务表的调度延迟直线上升。后来改成“扫描只负责锁定和生成实例派发走独立线程池”扫描线程永远快速结束系统的吞吐和延迟才算稳定下来。3.3 线程池与并发参数计算线程池参数是我踩坑最多的地方这里给出一套计算思路。假设业务高峰期每分钟需要触发 6000 次任务平均每个任务执行时间为 1.5 秒。那么瞬间需要的执行并发度大约等于每秒触发任务数 6000 / 60 100 次 并发执行数 每秒触发任务数 × 平均执行时间 100 × 1.5 150所以调度器向 worker 投递的并发能力需要至少 150我实际把派发线程池设成了 200预留 30% 的余量。队列长度不能设成无界否则任务突发时会内存暴涨我设置为 2000拒绝策略用CallerRunsPolicy积压时让调度线程帮忙消化而不是直接丢弃任务。worker 侧的线程池需要单独计算。worker 接收任务后先把 HTTP 请求吃掉再把真正的业务逻辑丢给业务线程池。假设 worker 有 4 个节点每个节点分到的任务量是 150 / 4 ≈ 40 并发所以 worker 业务线程池设 64 足够。这里的关键是不要让任务执行阻塞HTTP接收线程。任务执行有耗时如果在线程内同步执行并发一高 HTTP 线程就被占满新的任务根本进不来。我用了一个最小巧的模型接收线程只做入队执行线程池做业务逻辑立刻就把“网络层”和“执行层”解耦了。3.4 Worker 执行与状态回调worker 的核心逻辑也保持简单void onReceive(JobRequest req) { executorPool.submit(() - { try { JobResult result executeBiz(req.getBizCode(), req.getParam()); callbackService.report(req.getInstanceId(), result); } catch (Exception ex) { callbackService.reportFailure(req.getInstanceId(), ex); } }); }executeBiz是业务方实现的接口。回调时除了上报 status 和 result还会带上worker_addr和耗时信息调度器收到后更新ax_instance表。为了防止业务参数被篡改回调接口带了一个简单的签名校验header 里放 HMAC 签名密钥在执行器注册时下发。这里有个细节回调接口也需要设置超时。曾经有段时间 worker 回调总是失败排查发现是回调 HTTP 客户端默认连接超时 10 秒而调度器接口偶尔因为慢查询超过 10 秒worker 就认为回调失败又发起重试。后来把回调客户端的连接超时和读取超时都调到 3 秒调度器接口优化了索引问题立刻消失。基础组件之间的通信超时配置必须严格快于业务任务自身的超时阈值否则重试逻辑会错乱。3.5 上线步骤与验证清单ax调度 从开发完成到全量上线我按下面几步走每一步都有明确的验收标准部署MySQL表和调度器服务保留旧的 Quartz 调度并行运行。接入第一个测试任务验证 cron 触发、实例记录、回调更新三个基本链路。部署两个调度器节点验证同一时刻只有一个节点触发任务另一个节点不会重复执行。部署两个 worker 节点验证轮询路由和故障转移。手动 kill 掉某个 worker观察任务是否被调度到另一个节点。全量迁移任务配置旧调度器停止扫描但保留日志三个月。整个过程大约用了两周。最花时间的不是写代码而是把旧任务配置从各种代码和配置文件里挖出来统一录入ax_task表。这一步也暴露了此前“任务随代码散布”的问题有十来个任务根本没有文档全靠老同事回忆。迁移之后我要求所有新任务必须走控制台配置不许再往代码里写 cron。4. 上线后我踩过的那些坑与排查技巧4.1 任务被重复执行回调超时引发的“双花”上线第二周业务反馈“退款通知发了两遍”。排查ax_instance表发现同一个任务的同一次触发时间竟然有两条 RUNNING 记录。原因链是这样的worker 执行成功但回调包在网络上多花了 5 秒超过了调度器设定的 4 秒超时调度器判定失败按重试策略将任务再次投递第二个 worker 又把同一个退款通知发了一遍。这个坑的根源是“执行成功”和“回调成功”被当成一回事了。解决分三层第一层在ax_instance表加了uk_idempotent(task_id, idempotent_key)唯一索引同一次触发的实例只能插入一次。第二层worker 在执行业务前先按instance_id查本地缓存发现已执行就直接返回上次结果。第三层回调接口采用条件更新UPDATE ax_instance SET status SUCCESS, end_time NOW() WHERE id ? AND status RUNNING;如果更新影响行数为 0说明该实例已经被更新过忽略重复回调。三层兜底之后至少没有再出现过“执行两次、回调两次”的情况。4.2 延迟飙升线程池和数据库连接池打架灰度期间任务量翻倍调度延迟突然从几百毫秒涨到十几秒。看监控发现调度器扫描线程倒是正常但派发线程池的队列深度一直顶在1000多。再查数据库活跃连接数达到上限所有 SQL 的耗时都变得非常慢。原因是数据库连接池和派发线程池共用了一套配置连接耗尽后新的 SQL 只能排队等连接而 SQL 排队又把派发线程拖住导致任务积压。这一环扣一环典型的级联故障。处理方案是两个池子彻底隔离数据库连接池固定设 20不留弹性派发线程池按前面计算的 200 设队列有界。另外我加了告警当派发队列深度超过 500 或者数据库活跃连接超过 15 时立刻告警。隔离之后调度线程和数据库操作再也不会互相拖累。4.3 任务堆积慢执行器如何拖垮全局有一次某个 worker 节点的磁盘 IO 故障业务任务集体变慢平均执行时间从 1 秒涨到 30 秒。回调迟迟不来调度器反复判定超时并重试重试任务又投给同一个故障节点形成恶性循环。这个问题的本质是调度器对 worker 缺乏故障感知。我的解决方案有三步第一步worker 每分钟上报心跳调度器维护每个节点的最近心跳时间。超过 3 个心跳周期未上报的节点从可用节点列表里摘除。第二步路由不仅仅做轮询还要结合健康状态。连续执行失败超过 5 次的任务调度器暂时不再向该节点派发新任务进入冷却状态。第三步对重试策略加上退避第一次重试延迟 10 秒第二次延迟 60 秒第三次延迟 300 秒。避免同一时间把所有重试任务全部打出去。改造之后某个节点故障最多只影响它当时正在执行的那批任务新任务会迅速转移到健康节点全局任务堆积没有再出现过。4.4 一张速查表排查问题多了之后我整理了一张速查表新同学接手时看看这个就能少走很多弯路。现象可能原因排查思路解决办法任务被重复执行回调超时后重试缺少幂等保护查看ax_instance同一idempotent_key的记录数量加唯一索引、条件更新、worker 本地去重调度延迟超过10秒派发线程池排队或数据库连接池耗尽看线程池队列深度和活跃连接数线程池与连接池隔离加队列深度告警某节点任务集体失败节点网络异常或磁盘故障查看心跳与健康检查记录故障摘除 冷却策略 重试退避任务量暴涨cron 配置失误或时区错误审计新配置的 cron 表达式配置时预校验 cron预估触发次数回调接口偶发超时数据库慢查询或索引缺失用EXPLAIN检查查询计划补索引优化慢SQL实例表无限膨胀历史数据从未清理统计表大小与归档情况定期归档保留最近三个月热数据4.5 最后分享一个印象最深的教训最后再说一个我至今印象最深的问题。有一段时间凌晨的系统负载特别奇怪白天一切正常但一到凌晨就周期性飙升。排查到最后发现有个任务配置错了 cron把“每天零点执行一次”写成了“每分钟执行一次”结果它一晚上跑了1440次而且这任务内部还要读取一张大表做全量扫描。这之后我把所有新建任务的 cron 做了一次强制校验启动时解析并打印下一次执行时间控制台配置页面也显示“距下次触发还有XX时间”。这个小小的改动至少帮我拦住了无数个类似的低级别错误。另外我学到的一个重要经验是在做调度这类基础组件时性能和功能不是第一位的可观测性和边界情况才是。你永远不知道业务方会写一个什么样的任务塞进来所以每一次重试、每一次超时、每一次回调失败都应该留下清晰的状态流转记录。后期我甚至把任务实例的状态变更都做成了可追溯事件随便拿一个 instance_id 就能查出它在哪个环节耗时多少、失败在哪个节点。这套“可观测性”的投入远远比新增一个调度策略更有价值。