
前阵子把内部系统里的任务调度模块彻底重写了一遍项目代号取了个简洁的名字ax。后来同事们都习惯把这套东西称为“ax调度”。它其实没有那么玄乎本质就是一个分布式的任务触发和执行组件负责把“到点该做的事”和“延迟一定时间做的事”可靠地跑起来。但真正落地过程中踩的坑确实不少从最初的单机定时器到后来支持水平扩展、容错、重试的完整调度链路遇到了很多文档里不会写的细节问题。这篇文章我想把ax调度的核心设计、关键实现以及我自己验证过的实操方案完整梳理一遍给正在做类似事情的朋友一个可复用的参考无论你是打算自研调度引擎还是想更深入理解现有框架的底层逻辑这里面的思路都能用得上。1. 为什么会有“ax调度”这个项目1.1 原始痛点和需求梳理重写之前这套系统基本是国内中小团队最常见的做法一个基于Cron的定时任务框架外加几个固定的线程池大部分任务靠写死的cron表达式触发少部分异步任务靠消息队列驱动。最开始任务量不大这套东西跑得还行。但业务量上来之后问题就非常明显了。首先是任务分散在多个服务里没有一个统一的视图某个任务到底跑没跑、跑了几次、耗时多少只能靠各服务自己打日志去翻。其次是服务部署多个实例之后原本的单机调度器在每个节点上都会触发同一个任务被重复执行根本没法控制。再有就是任务失败之后没有统一的重试机制只能靠人工手动重放或者干脆等下一个周期。最后还有一个让我很难受的点想临时延迟一个任务的执行时间比如订单支付后30分钟自动关闭cron不太方便表达这种动态延迟。于是我把需求梳理成两类一类是固定周期的定时任务每天凌晨跑报表、每小时同步一次数据另一类是动态延迟任务用户下单后30分钟未支付自动关单、支付回调后15分钟未收到结果就主动查证。Cron能解决第一类但第二类需要事件驱动的延迟队列这正是压垮原有方案的最后一根稻草。这里我还想强调一个设计认识调度和执行必须分离。调度器只需要负责在正确的时间把任务投递出去真正执行任务的worker可以完全不同。这样调度引擎会很轻业务方只需要注册自己的执行器调度部分完全不关心业务逻辑。ax调度之所以后面能做得比较干净靠的就是这个边界划分。1.2 自研和开源框架之间的选择当时业界已经有不少成熟的调度框架比如Quartz、XXL-Job、ElasticJob我也短暂调研过直接引入XXL-Job。最后没有用并不是因为它不好而是有几个现实考量。XXL-Job功能很全但自带admin后台和DB表结构我们团队希望调度系统保持轻量不想为了一个定时功能引进来一个“重量级平台”。另外我们需要支持“动态延迟任务”这种触发器类型开源框架大多以cron为主二次开发成本其实不低。再加上当时整个团队都围绕Spring Boot加Redis这套技术栈自研一个调度核心的维护成本是可控的。当然这不是说自研一定比开源好。如果团队没有专门的中间件开发人力我反而建议直接用XXL-Job这类成熟方案。我的判断标准很简单任务量在千级别以下、没有太多动态延迟需求、人力紧张直接用开源框架省心很多但如果你面对一堆内生定制需求并且有人力去长期维护自研价值才会显现出来。ax调度属于后者我们当时确实需要高度定制化的能力才走了自研这条路。2. ax调度的核心设计与关键实现2.1 调度模型用时间轮代替数据库轮询设计ax调度时第一个要解决的问题是如何知道一个任务该在什么时候触发。早期方案是每隔几秒扫描一次任务表找出所有“预计执行时间小于当前时间”的任务然后逐个触发。实现确实简单但有两个硬伤一是扫描间隔决定了任务触发的粒度如果每5秒扫描一次任务执行时间误差最多5秒二是任务量上来之后频繁的全表扫描对数据库压力不小纯粹靠轮询撑不起高精度调度。我最终选用的是时间轮算法。可以把时间轮想象成一个圆形表盘表盘被等分成很多个槽位每个槽位代表一个时间间隔。一个指针每隔固定时间tick duration就跳到下一个槽位槽位上挂着的所有“到点任务”被取出来执行。因为指针和槽位都是内存操作调度精度很高也没有数据库轮询的开销。我实现时用的核心参数是tick duration为100毫秒wheel size为512。这意味着指针扫完一整圈需要51.2秒。单层时间轮里放不下超过51.2秒的延迟任务而实际业务里订单超时关单往往要延迟30分钟所以还配套了一个持久化延迟集合。实现思路是任务注册时先放进Redis的有序集合score就是任务的计划执行时间戳当任务剩余时间小于单层时间轮容量时由一个搬运线程把它从ZSet取出并放入时间轮。下面给出一个简易时间轮的核心结构代码方便理解public class TimingWheel { private final long tickDuration; private final int wheelSize; private final long interval; private final QueueTask[] slots; private final AtomicInteger currentIndex; public TimingWheel(long tickDuration, int wheelSize) { this.tickDuration tickDuration; this.wheelSize wheelSize; this.interval tickDuration * wheelSize; this.slots new Queue[wheelSize]; this.currentIndex new AtomicInteger(0); for (int i 0; i wheelSize; i) { slots[i] new LinkedBlockingQueue(); } } public boolean add(Task task) { long delay task.delayMillis(); if (delay interval) { int index (int) ((System.currentTimeMillis() / tickDuration delay / tickDuration) % wheelSize); slots[index].offer(task); return true; } return false; // 超出单层容量交给持久化延迟集合 } }这段代码把核心逻辑做了简化真实工程里还要处理指针进位、多线程安全、槽位遍历等问题。理解思想即可。由于tick是100毫秒任务实际触发时刻最多有100毫秒的误差对于关单、报表这类业务这个精度完全够用。2.2 分布式一致性保证同一个任务只执行一次时间轮解决的是“什么时候触发”但在分布式环境下每个服务节点都有自己的时间轮任务在本地被触发只是“本地触发”。如果两个节点同时触发同一个任务就会重复执行。解决这个问题的标准手段是分布式锁。ax调度里用的是Redis锁加锁的key是任务的唯一标识value是本次触发实例的requestId。核心要求是加锁必须原子解锁必须校验身份。加锁用一条Redis命令完成SET task:lock:{taskId} {requestId} EX 30 NX如果返回OK说明当前实例抢到了锁可以执行任务如果返回失败说明已经有别的节点在触发这个任务当前节点直接跳过。解锁时不能直接DEL否则可能把别的节点持有的锁误删。需要用Lua脚本保证“检查值加删除”的原子性if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end还有一个非常关键的细节锁的过期时间。如果一个任务执行时间超过30秒锁自动过期了另一个节点可能在下个周期又抢到锁去执行造成重复。针对这个问题我实现了一个简单的“看门狗”线程抢到锁之后后台每10秒执行一次续期把锁的过期时间重新设置为30秒任务执行完成之后再主动释放锁。这样基本避免了长任务导致的锁失效问题。锁的粒度也要注意不要用一把全局锁让所有任务串行执行。ax调度里锁的粒度是“任务实例级别”同一个taskId在同一时刻只会被一个节点执行不同taskId之间互不阻塞。从语义上讲分布式系统中“精确一次”执行是非常昂贵的。ax调度追求的是“至少一次”语义同时通过幂等执行器保证业务效果上的“精确一次”。也就是说调度器允许任务被重复触发但业务执行器要做好幂等重复触发时不产生副作用。这套思路比强行保证只触发一次要务实得多。2.3 任务分片与节点负载均衡如果只有一台机器能执行任务性能就会受限。ax调度的做法是把任务按策略分配到多个节点上并行执行。最简单的方式是taskId哈希对节点数取模但节点数量变化时会导致大量任务重新分配。实测下来一旦后面加机器或者有节点宕机取模策略会出现明显抖动大量任务换节点执行同时触发一遍对下游业务冲击很大。我最终改成了一致性哈希。核心思想是把所有节点映射到一个哈希环上任务也映射到哈希环上然后顺时针寻找第一个节点。这样当节点数量变化时只有少量任务会受到影响其他任务仍然落在原节点上。配合虚拟节点机制还能让每个节点上分配到的任务量相对均衡。节点状态靠心跳维护。每个节点每5秒上报一次心跳如果超过15秒没有心跳就认为该节点失联它负责的任务会被重新哈希到其他节点。这里要特别注意任务重新分配时“至少一次”语义会导致任务重新执行所以业务侧的幂等变得更加重要。举一个例子假设有node0、node1、node2三个节点taskId为1001的任务按一致性哈希落在node1上。此时node1失联任务重新分配后落到node2上node2会重新触发这个任务。如果这个任务本身有幂等保护重分配就是安全的没有幂等就会产生业务故障。2.4 失败重试与退避策略任务执行失败后不能直接放弃。ax调度里有完整的重试机制默认策略是最大重试次数3次初始重试间隔10秒每次间隔翻倍10秒、20秒、40秒每次重试间隔增加20%的随机抖动为什么一定要有随机抖动如果没有一批同时失败的任务会在同一时间点集体重试形成“重试风暴”直接把下游服务压垮。这个点我在后面踩坑实录里会单独讲。重试时的幂等设计同样关键。每次重试都带着原始taskId和当前的attempt次数业务侧用这两个字段加上自己的业务唯一键去重。举个例子关单任务重试时订单号就是唯一键如果订单已经处于“已关闭”状态重复执行时直接返回成功不再重复关单。对于重试仍然失败的任务会进入失败队列并触发告警。告警消息会带上taskId、执行节点、失败原因和重试次数方便值班同学判断是立即介入还是等下一轮。3. 实操把一套ax调度完整跑起来3.1 最小的运行环境与组件清单下面按照我自己的实践给出一套最小可用环境足够跑通“定时任务加延迟任务加分布式不重复执行”的完整链路。调度节点至少2个用Spring Boot应用模拟分别占用8081和8082端口Redis用于分布式锁、延迟任务集合MySQL用于持久化任务定义和执行记录可选但建议保留直连测试即可不需要额外网关MySQL不是必须的如果只做纯粹的内存调度Redis就够了。但实际业务中通常需要落库任务定义是人工配置的执行历史也需要查询和排障所以我会建议保留。启动两个节点的原因很简单只有部署了多实例才能验证分布式锁是否生效、故障转移是否正常。单机跑通再多的功能都代表不了生产环境。3.2 核心接口与代码骨架ax调度把“任务”抽象成两个接口任务定义和执行器。public interface Task { String taskId(); long delayMillis(); void execute(TaskContext context) throws Exception; }taskId是全局唯一的任务标识也是分布式锁的keydelayMillis表示延迟执行时间execute放真正的业务逻辑。调度器对外提供两个注册方法public interface Scheduler { void register(Task task); boolean cancel(String taskId); }一个简单的延迟任务注册示例Task closeOrderTask new Task() { Override public String taskId() { return order:close:123456; } Override public long delayMillis() { return 30 * 60 * 1000L; } Override public void execute(TaskContext ctx) { orderService.closeIfNotPaid(123456L); } }; scheduler.register(closeOrderTask);这段代码意思很直接订单123456下单后注册一个30分钟后执行的关单任务执行时检查订单是否已支付未支付就关闭。delayMillis应该根据业务触发时间动态计算比如用户下单时间是14:00:00要求30分钟后执行那delay就是当前时间到14:30:00的差值。如果用户提前支付了就把任务取消掉避免误伤已支付订单。3.3 关键参数配置与计算过程ax调度的核心参数集中在配置中心统一管理ax: scheduler: tick-duration: 100ms wheel-size: 512 lock-expire: 30s lock-renew-interval: 10s heartbeat-interval: 5s heartbeat-timeout: 15s retry: max-attempts: 3 initial-delay: 10s multiplier: 2 jitter-ratio: 0.2这些参数怎么定我简单解释一下。tick-duration和wheel-size决定了时间轮的容量和精度。tick越短调度越精准但CPU空转也会多一些。100毫秒是我反复测试后折中的值正常情况下CPU占用几乎可以忽略。wheel-size等于512时单层时间轮可容纳51.2秒的延迟范围更大的延迟走持久化延迟集合。锁过期时间和看门狗续期时间必须配套锁过期30秒续期每10秒一次允许锁在极端情况下最多有20秒不被续期。如果任务执行超过30秒看门狗能保证锁不失效如果执行线程卡死锁最终还是会在30秒后自动释放避免死锁。重试参数要结合业务容忍度。订单关单这类任务延迟10秒重试可以接受某些实时性要求高的任务重试间隔可以缩短到1秒。所以这些参数尽量做成可配置不要写死。分片数的估算我有个人经验公式预估分片数等于单任务期望耗时除以单分片可并行执行时间。举个例子一次对账任务要处理10万条数据单个分片1秒能处理5000条期望整个任务20秒内完成那么分片数就是10万除以5000再除以20大概10片左右。这个计算不需要很精确给出量级就够。3.4 部署两个节点的验证过程为了验证“分布式不重复执行”我在本地起了两个Spring Boot实例分别向调度器注册同一个延迟任务任务内容很简单打印当前节点名和时间。正常情况下两个节点都会在自己的时间轮里推进这个任务到点后都会尝试获取Redis锁。因为锁的key是相同的taskId最终只有一个节点能抢到锁执行另一个节点加锁失败后直接跳过。我把任务执行逻辑故意写成耗时较长的模拟比如sleep 2秒然后观察两个节点的日志确认同一个taskId只有一个节点打印了执行日志。下面是一次真实运行的日志示意[ax-node-0] 14:00:02.100 taskIdorder:close:123456, lock acquired, start execute [ax-node-1] 14:00:02.101 taskIdorder:close:123456, lock not acquired, skip [ax-node-0] 14:00:04.200 taskIdorder:close:123456, execute finished, release lock接着我kill掉ax-node-0模拟节点宕机再次注册任务会发现任务会被ax-node-1接管执行这就是故障转移。整个验证过程大概20分钟就能跑通关键是看日志和Redis里的锁状态。4. 我踩过的坑和排查实录4.1 时间轮被“慢执行”阻塞第一个坑很典型。上线后的某一天运维告诉我有一批任务延迟了将近半分钟才执行。排查后发现原因很简单我当时把任务的execute逻辑直接放在了时间轮的推进线程里调用而某个任务的执行耗时接近20秒直接把推进线程堵住后面所有槽位的任务全部跟着延迟。修复方案是把execute调用挪到独立的worker线程池里时间轮线程只负责“把任务取出来投递给线程池”然后立刻继续推进。这一点和NIO的事件循环模型很像永远不要在事件循环线程里做耗时操作。这个改动之后同样的任务量调度延迟稳定在200毫秒以内再也没出现过集体延迟。4.2 Redis锁提前过期导致同一任务执行两次虽然加了看门狗续期但还有一种场景会导致重复执行节点发生长时间GC停顿。一次Full GC停顿了40秒超出了锁的30秒过期时间看门狗线程也被GC暂停了没法续期。GC结束之后锁已经过期另一个节点抢到锁重新执行了任务。更糟的是原节点GC结束后并不知道自己的锁已经失效继续执行完整个任务业务上就出现了重复执行的风险。针对这个问题我从两个方向做了加固一是在任务执行核心业务之前再确认一次锁是否仍然由自己持有二是在执行结果回写之前也做一次锁校验如果锁已经丢了就放弃提交结果。这两个“二次检查”在实际工程中很有价值虽然不能完全避免重复但能显著降低危险窗口。4.3 重试风暴打垮下游系统还有一次典型故障某个上游接口出现超时导致大量任务同时失败随后重试机制启动所有失败任务在同一个时间点集体重试。由于重试请求带着相同的尖峰流量下游系统直接被压到熔断。这次之后我把重试策略里的随机抖动提到了第一位每次重试间隔在原基础上加正负20%的抖动同时增加了全局限流器用Redis令牌桶控制每分钟的重试总量。限流不通过的重试任务会暂时留在失败队列里等待下一轮调度。后来我养成了一个习惯凡是涉及大批量任务调度的场景都会主动给任务的触发时间加一个随机初始偏移。比如每天0点跑报表不要所有任务都在0点整触发而是0点到1点之间随机分散这样能大幅降低整点雪崩的概率。5. 常见问题速查与实操心得5.1 问题排查速查表下面把ax调度开发和运维中容易遇到的问题整理成一个速查表遇到问题可以直接按表排查。现象可能原因排查方法解决方案任务不执行任务定义未注册到当前节点检查注册日志确认注册逻辑检查节点配置任务重复执行锁过期或看门狗未续期查看Redis锁的TTL和值配置看门狗缩小锁粒度任务延迟过高时间轮被慢任务阻塞查看时间轮线程堆栈将执行逻辑移入独立线程池节点宕机后任务丢失心跳超时未剔除查看心跳日志调小心跳超时时间加强监控重试风暴退避策略没有抖动查看重试日志时间分布增加随机抖动和全局限流5.2 几条实战心得调度系统最重要的是“轻”。调度器只负责触发不负责执行。一开始我总觉得调度器得能做很多事情结果越做越重后面砍掉一堆能力反而稳定了。调度器本质是“闹钟”闹钟只要准时就够了起来之后干什么事是“人”也就是worker的事。监控比实现更重要。调度系统出故障往往不是“完全不执行”而是“多执行了一次”或者“延迟了10分钟”。如果没有执行日志和指标上报这些问题很难发现。ax调度里每个任务的触发时间、实际执行时间、耗时、重试次数都打了日志还接了监控指标。建议任何做调度系统的团队先把监控补齐再谈功能。幂等是分布式调度的基石。不管用多可靠的锁、多完美的调度算法分布式环境下总会存在极端边界。与其花大力气追求“绝对只触发一次”不如强制要求业务执行器具备幂等能力。调度器保持“至少一次”语义业务侧通过唯一键保证“实际效果精确一次”性价比最高。最后分享一个我在ax调度里最常用也最推荐的小技巧任务触发时间一定要加随机偏移。不管是定时任务还是延迟任务注册的时候都给delay加一个0到10%的随机值让任务在时间轴上散开。这个操作成本几乎为零但能避免掉绝大部分“整点雪崩”和“并发尖刺”问题。我个人做了几百次调度优化之后越来越觉得调度系统的成败不在于某个炫技算法而在于对异常路径的敬畏和对细节的反复打磨。希望这篇ax调度的实战记录能帮你在设计自己的调度系统时少踩几个坑。