如果你也遇到过那种“每天定时任务东一个西一个,上线靠人肉、失败靠运气”的系统,那你大概能理解我为什么花了大半年时间,把一个原本藏在使用方服务里的调度逻辑全部抽出来,单独做成了一个调度中间件。内部代号就叫ax,全称是 Action eXecutor。现在团队里不管是技术评审还是故障复盘,“ax 调度”已经成了一个默认词汇——它代表的不只是这个组件,更是一种“调度和执行必须解耦”的做事方式。
这篇博客会把 ax 调度从需求出发、技术选型、架构设计、核心实现,到真实压测和一堆线上排障经验完整复盘一遍。内容偏实战,不写过多的理论包装。适合两种人看:一是正在被 cron 和脚本任务折磨,想自己造一个调度器的人;二是已经准备上 XXL-Job 或 Quartz,但还没想清楚边界和取舍的人。
1. 为什么放着现成调度器不用,要自己写一个 ax
先说我当时的真实处境。项目里有 8 个业务服务,各自的定时任务刚开始都是走机器上的 crontab。表面上看没问题,因为单个服务里也就几十条规则。但等业务发展到几百条任务之后,痛点开始变得非常具体:任务参数没地方管理、执行历史看不到、失败只能翻日志、想按业务维度停掉一组任务根本做不到。更要命的是,只要涉及多服务的任务需要按顺序执行,你只能靠“人工编排”去错开时间点。
这不是一个靠堆人力能解决的问题。我当时的判断是,需要一个真正能被业务方“感知”的调度中心:能创建任务、能看到触发记录、能失败重试、能支持一个最简单的任务依赖关系。但直接上现成调度框架又让我犹豫,因为很多框架把调度中心的职责和业务执行逻辑绑得太紧。
1.1 被 crontab 支配的那半年
crontab 本身没毛病,它在单机场景下非常可靠。但你有几十台机器、几百条任务之后,crontab 的分布式劣势就很明显。每台机器上是什么版本的脚本、有没有被误删、环境变量是否一致,这些都成了隐形风险。
最有说服力的一个事故是:某个数据对账任务写在了两台机器的 crontab 里,本意是做高可用,但因为两个实例共用同一个输出目录,结果两边同时跑,把中间结果文件写乱了。排障花了整整一个下午,最后发现是 crontab 没有分布式锁。从那时我就意识到,定时触发只是最表层的问题,真正的复杂度在“这么多实例同时跑,如何保证只有一个人干活”。
1.2 我把候选方案全部过了一遍
当时认真对比过三个方向:Quartz、XXL-Job、自研。Quartz 很成熟,但它的集群模式还是基于数据库锁,调度量大了以后数据库会成为瓶颈,而且要嵌入到业务应用里,不利于和业务代码隔离。XXL-Job 功能很全,有后台管理界面、有执行器、有报警,这确实是很多团队的首选;但它也带来了相对固定的接入方式,执行器 SDK 一旦和 Spring 版本出现兼容问题,升级会非常被动。
我把它们的关键差异整理成了一张表,越看越清楚自己要什么:
| 方案 | 调度能力 | 部署成本 | 与业务耦合度 | 二次开发成本 |
|---|---|---|---|---|
| crontab | 弱 | 低 | 极高 | 极低 |
| Quartz 集群 | 中 | 中 | 高 | 中 |
| XXL-Job | 强 | 中 | 中 | 中 |
| 自研 ax | 可定制 | 中 | 低 | 高 |
自研看上去最重,但我的核心诉求其实就一个:调度器只负责判断“到什么时间该调用谁”,不执行任何具体业务动作。这样调度中心可以独立部署、独立扩缩容,业务的接入方式就是注册一个 HTTP 回调,干净利落。所以最终拍板自研 ax,其实是业务边界逼出来的选择。
1.3 想清楚 ax 的边界
自研调度器最大的风险不是写不出来,而是越写越膨胀。今天想加重试面板,明天想加工作流编排,后天想加任务依赖可视化,最后变成一个谁都不敢动的怪兽。所以 ax 在设计之初就定了三条边界:
第一,不做任务依赖图谱。只做一层简单依赖,也就是一个任务可以指定“等哪些任务先完成”,但不去实现复杂 DAG。业务方真有复杂编排,请使用独立的工作流引擎。
第二,不做执行器。内部的热点函数不放在调度器里跑,所有任务都通过 HTTP 调用下游服务,由下游返回成功还是失败。调度器不关心业务结果内容,只关心任务状态。
第三,不做高精度秒级调度。默认使用秒级精度即可,所有任务最小粒度是秒,不追求毫秒级触发。大部分业务定时任务用不到毫秒级,如果追求毫秒级,技术复杂度会指数级上升。
这三条定下来之后,ax 的形态就清楚了:它是一个能横向扩展、能持久化任务定义、能可靠触发任务的调度核心,而不是一个大而全的自动化平台。
2. ax 架构设计:调度器不干活,只负责“吹哨”
ax 在设计上是最朴素的“触发器 + 调度器 + 执行器”三层结构。触发器负责计算任务下一次运行时间;调度器负责在时间到达时把任务变成可执行状态;执行器由各业务方自己实现,通过 HTTP 接口接收指令。ax 只做前两层。
任务在 ax 里的生命周期被抽象成几个简单的状态:PENDING(等待触发)→READY(可以被执行)→RUNNING(执行中)→SUCCESS或FAILED。调度器的所有工作都在把任务从PENDING推到READY,执行器则负责后面的状态流转。这看起来很简单,但只有分解到这个程度,后续的分布式改造才有抓手。
2.1 任务模型:宁可字段多一点,也别留后患
任务定义表ax_task是 ax 最重要的数据模型。它的核心字段包括:任务 ID、名称、场景标识、cron 表达式、回调地址、请求参数、超时时间、最大重试次数、状态、下次运行时间next_run_at。
注意:
next_run_at一定要建索引。这个字段是整个调度器扫描和加载任务的核心条件,没有索引的话,任务量上来之后数据库先扛不住。
我见过不少调度系统把任务参数直接塞进一个 text 字段,短时间看没问题,但一旦任务需要批量编辑或者按参数维度检索,就会非常痛苦。ax 里用了 JSON 字段存扩展参数,同时也保留了scene这样一个业务维度字段,方便按场景批量启停。
ax_instance表负责记录每一次触发。每条实例记录都有唯一的request_id,这是后面做幂等和排查问题的关键。执行器回调的时候带上这个 request_id,ax 就能确定是哪一次触发,不会把不同时间点的执行结果搞混。
2.2 时间轮:为什么是 600 个槽
调度器核心是一个基于时间轮实现的定时器。我选时间轮而不是小顶堆,主要是出于两个原因:一是 ax 的任务量大,单实例要支撑几万甚至十几万个定时任务,小顶堆每次插入删除都是 O(log n),时间轮插入和删除都是 O(1);二是时间轮的语义简单直接,每秒钟 tick 一次,每次只看当前槽位里的任务列表,心智负担低很多。
ax 的时间轮默认设置是 600 个槽位,每个槽位代表一秒,走完一整圈就是 10 分钟。也就是说,任何任务的延迟触发时间如果超过 10 分钟,就需要额外记录它属于第几圈。任务注册进时间轮时,会计算它在轮上的位置和圈数,tick 到对应槽位时,只处理圈数为 0 的任务。
这个设计的巧妙之处在于,10 分钟的窗口足够覆盖绝大多数定时任务的精度要求。cron 表达式算出来的下一次运行时间一般都在未来几分钟或几小时内,超过 10 分钟的任务会被放在后续的某个槽位里,等时间轮转到了再处理。就算某一个槽位里的任务特别多,也只是那 1 秒的 CPU 开销变大,不会影响其他槽位的任务。
2.3 多实例之间不抢活:协调器与租约
ax 调度器可以横向部署多个实例,但同一个任务在一个时间点只能被触发一次。这就要解决分布式协调问题。我没有引入比较重的选主框架,而是为每个任务生成一把 Redis 分布式锁。
具体来说,调度器实例在准备把任务从PENDING推到READY前,会先执行一条 Redis 命令:尝试设置ax:lock:{taskId},value 是当前实例的 ID,并带上一个 3 秒的过期时间。只有设置成功的实例,才有资格去更新数据库里的任务状态。这样即使两个实例同时从时间轮里拿到了同一个任务,也只有一个能拿到锁。
这个方案有几个需要注意的细节。锁的过期时间不能太长,否则实例宕机后任务会被锁住很久;但也不能太短,否则任务还没处理完锁就过期了。我们的做法是锁只负责“触发瞬间”的互斥,不负责整个执行过程的互斥。真正保证不重复执行的是数据库里的唯一约束和实例状态机,Redis 锁只是第一道闸门。
3. 实操细节:从定时表达式到分布式锁,逐个填坑
ax 看起来结构简单,但真正把它做稳,是在几个细节上反复打磨的结果。这里挑三个最有代表性的实现细节展开讲,都是可以直接抄作业的经验。
3.1 自研 cron 解析的取舍
第一版 ax 想省事,直接打算用现成的 cron 解析库。但试下来发现,不同库对“星期和日期同时指定时到底是取交集还是并集”的处理不一样,而这个语义差异会直接导致任务触发时间完全错误。
所以最终决定自己实现一个精简版解析器。ax 内部只用标准的 6 段 cron 表达式:秒、分、时、日、月、星期。解析的核心逻辑不复杂:从给定时间起,逐级判断秒、分、时、日、月、星期是否匹配;不匹配就进一位,循环直到找到一个合法时间。这个算法简单,但足够可靠。
func NextRunTime(expr string, from time.Time) (time.Time, error) { spec, err := ParseCron(expr) if err != nil { return time.Time{}, err } t := from.Add(time.Second) for i := 0; i < 100000; i++ { if !spec.Seconds.contains(t.Second()) { t = t.Add(time.Second - time.Duration(t.Nanosecond())) t = t.Add(time.Second) continue } if !spec.Minutes.contains(t.Minute()) { t = t.Truncate(time.Minute).Add(time.Minute) continue } if !spec.Hours.contains(t.Hour()) { t = t.Truncate(time.Hour).Add(time.Hour) continue } if !spec.Days.contains(t.Day()) { t = t.Truncate(24 * time.Hour).AddDate(0, 0, 1) continue } if !spec.Months.contains(int(t.Month())) { t = t.AddDate(0, 1, -t.Day()+1) continue } if !spec.Weekdays.contains(int(t.Weekday())) { t = t.Truncate(24 * time.Hour).AddDate(0, 0, 1) continue } return t, nil } return time.Time{}, errors.New("cron: next time not found") }这里有一个极容易踩坑的点:所有时间必须基于 UTC 存储和计算,展示层再转成业务时区。cron 表达式如果直接按服务器本地时间解析,一旦服务器时区设置不一致,同一个表达式会在不同实例上算出不同的触发时间,这是分布式调度中最隐蔽的故障。
上面的代码里还加了一个 10 万次的循环上限,主要是防止表达式本身无解时陷入死循环。比如0 0 0 31 2 *这种不存在的时间,循环到上限就直接报错。
3.2 触发之前的那把分布式锁
时间轮把任务送到触发队列之后,调度器并不是无脑把任务状态改成READY,而是先加锁。这样做是为了防止多实例抢占。锁的实现我们直接用 Redis 原子命令,比引入复杂框架来的更可控:
SET ax:lock:{taskId} {instanceId} NX PX 3000这条命令的含义是:只有当ax:lock:{taskId}这个 key 不存在时,才能设置成功,并且 3 秒后自动过期。返回值是 OK 的实例才可以继续处理。这个锁虽然简单,但配合数据库状态更新就是一套完整的防重复触发方案。
后来遇到过一个问题:某些任务回调特别慢,业务方反馈明明只配置了一次,却出现了两次执行记录。排查发现是 Redis 锁的 3 秒过期时间到了,而调度实例还在等待数据库更新,另一个实例趁机拿到了锁。解决办法不是盲目延长锁时间,而是把“抢锁”和“标记 READY”做成一个事务性操作:先抢锁,再立刻更新数据库中的任务状态。如果更新成功,锁过期也无所谓,因为其他实例在数据库层已经无法把同一个任务从PENDING改成READY。
3.3 幂等和重试:宁可多跑一次,不能少跑一次
分布式系统里,网络超时、进程重启、消息重复几乎是不可避免的。ax 的触发语义是 at-least-once,也就是“任务至少被触发一次”,但可能被触发多次。这就要求执行器必须做幂等。ax 在触发任务时生成一个全局唯一的request_id,执行器需要把这个 ID 作为业务处理的幂等键。
重试逻辑上也踩过坑。最初设计的是失败后立刻重试,结果下游服务一旦抖动,几千个任务同时打过去,直接把下游打挂。后来改成指数退避重试:第一次失败后等 5 秒,第二次等 25 秒,第三次等 125 秒,最多重试 5 次。这样既保证了任务不会轻易丢失,也给下游留出了恢复时间。
重试不是越多越好。如果任务本身有依赖,比如数据没生成完,重试多少次都没用。ax 里专门给重试设计了一个“可中断”标志,业务方可以在回调结果里告诉调度器“别再试了”。很多事故的根源就是大量无效重试把系统资源耗尽。
4. 用 3 万条定时任务实跑一遍:复现步骤和压测数据
前面讲了原理,但这部分才是真正能让你在自己环境里复现 ax 的完整流程。按照下面的步骤操作,你可以在一台 4C8G 的机器上跑起一个最小可用的 ax 调度集群。
4.1 数据库表结构
先准备 MySQL,创建两张核心表。第一张是任务定义表,第二张是触发实例表。
CREATE TABLE `ax_task` ( `id` bigint NOT NULL AUTO_INCREMENT, `task_name` varchar(128) NOT NULL, `scene` varchar(64) NOT NULL DEFAULT '', `cron_expr` varchar(128) NOT NULL, `handler_url` varchar(256) NOT NULL, `req_params` json DEFAULT NULL, `max_retry` int NOT NULL DEFAULT 3, `timeout_ms` int NOT NULL DEFAULT 30000, `status` tinyint NOT NULL DEFAULT 1, `next_run_at` datetime NOT NULL, `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_scene_status` (`scene`, `status`), KEY `idx_next_run_at` (`next_run_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;CREATE TABLE `ax_instance` ( `id` bigint NOT NULL AUTO_INCREMENT, `task_id` bigint NOT NULL, `request_id` varchar(64) NOT NULL, `trigger_time` datetime NOT NULL, `status` tinyint NOT NULL DEFAULT 0, `retry_count` int NOT NULL DEFAULT 0, `callback_code` int DEFAULT NULL, `callback_msg` varchar(512) DEFAULT NULL, `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_request_id` (`request_id`), KEY `idx_task_id_status` (`task_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;字段说明:ax_task.next_run_at用来启动时恢复调度现场;ax_instance.request_id的唯一约束是整个系统幂等防重的最后一道防线,这个索引绝对不能省。
4.2 启动调度器与接入执行器
调度器是一个独立的二进程:一个进程负责从数据库加载任务并注册到时间轮,另一个进程负责处理时间轮触发的就绪任务。实际部署时用容器跑,启动流程如下。
先修改配置,核心配置是数据库地址、Redis 地址、调度器实例 ID:
server: port: 8901 instance_id: ax-scheduler-01 store: mysql_dsn: "user:pass@tcp(127.0.0.1:3306)/axdb?charset=utf8mb4&parseTime=true" redis_addr: "127.0.0.1:6379" scheduler: tick_interval: 1s wheel_size: 600 max_grab_batch: 500调度器启动时,会做一次“补拉”:把ax_task中status=1且next_run_at <= now+10min的任务全部加载到时间轮里。这里的 10 分钟窗口很关键,既保证任务加载足够及时,又不会让数据库一次性加载过多数据。
执行器接入更简单,业务方只需要提供一个 HTTP 接口。ax 调用该接口时,POST 一段 JSON,里面包含任务 ID 和request_id。业务方处理完业务后,回调 ax 的结果接口,把request_id和成功/失败状态传回来。
{ "taskId": 123, "requestId": "8f1a2b3c4d5e6f7a", "params": { "date": "2024-01-01" } }这样接入一个任务,从写代码到上线跑通,正常情况下半天足够。
4.3 压测结果:不是几个任务能跑通,而是几万个任务不崩
ax 上线前我做过一轮比较完整的压测,这里直接贴数据。测试环境就是一台 4C8G 的裸金属机器跑调度器,Redis 在同一台机器,MySQL 在另一台同机房机器。任务特征是:3 万条任务,cron 表达式各不相同,平均触发频率每 30 秒一次,每条任务的回调用 mock server 模拟,响应时间控制在 5ms 以内。
| 指标 | 结果 |
|---|---|
| 单实例承载任务数 | 30000 |
| 触发成功率 | 99.997% |
| 触发调度平均耗时 | 27ms |
| 触发调度 P99 耗时 | 56ms |
| 时间轮内存占用 | 约 400MB |
| 调度器 CPU 使用率 | 平均 35% |
这轮压测也暴露了一个问题:数据库写入是瓶颈。每个任务触发都要写一条ax_instance,高峰期每秒写入超过 1000 条,MySQL 的压力很大。后来把实例记录的写入改成批量模式,攒够 100 条或 500ms 批量落库,数据库压力立刻降下来了。代价是查询会稍有延迟,但对于调度场景完全可接受。
5. ax 调度常见问题与排查手册
每套系统都有自己典型的“死法”,ax 上线到现在,我整理过一份排障速查表。这里挑几个代表性问题分享,几乎每个都是真实踩过的坑。
| 症状 | 可能原因 | 排查方式 |
|---|---|---|
| 任务没到点就触发 | cron 时区解析错误 | 检查服务器时区,确认 cron 统一按 UTC 计算 |
| 任务到点了不触发 | 任务被误停用 | 查ax_task.status是否为 1 |
| 任务到点了不触发 | 数据库里的next_run_at没有及时刷新 | 查调度器日志,看是否有 SQL 更新失败 |
| 同一个任务触发两次 | Redis 锁过期时间太短 | 调大锁过期时间,同时确认数据库状态更新及时 |
| 同一个任务触发两次 | 执行器回调超时后重试 | 检查request_id是否相同,确认幂等逻辑 |
| 任务大面积延迟 | 数据库批量插入积压 | 查看调度器线程池活跃度和数据库慢日志 |
| 任务失败但不重试 | 超过最大重试次数 | 查ax_instance.retry_count和回调状态 |
5.1 任务没触发,问题往往不在调度器
我最常遇到的一类咨询是“ax 是不是漏触发了”。打开日志一看,任务在时间轮里已经走到触发队列了,但是下游执行器根本没收到请求。原因大多是执行器接口地址配错了,或者执行器服务在发布期间短暂不可用,请求超时后进入重试。
所以排查第一步永远是看ax_instance里有没有生成记录。有记录但状态是READY,说明执行器没消费;没有记录,才需要回过头检查调度器。
5.2 重复执行,先看唯一约束,再想分布式锁
重复执行是分布式调度系统最敏感的故障。ax 里防重复有三层:Redis 锁挡并发、数据库状态机挡逻辑冲突、request_id唯一索引挡最终落库。如果还是重复了,优先查ax_instance里是否有两条相同request_id的记录。
如果有,说明执行器回调或者 ax 的结果处理写库幂等没做好;如果没有,说明是两次不同的触发,那才需要看 Redis 锁是否提前过期。多数情况下问题出在业务执行器自己没做幂等,ax 只是做了触发,它不可能替业务决定哪些操作可以重复。
5.3 大面积积压时的应急处置
任务积压是一种连锁反应:一个下游服务慢了,触发成功率下降,重试任务增多,调度器的回调处理线程被占满,进而影响其他正常任务。ax 的处理逻辑是每个任务有独立的超时时间和最大重试次数,但积压发生时不建议一个个调任务参数。
我的经验是:先停掉次要场景的任务。scene字段在这里发挥了巨大作用,一条 SQL 就能把某个场景的任务全部暂停。等下游恢复后,再按顺序恢复。这种粗粒度的“熔断”比精确控制每个任务更有效,也更容易让人理解。
如果任务本身对时间不敏感,cron 表达式千万别写成每秒触发一次。生产环境见过有人把任务配成
* * * * * *跑数据同步,下游每天被调几百万次。调度器其实无所谓,但下游数据库受不了。定时任务不是越频繁越好,能 5 分钟跑一次的业务,绝不写每分钟跑一次。
5.4 重启恢复别忽略“错过的任务”
ax 重启时,会扫描数据库里next_run_at已经过期但还没执行的任务。此时要注意一个细节:如果错过的时间超过 5 分钟,直接判定为“missed”,不再补跑。为什么?因为任务积压超过 5 分钟,往往意味着系统刚经历过故障,这时候一股脑补跑只会加剧下游压力。
这个 5 分钟的阈值是独立配置的,不同场景可以不同。数据补数类任务可以放宽到 30 分钟,对外接口类的任务建议直接丢弃,宁可少跑一次也不能制造故障窗口。
我个人在后来的运维中最大的体会是:调度器真正考验人的不是数据结构,也不是算法,而是对“异常”的预判。你永远要想清楚时钟跳变、网络超时、重复请求这三个问题,并且把每一层都落在数据库或 Redis 的约束上,而不是靠“我觉得应该不会”。ax 这套设计不一定适合所有人,但只要你也把“触发”和“执行”彻底分开,很多定时任务的痛苦都能提前避免。