分布式定时任务调度系统实践:从Cron到AX调度平台
发布时间:2026/9/28 17:26:29来源:尧图网络
跟任务调度打交道久了你会发现一个很有意思的现象很多业务团队最早都是从几个 cron 脚本开始跑定时任务跑着跑着一两年过去脚本越来越多互相之间出现依赖半夜失败以后没人知道数据对不上账也没人敢重跑。我这里说的 ax 并不是那个 Wi-Fi 6 的 802.11ax也不是什么斧头类游戏道具而是一个内部项目的代号——AX 调度。这个项目本质上是一套分布式定时任务与异步任务调度系统把任务的创建、触发、执行、重试、监控全部收敛到一个统一平台里。这篇文章我围绕 AX 调度的完整落地过程来写包括为什么没直接上现成组件、任务模型怎么设计、分布式锁怎么才能不出事故、压测数据大概是多少以及一系列我在实际运维中踩过的坑。如果你正准备把团队里的零散 cron 收拢成一套正经的调度平台或者已经在用 XXL-Job、Quartz 但觉得不够贴合业务这篇内容应该能给你不少可以照抄的细节。1. 项目整体设计与思路拆解1.1 出发先说清 AX 调度解决的是一类什么问题很多团队在最早期往往只有一两个定时任务比如每天凌晨同步一次数据、每小时清理一次日志用系统的 crontab 就能搞定。但业务一旦复杂起来定时任务会迅速失控。我见过一个团队在生产环境挂了大概 30 多个 cron 脚本分散在三四台机器上有些脚本之间还有隐式的先后依赖——上游不跑完下游跑出来就是脏数据。出了问题就只能靠人去翻日志找到执行记录再手动补跑几乎谈不上可靠和可观测。AX 调度最开始的目标非常朴素把分散在各台机器上的定时任务收进来做成一个可以统一查看、统一编辑、带失败重试和告警的调度中心。后续业务提出异步任务、延迟任务、分片任务这些需求以后体系才逐步长成。核心要解决的四个问题分别是定时触发的可靠性、任务执行期间的状态可见性、失败后的自动重试与幂等兜底、以及多实例部署时不会出现任务重复执行。这四个问题听起来简单但真正落地过的人知道每一个背后都有大量细节。比如定时触发不只是“到点调一个接口”还要考虑错过了触发时间怎么办、实例重启以后没有补偿怎么办任务状态也不只是“成功/失败”两个值中间还夹着运行中超时、手动取消、被下游依赖阻断等一堆状态。AX 调度的整个设计本质上是围绕这几个问题上做的取舍。1.2 技术选型为什么不是 Quartz也不是直接上 K8s CronJob做选型的时候我把市面上常见的方案都摆在一起比过一轮Quartz、XXL-Job、Apache Airflow、K8s CronJob以及自研的 AX。方案定时触发分布式调度任务依赖UI与监控二次开发成本Quartz成熟需要自己搭集群方案弱无中XXL-Job成熟好一般有低Airflow好好强(DAG)有高偏向数据管道K8s CronJob基础依赖K8s无无低但能力有限AX(自研)自研可控自研可控可逐步增强自定义高Quartz 本身是一个非常好的调度库但它在集群环境下要依赖数据库锁来做调度互斥时间触发精度在大规模任务下会随着任务数量上升而明显下降。XXL-Job 是一个很成熟的开源产品文档也全但它天然带了自己的管理端和执行器模型接入以后很多逻辑要往它的模型上靠。Airflow 更适合做数据管道编排用来跑普通业务接口显然太重了。最后决定不走“拿开源产品改”的路线核心原因是业务侧有很多定制需求。我们要的任务不只是“到点触发 HTTP 请求”还包含延迟队列、任务分级、跨服务编排、灰度发布任务、失败分群提示这类偏业务能力。与其在开源框架上绕来绕去不如用一个非常朴素的核心模型自己搭。AX 的核心模型其实只有三件事任务、触发、执行结果。简单反而让后续扩展余地变得很大。1.3 整体架构与核心流程AX 调度在逻辑上拆分成三个模块ax-server调度中心、ax-worker执行器、ax-console管理后台。ax-server 负责任务的注册、调度时间计算、任务分发和结果回收是无状态服务可以水平扩多个实例。所有实例共享同一套任务配置数据库通过 Redis 做分布式锁和延迟队列。ax-worker 是真正跑业务逻辑的一端它接收调度中心下发的任务执行业务方法再把结果回调给调度中心。ax-console 提供可视化的任务管理界面。一条任务从创建到完成的核心流程大致如下用户在 ax-console 创建任务写入任务配置表指定任务类型、调度表达式、超时时间、重试次数、所属业务分组。ax-server 启动后从数据库加载未完成任务并把最近需要触发的任务写入 Redis ZSetscore 为下一次触发时间戳。调度线程每秒从 Redis ZSet 拉取 score 小于等于当前时间戳的任务取出后投递到任务分发线程池。分发线程根据任务配置选择对应的 ax-worker把任务请求发给执行器。ax-worker 收到请求后开始执行并在执行过程中定期上报心跳、更新执行日志。执行结束后ax-worker 把结果回调给 ax-serverax-server 更新任务状态和下次触发时间。如果执行失败ax-server 判断重试次数是否耗尽没有耗尽则重新进入延迟队列等待重试已耗尽则标记失败并触发告警。这整个流程听起来并不复杂但实际开发里最花时间的恰恰是那些异常分支回调丢了怎么办、执行器假死怎么办、任务重试时业务侧重复执行了怎么办。后面我会逐个拆开讲。2. 核心细节解析与实操要点2.1 任务模型与生命周期状态机AX 调度里有一个最核心的表叫任务表 ax_task它保存任务本身的配置信息。任务实例执行时会产生 ax_task_log记录每一次触发和执行的实际情况。任务整体生命周期我用状态机管理避免状态散落得到处都是。核心状态如下状态含义触发条件CREATED已创建未启用新建任务默认状态ENABLED已启用任务开启调度RUNNING执行中调度中心分发成功执行器开始执行SUCCESS执行成功执行器回调成功结果FAILED最终失败重试次数耗尽或不可自动恢复错误RETRYING重试中执行失败但重试次数未耗尽TIMEOUT执行超时超过配置的最大执行时长仍未返回CANCELLED已取消手动停用或删除任务状态机设计的关键是让每个状态都有明确的进入与离开条件。比如 RUNNING 不是靠执行器回调才能离开调度中心还有一个超时扫描线程它定期扫描长期处于 RUNNING 状态的任务一旦超过配置的超时时间就先把状态改成 TIMEOUT再通知执行器执行中断。这个超时扫描很重要因为执行器可能会假死网络分区时回调也可能一直发不回来如果只依赖回调任务状态会永远卡在 RUNNING。这里有个容易被忽略的细节任务状态与执行日志状态要分开。任务配置表 ax_task 上的字段表达“这个任务最近一次跑到什么状态了”而 ax_task_log 记录每一次运行的具体状态。否则一个每日执行的任务你只能看到最终状态无法回溯历史执行过程排查问题会非常痛苦。2.2 分布式锁的正确姿势调度中心既然可以水平扩展那理论上多个调度实例可能同时把同一个任务拉出来执行这是任务重复执行的最大来源。AX 调度里我用 Redis 分布式锁来保证同一时刻同一个任务只被一个实例处理。很多文章教人用 setnx 加锁用完再 del这个思路能跑通 demo但离生产可差得远。AX 里的分布式锁实现做了三件关键事锁的 value 必须携带一个全局唯一的请求标识比如 UUID这样释放锁的时候要校验 value 是否属于自己防止误删别人的锁设置锁的时候必须用 set key value nx ex 这种原子命令而不是 setnx 之后单独设置过期时间否则中间进程崩溃会让过期时间设置失败锁永远不会释放锁的过期时间要略微大于任务执行时间并且执行期间要做续期我建议用一个后台线程每过三分之一过期时间就续一次锁。我当时踩过的真实事故是这样的某次任务执行时间比较久锁过期了另一个调度实例抢到锁开始执行同一个任务两边同时跑下游接口被重复调用了好几次出现了脏数据。后来我把锁的过期时间从固定 60 秒改成根据任务预估执行时间动态计算并加了续期逻辑这个问题才算彻底根治。分布式锁不是加一个 setnx 就高枕无忧它需要像一小节有状态的生命周期来管理。2.3 任务幂等与超时控制分布式锁能避免同一个任务被两个调度实例同时捞出去执行但它没法处理一种情况任务调用下游 RPC 之后网络超时了调度中心判定这次执行失败进入重试于是任务被同一实例再次执行。对下游系统来说这相当于重复请求所以 AX 调度强制要求每个任务都要做幂等设计。具体做法是定义全局唯一的执行标识简称为 gid。ax-server 每次分发任务时生成一个 gid透传给 ax-workerax-worker 调用下游系统时把 gid 作为幂等键传递。下游系统收到请求后先查幂等表如果这个 gid 已经处理过直接返回上一次结果如果没有才执行业务逻辑并把结果落库。这个设计很简单但很多人会忽略 gid 的生成规则直接用任务 ID 加时间戳其实是错的——重试时时间戳变了幂等就失效了。正确做法是 gid 由任务实例 ID 直接派生我推荐用 ax_task_log 的主键作为 gid 来源同一个任务实例无论重试多少次gid 始终保持不变。超时控制方面AX 采用两层超时调度侧超时和执行器侧超时。调度侧超时主要防止执行器失联导致任务卡死执行器侧超时则控制任务本身的执行时长以代码里 Future.get 的 wait 时间来实现。两层超时的时间设置要有区别建议执行器侧超时比调度侧超时短 5 到 10 秒这样执行器侧先超时主动中断任务并上报超时结果调度中心就不用等到扫描线程来处理了。2.4 优先级分组与资源隔离调度系统最让人头疼的场景之一是一个低优先级的批量任务把线程池占满导致线上高优任务排队等了几分钟。AX 调度从一开始就把任务按业务分组隔离开每一组任务有独立的调度配额和执行线程池。分组对应一张 ax_group 表字段包括 group_key、调度配额、告警联系人。比如支付相关的任务组调度配额是 200线程池大小是 50报表相关的任务组调度配额是 50线程池大小是 10。这样即使报表组任务量爆发也不会抢占支付组的执行线程。同一个组内的任务AX 还支持优先级字段 priority。优先级高的任务在分发队列里会被优先取出代码实现上用 Redis 的 ZSet 本身就是按 score 排序的所以优先级调度不需要额外维护队列直接把优先级映射到 score 的低位区间就行。这里需要注意的是优先级不能只靠调度端的队列保证执行器端也要有对应的处理优先级。ax-worker 接收到多个任务请求时会先把请求放入一个队列再按优先级重新排列这样即使调度端同时分发一批任务执行器端也不会出现低优任务抢占高优任务的情况。3. 实操过程与核心环节实现3.1 表结构设计与初始化先看 AX 任务表的核心字段。我这套用 MySQL 8.0表结构做了最简优化去掉了业务无关的冗余字段。CREATE TABLE ax_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, group_key VARCHAR(64) NOT NULL COMMENT 任务分组, task_name VARCHAR(128) NOT NULL COMMENT 任务名称, task_type TINYINT NOT NULL COMMENT 任务类型1-scheduled 2-delay 3-event, status TINYINT NOT NULL DEFAULT 0 COMMENT 任务状态0-创建 1-启用 2-暂停, cron_expr VARCHAR(32) COMMENT cron 表达式, executor_key VARCHAR(128) NULL COMMENT 执行器标识, biz_param TEXT NULL COMMENT 业务参数JSON 格式, timeout_seconds INT NOT NULL DEFAULT 60 COMMENT 超时时间, retry_count TINYINT NOT NULL DEFAULT 3 COMMENT 最大重试次数, priority TINYINT NOT NULL DEFAULT 5 COMMENT 优先级 1-10, last_trigger_time BIGINT NULL COMMENT 上次触发时间戳, next_trigger_time BIGINT NULL COMMENT 下次触发时间戳, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_group_status (group_key, status), KEY idx_next_time (next_trigger_time) );任务执行日志表 ax_task_log 负责记录每一次触发实例的执行状态核心字段是 task_id、gid、trigger_time、start_time、end_time、status、result_msg、retry_times、trace_id。初始化时注意两点一是 task_type 要设计得足够抽象我见过很多调度系统把定时任务和延迟任务分成两张表维护成本加倍其实它们在触发逻辑上高度一致只是触发时间来源不同二是 next_trigger_time 必须建索引调度中心每次启动都要扫描下一批要触发的任务没有这个索引任务量一上来肯定全表扫描。3.2 调度主循环实现细节ax-server 的调度主循环是整个系统的发动机。我用 Go 来写这一段因为 goroutine 在这个场景下用着顺手但换成 Java 对应的逻辑也完全一样。func (s *Scheduler) dispatchLoop(ctx context.Context) { ticker : time.NewTicker(1 * time.Second) defer ticker.Stop() batchSize : 200 for { select { case -ctx.Done(): return case -ticker.C: now : time.Now().UnixMilli() keys : buildZSetKeys() for _, key : range keys { s.pullAndDispatch(key, now, batchSize) } } } } func (s *Scheduler) pullAndDispatch(key string, now int64, batchSize int) { tasks, err : s.redis.ZRangeByScore(key, now, batchSize) if err ! nil { log.Errorf(pull tasks from redis failed: %v, err) return } for _, t : range tasks { // 尝试获取分布式锁 locked, err : s.tryLock(t.ExecutorKey, t.Id, t.Gid) if err ! nil || !locked { continue } // 投递到本机分发线程池 s.dispatchPool.Submit(func() { s.dispatch(t) }) } }这里一个非常关键的细节ZSet 里拉取任务之后不能立刻删除任务的 zset 成员。如果立刻删除但分发线程执行任务时失败任务需要回滚到队列里处理起来会很绕。我的做法是先把任务从 ZSet 中移除但把整个任务对象反序列化后放入自己维护的待执行集合中执行完再根据结果决定是回写 ZSet 还是直接更新状态。这样既避免多个调度实例同时捞到同一个任务又保留了失败回滚的能力。另外ZSet 的 key 我这里做了分片默认 16 个分片。所有任务按照 group_key 的 hash 值落到不同分片的 ZSet防止单 key 数据量过大导致 Redis 操作变慢。如果你任务量不大不分片问题也不大但一旦每秒调度任务量超过几千分片优势就很明显了。3.3 执行器回调与结果处理ax-worker 接收到任务后会按照 executor_key 找到对应的执行器实现类反射调用业务方法然后把执行结果回调给 ax-server。回调不能做成同步 HTTP 请求后直接等结果。真实场景里调度中心分发时可能正好在发布重启或者网络抖动导致回调失败所以 AX 设计成执行器先写本地结果队列异步把结果上报。func (w *Worker) execute(task Task) { execResult, err : w.invoke(task) if err ! nil { execResult Result{Status: StatusFailed, Message: err.Error()} } // 本地落一条结果记录 w.localResultStore.Save(task.ExecutionId, execResult) // 异步回调调度中心 w.callbackAsync(task.ExecutionId, execResult) }回调结果写到 MySQL 时再做一层校验只有当前任务状态是 RUNNING 或 RETRYING 时才允许更新为 SUCCESS 或 FAILED。如果任务已经被超时扫描线程改成了 TIMEOUT回调结果仍然记录到日志表但不更新任务主状态。用乐观锁版本号实现更新语句带上 where version current_version稳得很。这里顺便说一下重试的表态公式。每次执行失败后判断当前重试次数是否小于配置的最大重试次数如果小于则把任务重新放入延迟队列延迟时间按 2 的 n 次方递增即 1 分钟后重试、2 分钟后重试、4 分钟后重试封顶 30 分钟。这个简单指数退避策略比固定延迟效果好很多能避免服务恢复瞬间发生重试风暴。3.4 压测结果与配置参考AX 调度上线前我做了一轮压测这里把关键数据放出来你们可以对照自己的机器配置来评估。压测环境8 核 16G 虚拟机两台跑 ax-serverRedis 6.0 单节点MySQL 8.0 单实例任务总量 2 万个定时任务调度频率为每 10 秒一批任务执行时间是模拟的真实 HTTP 调用平均耗时 80ms。指标结果调度成功率99.98%平均调度延迟23msP99 调度延迟86ms每秒最大调度任务数7250Redis ZSet 单分片任务数约 1250调度线程池占用峰值 46/200这个压测结果基本满足大部分业务场景。如果你的任务量超过这个量级优先把 Redis 换成集群模式再不行就把调度任务量按租户维度做垂直拆分。调度延迟的 P99 主要花在 Redis 和网络 IO 上任务本身不复杂的话单机调度 5000 个任务每秒并没有太大压力。4. 常见问题与排查技巧实录4.1 任务重复执行先从一张调度日志找线索线上最怕的就是“任务明明只该跑一次结果跑了一次又一次”。我处理过的重复执行案件里真正因为分布式锁失效的其实占比不大更多是回调超时后触发了重试而下游接口没有做幂等。排查这类问题我的经验是先查 ax_task_log 表从日志看同一个 task_id 在同一个时间窗口内的多条日志比较它们的 gid。如果 gid 相同说明是同一任务实例的重试如果 gid 不同说明是多个调度实例各自捞取了一次那就要查分布式锁为什么没有生效。两条路径的修复方案完全不同前者是下游幂等没做好后者是锁本身有问题。很多新手一上来就去翻 Redis 锁的日志方向就错了。补充一个排查技巧ax-worker 每次收到任务请求时把任务 ID、gid、执行器 IP 完整打一行日志这行日志是解决 80% 重复执行问题的钥匙。没有这行日志的话两边日志时间轴对不上排查难度会高一个量级。4.2 任务积压导致调度延迟飙升某个业务组曾经因为大量耗时的导出任务堆积把调度延迟从 20ms 推到 5 分钟级别。当时的表象是所有的任务触发时间都比配置时间晚了几分钟业务方来投诉说定时上报的数据没有按时出现。我看了一眼监控调度线程池的活跃线程数是 200直接满编。这些线程全被大量耗时 3 到 5 秒的导出任务占住了。问题出在调度分发时没有区分任务的执行时长类型把短任务和长任务混在同一个线程池里。长任务占线程短任务排队等延迟自然就上去了。解决办法有两个层面。第一个层面按照任务时长打断言长耗时任务走单独的线程池带宽限制更严格。第二个层面调度分发时增加一个“已投递未完成数量”的指标如果某个组的未完成任务数超过阈值新任务直接进入等待队列而不是盲目往线程池里塞。压测里我把这个阈值设置为线程池大小乘 3实测下来系统稳定很多。4.3 锁超时但任务实际执行成功的情况前面提过锁过期导致重复执行这里还有个更隐蔽的问题任务实际执行成功了但因为锁超时被另一个实例抢走执行第二个实例也成功了结果下游系统同一笔业务被处理了两次。这个事故的根源在于任务执行时间超过了预估。我们后来的解决方案是执行器执行期间必须发送心跳心跳不仅用于续期分布式锁还会实时更新任务日志表的心跳时间。调度中心的超时扫描线程判断任务是否超时不再只看总时长而是看“最近一次心跳到现在”的间隔。只要执行器还在发心跳任务就一直保持 RUNNING一旦心跳停了才可能判定超时。这个机制能有效减少执行苏醒但实际正常的情况。与此同时锁的续期逻辑和执行器心跳绑定心跳发一次锁就续一次过期时间从机制上杜绝了锁比任务先过期的情况。4.4 实例重启后任务丢失开发环境重启服务是很频繁的事但 AX 初版上线时确实遇到过一个问题凌晨一次性重启所有 ax-server 实例结果本该在凌晨 2 点触发的任务没跑第二天早上业务报警才发现。原因是任务只在 Redis ZSet 里存了一份Redis 虽然不会丢但重启后我的调度主循环只加载 next_trigger_time 大于当前时间的任务那些在重启窗口期已经该触发但还没触发的任务被跳过了。这个 bug 的修复方式是在 ax-server 启动时补做一个补签操作从数据库扫描 last_trigger_time 小于配置周期且 next_trigger_time 已经早于当前时间且状态为 ENABLED 的任务把它们重新投入调度队列。补签窗口设置成 10 分钟超过 10 分钟没跑的任务视为异常人工介入处理避免服务宕机很久以后重启时瞬间把所有历史任务全部补跑一遍造成下游压力过载。4.5 配置了几个最佳实践的默认值最后分享一组经验默认值适合大多数常规业务任务超时时间默认 60 秒允许重试 3 次重试退避策略用 1 分钟、2 分钟、4 分钟指数递增锁过期时间设置为超时时间加 10 秒心跳上报频率每 15 秒一次调度主循环轮询周期 1 秒。这组参数在我维护的大部分业务系统里都能直接使用可以当作起步值再根据具体业务调整。5. 扩展经验与个人体会5.1 从单机任务到 DAG 工作流的自然演进AX 调度做到第二个月业务侧提出了一个典型需求数据同步任务跑完以后要自动触发数据校验任务校验通过之后再触发报表生成任务。这不再是简单的定时任务而是任务之间的依赖编排。当时有两种方案一种是在任务参数里加一个上游任务 ID上游执行成功之后由调度中心主动触发下游另一种是引入完整的 DAG 模型。我最终选了前者原因很简单业务真正需要的有向依赖不超过两层引入完整 DAG 的工作量和对任务模型的影响都不划算。在 ax_task 表里增加 upstream_task_id 字段执行器执行成功后调度中心判断是否存在下游任务如果存在直接投递下游。这个轻量级方案上线后运转得很稳定。如果你确实需要复杂的大规模 DAG 编排再考虑引入 Argo 或 Airflow 那一层不必一开始就上重武器。5.2 简单模型带来的维护红利现在回头看AX 这个项目最值得骄傲的并不是功能有多丰富而是核心模型足够简单简单到每个新接手的人在一小时内就能看懂状态机。你去看很多开源调度平台的设计它们往往把触发机制、任务依赖、告警规则、权限管理塞在一起扩展性确实好但理解成本很高。AX 走的是另一个极端核心模型只有任务、触发、执行结果三个概念一切复杂行为都在这三个概念的基础上去叠加。这使得排查问题时的心智负担非常低。5.3 最后一点建议如果你也在做类似的调度系统我的建议是先花两周时间把自己业务里现有任务完整盘点一遍搞清楚每一类任务对延迟、可靠性、幂等的要求差别再动手设计。千万不要一开始就把 DAG、分片、动态扩缩容全部塞进去调度系统的复杂度往往是后面长出来的不是前期设计出来的。AX 调度从最初只是把 cron 收拢起来的轻量平台慢慢长出分组隔离、优先级、重试、补签这些能力每一步都是为了解决实际线上问题才加的这也是它至今没有变得臃肿的原因。最后分享一个小技巧每次发布调度系统新版本之前手动把任务配置表完整导出一份 JSON 文件放到发布目录里一旦新版本出现批量问题可以用最快的速度回滚配置而非依赖数据库备份恢复。这个小习惯已经帮我避免过两次潜在事故希望对你有用。
网站建设高端定制企业官网