起因很简单:团队里的定时任务越来越多,上百个脚本散落在不同机器上,有的挂在crontab里,有的写在业务代码里用time.sleep硬撑,有的甚至靠某台笔记本长期开机来跑。每次任务漏跑,排查都要翻遍所有服务器。后来我们决定自研一个调度平台,项目代号就叫AX,核心目标就一句话:把"到点了该执行什么"这件事收敛到一个系统里,让调度可见、可控、可告警。这篇文章就围绕AX调度的选型、架构、实现、压测和落地过程展开,把我踩过的坑和验证过有效的做法都写出来,给正准备做类似调度平台的团队一个参考。
这不是一篇纯理论文章,所有的模块拆解、参数设计、故障排查都来自实际运行环境。你可以把它当作一份"如果重来一次,我会怎么做"的完整复盘。
1. 为什么一个调度平台值得从零开始写
先别急着上架构图,聊明白"为什么自研"这件事,后面所有取舍都好解释。很多团队一看"调度"两个字,第一反应是上开源方案,这个思路没错,但实际对照过需求之后你会发现,开源工具解决的是通用问题,而我们遇到的痛点恰恰在细节里。
1.1 定时任务散落各处的真实痛点
我们当时的状况是:数据处理任务用 crontab,业务重试任务在代码里自循环,外部接口的定时对账靠一个常驻进程,还有一些任务是让运维手动点的。问题不是单个任务跑不起来,而是它们互相之间没有统一的状态、没有统一的日志、没有统一的失败重试机制。
最典型的一次事故:某个凌晨的数据同步任务跑挂了,因为日志落在那台机器的一个角落文件里,第二天上午十点业务方发现数据不对才来问。从任务失败到被感知,中间隔了十个小时。这类问题反复出现之后,团队达成共识——需要一个东西把"什么任务在什么时候该跑"管起来,并且运行状态要让所有人都能一眼看到。
1.2 开源调度方案为什么不够用
我们认真对比过当时主流的开源调度框架,结论是:单机任务的cron模式太原始,重量级工作流引擎又过度设计,真正符合"定时触发+可观测+轻量"这个定位的开放方案很少。
具体来说,重量级引擎擅长编排复杂的DAG依赖,但我们80%的任务就是简单定时触发,为了20%的复杂场景背上整套DAG引擎,部署运维成本都上去了。而轻量级方案大多只是把crontab搬到Web界面上,缺少我们看重的几个能力:任务执行状态的回传确认、失败后的自动重试策略、以及调度器本身的故障转移。这几个能力恰恰是生产环境最需要的。
1.3 AX调度的边界定义
自研之前一定要划清边界,否则做着做着就变成一个四不像。AX调度的定位非常明确:只负责"到点触发",不负责任务内部逻辑。执行器拿到任务参数后跑自己的代码,跑完通过回调告诉AX成功还是失败,仅此而已。
这个定位带来的好处是:任何语言写的任务都能接进来,只要实现一个HTTP回调接口就行。团队里的Java服务、Python脚本、Go程序,统统不需要改内部逻辑,只需要加一层壳。这也是AX项目能快速落地的关键原因——接入成本极低,大家愿意用。
2. AX调度的整体架构与核心模块拆解
架构设计没有一次到位的,AX经历了从单体到拆分的过程。最初是"调度器+MySQL"两个组件简单粗暴,跑了一段时间后才逐步拆成控制面、调度面、执行面三条主线。
2.1 三条主线:控制面、调度面、执行面
控制面负责任务元数据的管理,也就是任务的新增、修改、启停、删除,以及权限控制。调度面是核心,它根据任务配置算下一次触发时间并派发执行指令。执行面相对独立,它运行在任务所在的机器上,接收调度指令、拉起任务进程、回传执行结果。
三条线之间通过数据库和消息队列解耦。控制面的变更写入MySQL,调度面从MySQL读取任务配置并缓存在内存中,执行面的回调结果通过HTTP接口写回,调度面把执行记录异步写入消息队列,再由一个消费者落库。这样设计的目的是:调度面不直接依赖执行面的可用性,执行面挂了不影响调度器继续计算触发时间。
2.2 核心模块的职责与数据流
AX调度有四个核心模块:触发器、调度队列、任务分发器、结果收集器。触发器负责计算下一次触发时间,到点后把任务放入调度队列;调度队列是一个基于Redis的有序集合,按触发时间排序;任务分发器从队列中取出到期任务,选择合适的执行器节点并发送HTTP请求;结果收集器接收执行器回调,更新任务状态并决定是否需要重试。
这四个模块的职责像一条流水线:触发器只关心"何时",分发器只关心"发给谁",结果收集器只关心"结果如何"。每个模块可以独立扩缩容,调度器多实例部署时,通过分布式锁保证同一时刻只有一个实例在消费调度队列。
2.3 技术选型背后的计算逻辑
选型这块我多花点篇幅,因为很多人在这一步纠结。核心存储用MySQL而不是Mongo或ES,理由是任务元数据是强事务数据,MySQL的ACID特性让任务状态的变更可靠,三张核心表(任务表、执行记录表、节点表)用InnoDB足够扛住单日百万级的调度量。
分布式锁用Redis实现,配Lua脚本保证加锁和设置超时的原子性,没有引入etcd的原因是集群规模不大,为了一个锁再维护一套一致性组件有点浪费。任务队列用Redis的有序集合,每一轮调度时通过ZRANGEBYSCORE取出到期任务,配合ZREM防止重复消费,这套组合在单Redis实例下实测能支撑每秒几千级的调度触发,完全够用。
消息队列我们用的Kafka,用它来解耦"执行结果的写入"和"执行记录落库"。如果对Kafka的运维有顾虑,换成RabbitMQ甚至直接用本地表轮询也能实现,关键思路是不要让结果收集器在回调请求里同步写库,否则执行器回调的RT会拖垮数据库。
3. 调度核心:时间轮定时器与任务状态机
AX调度的定时器没有直接用Linux的cron实现,而是自己在调度器进程内实现了一个分层时间轮。这个决策很重要,直接决定了我们能支持的调度规模上限。
3.1 为什么不用cron自带调度
cron的粒度最小到分钟,这对大多数场景够用,但AX要做秒级调度的时候就无能为力了。更重要的是,cron在每个节点上独立运行,无法集中管理任务的触发状态。用时间轮就不一样,它把时间片切成很细的刻度,指针每走一格就触发该刻度上挂载的任务,想要秒级调度只需把刻度设为1秒。
时间轮还有个额外好处,它的内存占用是固定的,不随任务数量线性增长,因为任务是挂在哈希表里的。创建一个槽位为3600格的秒级时间轮,加上指针转动逻辑,内存占用不过几十KB,却能管理数十万个任务。
3.2 时间轮的设计参数与调度精度计算
AX的时间轮做了两层:第一层60格,每格1秒,走完一轮后进位到第二层;第二层60格,每格1分钟。这种设计叫层级时间轮,避免了槽位过多导致扫描浪费。
精度方面有个反直觉的点:定时器精度不是越高越好。如果把刻度设为100毫秒,调度器每100毫秒就要被唤醒一次,CPU空转的代价很高。实测下来,1秒精度对绝大多数定时场景已经足够,如果业务真的需要毫秒级触发,那应该用消息队列的延迟消息,而不是调度平台硬扛。
调度延迟的控制点在"触发时间与真实执行时间之间的差值"。我们的目标是P99调度延迟小于5秒,为什么是5秒而不是1秒?因为考虑到执行器节点的网络状况、回调的延迟抖动,过度追调度精度没有意义,任务跑到执行器后的实际执行时间才是大头。
3.3 任务状态机的流转与持久化策略
任务状态机是AX调度里最容易写乱的部分。我们定义的状态有六个:待触发、已触发、执行中、执行成功、执行失败、已暂停。状态转换的规则必须写入代码注释,否则三个月后你根本不敢改状态字段。
关键点是状态流转和持久化的配合。调度器把任务从"待触发"改为"已触发"时,必须同时把触发记录写入MySQL,并且更新任务表里的next_run_time字段。这两步操作放在一个事务里,避免出现"任务已触发但next_run_time没有更新"的脏数据。
状态持久化用的是两个字段组合判断:任务表里保存current_status和last_trigger_time。恢复时如果发现last_trigger_time停留在某个节点宕机前的时刻,就把这个时间作为基于下次可触发时间重新计算触发的基准,宁可重复执行一次也不要漏执行——这是定时调度界的黄金法则,尤其是对账、补数据这类任务,重复跑一遍的代价远小于漏跑的代价。
4. 分布式场景下的防重、幂等与故障转移
单机调度器好写,一旦上了多实例,问题就从"怎么触发"变成了"怎么保证只触发一次"。这是AX踩坑最多的环节。
4.1 主从切换时的抢占与防重
AX调度器可以多实例部署,但同一时刻只有一个leader在处理调度队列。leader选举通过Redis的SETNX实现,加锁成功并且设置了过期时间的实例成为leader,其他实例进入待命状态。问题出在leader宕机的场景:如果leader持有锁期间任务执行到一半,锁过期了,其他实例抢到锁后会把同一个任务再触发一次。
解决思路是"触发不等同于执行"。调度器触发的动作只是把任务信息投递给执行器,真正执行权和结果确认在执行器手里。执行器维护一个去重表,以taskId+triggerTime作为唯一键,重复收到的触发请求直接返回"已执行"。这个设计把幂等性控制方从调度器转移到了执行器,更贴近实际业务语义。
4.2 执行器的幂等与结果确认
执行器的回调确认是AX的一个核心设计。执行器收到触发请求后,先写一条"执行中"状态到本地表,然后开始跑任务,任务跑完再调用AX的回调接口上报结果。如果超时没有收到回调,调度器会重新触发一次,执行器根据唯一键判断是重复任务,返回上次结果。
这种"先落库,后执行"的模式有个额外好处:执行器节点如果崩溃重启,可以从本地表里找到未完成的任务,根据自己的业务逻辑决定是继续跑还是标记失败。回调接口本身做了超时重试,回调超过3次仍然失败的话,会把状态置为"结果未知",由运维人工确认。
4.3 故障转移的边界条件
故障转移里容易忽略的是"脑裂"场景。两个实例同时认为自己是leader,同时往执行器发指令,如果执行器没有幂等保护,任务会被执行两遍。我们的兜底方案是:调度器向执行器发请求时带上自己的leader标识,执行器同时只会接受一个leader的指令,如果发现leader标识发生变化,会忽略后续请求直到重新初始化。
但这里必须坦白一个教训:再好的机制也兜不住代码bug。曾经有一次leader切换不彻底,旧leader还残留在内存里的任务缓存没有清掉,导致切完leader后它继续往执行器投递任务,执行器的去重逻辑扛住了大部分,但从那次之后我们把"触发确认"改成"双向握手"——执行器收到触发先回一个ACK,调度器收到ACK才算真正的触发成功,如果ACK丢失就重发。虽然牺牲了一点性能,但系统的确定性大大提高。
5. 压测中的性能瓶颈与优化记录
AX调度上线前做过两轮压测,第一轮结果惨不忍睹,P99调度延迟直接飙到几十秒,一度怀疑架构方向错了。后来定位到几个性能杀手,逐个击破后单机调度能力提升了接近8倍。
5.1 一次调度延迟飙升的完整排查链路
现象是压测到每秒5000次调度的时候,延迟曲线突然变成锯齿状,每隔几秒就出现一个高峰。初步怀疑是数据库慢查询,但看监控列表后MySQL的负载并不高。后来把排查重点放到Redis,发现问题在于分发器每调度一个任务就做一次Redis的ZRANGEBYSCORE + ZREM,这个组合在任务量大的时候会产生大量Redis往返请求,相当于每个任务都浪费了一次网络IO。
优化方案是把每秒到期任务批量取出,整合成一次ZRANGEBYSCORE获取全部到期任务,再通过一个pipeline批量ZREM。这个改动之后,Redis的调用次数从每秒几千次降到每秒几十次,延迟锯齿消失了。这个排查过程让我深刻体会到——性能问题大多数时候不是组件不行,而是访问组件的姿势不对。
5.2 任务堆积的背压机制
压测中还发现一个问题:当执行器节点响应变慢时,调度器仍然在拼命投递任务,任务在队列里越积越多,最终导致调度器的内存持续膨胀。解决办法是给调度队列加一个背压阈值,队列里的待执行任务数量超过设定值时暂停触发新的任务,把流量挡在更上游。
背压阈值不是拍脑袋定的,我们按"队列长度除以单任务平均执行时间"估算出合理的排队等待时长,当预估等待超过30秒时就触发背压。这个参数要结合实际任务量动态调整,设得太小容易误伤高吞吐的正常任务,设得太大又起不到保护作用。
5.3 连接池与线程池的参数调优
调度器是IO密集型应用,线程池和连接池的配置直接影响吞吐。一开始线程池核心线程数设成CPU核数的2倍,压测时任务处理不过来,后来按"每秒任务数乘以单个任务期望耗时"倒推线程数,才把处理能力拉满。
数据库连接池和Redis连接池也踩过坑。MySQL连接池初始设了20个,压测中发现连接不够用导致白等,调到60个之后明显改善。连接池调优有个经验规律:连接数不是越大越好,超过一定量后数据库线程切换反而带来性能下降,要在压测中找拐点。
6. 可观测性:日志、指标、告警如何落地
调度平台如果只是"能用",那和crontab没有本质区别,真正的价值在于出了故障能快速定位。AX的可观测性体系是上线后逐步完善的,核心方向只有三个:每个阶段有日志、每个关键路径有指标、每个异常场景有告警。
6.1 调度平台到底要埋哪些指标
调度平台度量的核心指标是"调度延迟"和"执行成功率"。调度延迟指从任务到达触发时间到调度器发起投递的时间差,执行成功率指执行器回传成功的结果数除以总触发数。
除了这两个核心指标,还需要关注三个辅助指标:调度队列长度反映积压情况,执行器回调超时数量反映节点健康状况,任务状态分布能帮助发现"待触发任务异常增长"这类隐蔽问题。每个指标都打上task_group标签,方便按业务分组筛选。
# 指标名称及其含义 schedule_trigger_total{task_group} # 分组任务触发总数 schedule_trigger_delay_seconds{task_group} # 触发延迟,记录P99 schedule_execute_success_total{task_group} # 分组执行成功数 schedule_queue_depth{node} # 调度队列当前深度6.2 基于日志做请求追踪
日志方面,AX用了一个简单的traceId贯穿全链路:调度器生成触发消息时带上traceId,投递给执行器的HTTP请求头里也带同一个traceId,执行器回传结果时再把traceId带回来。这样一排查问题,从"什么时候触发的"到"执行器什么时候收到的"再到"结果什么时候确认的",一条链路串起来,定位效率提升好几倍。
日志格式遵循"统一JSON、固定字段"原则,所有模块共用一套日志工具库。谁破坏了格式,code review就会被打回,这比在运维层面做日志清洗省事得多。
6.3 告警规则的设计经验
告警规则是踩了"告警轰炸"的坑之后才做好的。一开始我们对失败任务设置了阈值告警,结果某个执行器节点一次宕机触发了上千条告警,运维在半夜被震醒,后来熬不住直接把告警全关了——这比没有告警更可怕。
优化后按严重程度分三档:任务连续失败3次属于P1,立刻通知;执行成功率低于90%持续5分钟属于P2,发工作群;调度延迟P99超过10秒属于P3,汇总到每日报告中。告警一定要给人留出反应时间和行动路径,没有行动路径的告警最后只会被关掉。
7. 上线半年后我总结的几条实操经验
AX调度在团队内部稳定运行了大半年,接入任务从最初的十几个涨到几百个,这段期间遇到过不少想象不到的问题,也积累了比写代码本身更宝贵的一线经验。
最大的教训是:调度平台最怕的不是任务失败,而是失败之后数据状态混乱。一旦执行记录丢失、回调超时、状态机卡在一个中间态,排查成本极高。所以AX的代码里到处是"冗余"的一致性校验——状态迁移时做二次确认,回调结果与触发记录对不上时主动告警而不是静默修正。这种谨慎让系统的代码量多了一些,但换来的是线上的确定性。
第二个体会是调度任务一定要有"暂停"和"立即执行"的能力。听起来很基础,但很多调度系统的暂停只是改了配置,并没有处理当前正在执行的任务。AX的暂停逻辑会等当前执行结束再标记暂停,避免任务被粗暴打断后留下半截脏数据。这可能是小事,但对业务方来说感知极强。
最后想说的是,如果让我再做一遍,我会把"执行器节点的自治能力"前置到架构设计的第一版里。调度器挂了可以快速切换,但如果执行器不了解任务的全貌,做得再好也只是个传话筒。下一阶段AX的演进方向就是让执行器具备一定的本地调度兜底能力,在网络分区或调度器不可达时,依然能按计划拉起任务执行,并在恢复后一键对齐状态。毕竟,对于靠定时任务吃饭的业务来说,到点就绪就是最低的底线。