ax调度这个词最近在圈子里被频繁提起乍一看像个随手的内部代号实际聊下来发现它指的是围绕异步执行Async eXecution思路构建的一整套任务调度方案。我在生产环境里把这套东西从零搭起来过期间踩了不少坑也沉淀了一些真正好用的配置思路这篇就把核心细节和可复现的实操过程整理出来。1. 为什么需要一套专门的调度方案很多业务刚起步的时候定时任务靠数据库轮询就能凑合写个SQL扫状态字段到期就更新。数据量小的时候确实简单粗暴但一旦任务量大起来这种方案的问题会被迅速放大。我当时接手的是一个偏中台的业务系统高峰期每天新增十几万条异步任务包括延迟通知、定时对账、状态超时判定、批量消息推送。用数据库轮询做扫描每分钟扫一次全表单表很快涨到几千万行慢SQL直接把主库拖垮过几次。更难受的是任务执行不具备闭环能力进程重启后丢任务、执行超时没人管、同一个任务被多个worker重复消费这些问题轮流炸。ax调度本质上就是针对这些痛点做的统一处理层把任务从业务代码里解耦出来交给一个独立的调度引擎来管理。它解决的不只是到点执行这件事而是一整套生命周期管理——任务生成、触发、派发、执行确认、重试、告警、人工干预每一步都有明确的落点。如果你也在面临类似场景判断标准很简单你的任务量是否已经让数据库查询变慢或者你是否需要把任务的可靠性和可观测性做起来。如果两个条件任何一个成立就该考虑引入专门的调度方案了。1.1 ax调度解决的核心问题ax调度不是某一个开源组件的代称而是我在项目里落地的一套调度机制的统称核心由三个部分组成延迟任务管理器、时间轮驱动的事件触发模块、以及基于Redis Stream的任务分发通道。这套结构解决的第一类问题是延迟任务精度。比如下单后30分钟未支付自动关单这类任务如果用数据库轮询每隔几秒扫一次匹配时间精度粗、数据库压力大。基于时间轮的实现可以把精度控制在毫秒级别而且不依赖数据库轮询内存扫描的代价低得多。第二类问题是任务不丢失。执行器收到任务后立即确认消费端崩溃也不会导致任务从队列里消失。任务执行结果回传失败时自动进入重试队列配合退避策略避免雪崩。第三类问题是重复执行的风险控制。网络抖动导致结果回传超时调度器可能重新派发任务这时候要求任务本身具备幂等性否则数据会重复入账、重复发消息。我在设计时强制每个任务携带幂等ID并在落地端做去重校验。1.2 选型时的方案对比市面上可参考的方案不少我当时认真对比过几类分布式定时任务组件比如xxl-job这类、消息队列自带延迟消息能力比如RabbitMQ的延迟插件、RocketMQ的定时消息、以及自研调度引擎。第三方定时任务组件适合固定周期、固定时间的调度场景但处理大量随机延迟任务时需要把延迟时间换算成具体的调度时间任务越多任务表越大扫描压力指数上升。消息队列的延迟消息能力天然适合到点消费完全不占数据库资源但它在需要动态取消任务、批量查询状态、定时补偿的场景里比较别扭。最后我选择了自研轻量调度引擎一方面是因为上述原因另一方面是当时的团队规模不大引入一套重框架的学习和运维成本远高于自研一套针对性的方案。自研并不意味从零发明轮子而是把成熟组件组合起来在关键路径上做精细化控制。2. 核心代码设计与关键机制详解ax调度整体的数据流向是这样的业务方通过SDK把任务投递进来调度器把任务先落Redis同时注册到时间轮。时间轮回旋到目标时刻时把任务ID推送到待处理队列。执行器消费到任务后执行执行完成回传状态调度器更新任务状态并记录结果。任务状态的流转是我最花心思设计的地方。最初我用的是两个状态待执行、已完成后来实际跑起来发现这条路走不通。网络超时、业务异常、执行器宕机所有这些异常情况都会让状态流转变得混乱。最后我把状态细化为待执行、延迟中、派发中、执行中、执行成功、执行失败、已取消、已超时八种每个状态之间的转换约束得死死的。比如待执行只能流转到延迟中或已取消延迟中的任务到达时间后才能变成派发中。派发中的任务如果执行器已经领取就变成执行中。执行中在规定时间内没有回传结果自动判定为执行超时进入失败分支进行重试。这样设计的好处是任何时候我都能明确回答一个问题这个任务现在在哪一步。排查问题不再靠猜。2.1 时间轮调度触发原理时间轮的核心数据结构是一个环形数组每个槽位放一个任务链表一个指针按固定频率跳动跳到一个槽就把这个槽上的所有任务取出来执行。数组长度1000跳动精度10毫秒整个轮盘走一圈正好10秒。任务插入时间轮的时候先计算当前时间到目标时间的时间差换算成需要跳动的槽位数然后挂到对应的槽位链表中。这个计算有个关键细节时间差可能超过一圈需要记录圈数。轮盘指针每经过一次槽位检查任务记录的圈数圈数为0才真正取出执行否则圈数减1。我遇到的一个典型问题是明明时间轮精度是10毫秒为什么任务触发时刻误差到了几百毫秒排查后发现是延迟任务队列的消费者被前一个任务的耗时阻塞了。时间轮本身没有问题问题出在任务提交给后续队列的环节是串行的。改成提交动作放入独立线程池后误差稳定在了20毫秒以内。2.2 任务分发与幂等消费任务分发用的是Redis Stream核心原因是它在消费确认机制上做得比较完整。生产端把任务ID和参数写入Stream消费端用消费组读取读取成功但处理失败时不会自动确认数据留在pending列表中重置后可以再次消费。消费端的核心逻辑Component public class AxTaskConsumer { Autowired private ExecutorService executorService; Value(${ax.task.threads:16}) private Integer taskThreads; private final String streamKey ax:task:stream; private final String groupName ax-task-group; PostConstruct public void init() { // 初始化消费组 try { stringRedisTemplate.opsForStream().createGroup(streamKey, groupName); } catch (Exception e) { // 消费组已存在时会抛异常忽略即可 } executorService Executors.newFixedThreadPool(taskThreads); // 启动消费线程 for (int i 0; i taskThreads; i) { Thread worker new Thread(this::consume, ax-task-worker- i); worker.setDaemon(true); worker.start(); } } public void consume() { while (true) { try { ListMapRecordString, Object, Object records stringRedisTemplate.opsForStream().read( Consumer.from(groupName, consumer- Thread.currentThread().getName()), StreamReadOptions.empty().count(16).block(5000), StreamOffset.create(streamKey, ReadOffset.lastConsumed()) ); if (records null || records.isEmpty()) { continue; } for (MapRecordString, Object, Object record : records) { processTask(record); } } catch (Exception e) { log.error(任务消费异常, e); try { Thread.sleep(500); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); return; } } } } private void processTask(MapRecordString, Object, Object record) { String taskId record.getValue().get(taskId).toString(); String taskType record.getValue().get(taskType).toString(); String payload record.getValue().get(payload).toString(); try { // 业务方注册的回调按类型分发 TaskHandler handler handlerRegistry.get(taskType); if (handler null) { log.error(未找到任务处理器, taskType: {}, taskType); return; } handler.execute(payload); // 确认消息已消费从pending列表移除 stringRedisTemplate.opsForStream().ack(streamKey, groupName, record.getId()); // 更新任务状态为执行成功 taskStateManager.markSuccess(taskId); } catch (Exception e) { // 不确认消息让它留在pending队列等待后续处理 log.error(任务执行失败, taskId: {}, error: {}, taskId, e.getMessage()); taskStateManager.markFailed(taskId, e.getMessage()); } } }这段代码里最关键的地方是任务执行成功才ack执行失败不ack。不ack的消息会一直留在pending列表里配合一个定时任务去扫描pending列表判断超时后重新加入消费。消费端还加了一道幂等防线每个任务在Redis里存一个执行标记用SETNX命令保证成功执行且标记存在的任务不会被再次执行。比如消费者A执行任务花了两秒期间网络异常导致消费者B也拿到同一个任务B发现执行标记已存在直接丢弃。判断任务状态之前先走幂等检查这个习惯帮我在很多次网络抖动中保住了数据一致性。2.3 任务重试与超时控制任务执行失败后的重试策略我采用的是分级退避算法。默认最大重试次数是5次每次重试的间隔时间依次是5秒、30秒、2分钟、10分钟、30分钟也就是说从第一次失败到最终放弃最长等待时间是42分钟37秒左右。这个退避间隔的设定是经验值。太激进会让系统在异常高峰期反复对下游发起无效请求反而加剧故障太保守又会让任务迟迟无法成功影响业务时效。按这个档位配下来大多数短暂故障都能在30分钟以内被重试解决剩下还没成功的说明是持续性问题人工介入更合适。重试参数的配置示例ax: task: max-retry-count: 5 retry-intervals: [5s, 30s, 2m, 10m, 30m] execution-timeout: 120s queue-warn-threshold: 10000 queue-error-threshold: 50000execution-timeout是单次任务的执行超时时间超过这个时间没有回传结果调度器把任务标记为执行超时。这个超时不能设太短一些批量处理任务本身就耗时较长我压测过90%的任务执行耗时在5秒以内99%的任务在60秒以内因此设了120秒作为兜底。这里有个容易被忽略的坑如果任务执行耗时接近超时时间会出现任务刚好做完但被判定超时的情况。我的解法是在执行器内做一次心跳续约任务执行超过90秒时向调度器发送续约请求把超时时间再延长60秒。实际上线后这种机制极少触发但真触发时能避免大量本应成功的任务被误判。2.4 任务去重与并发控制任务去重解决的是同一个业务事件因为重复提交导致的任务重复创建。比如用户疯狂点击支付按钮理论上应该只创建一个关单任务。我的做法是定义业务唯一键在任务创建时用SETNX写入Redis写入成功才允许创建任务已经存在唯一键的直接丢弃重复请求。具体实现public String createTask(String bizKey, String taskType, long delayMillis) { String taskId UUID.randomUUID().toString().replace(-, ); Boolean success stringRedisTemplate.opsForValue() .setIfAbsent(ax:biz:unique: taskType : bizKey, taskId, Duration.ofHours(24)); if (!Boolean.TRUE.equals(success)) { return null; // 重复任务直接丢弃 } // 记录任务信息注册到时间轮 Task task buildTask(taskId, taskType, delayMillis); taskRepository.save(task); timerWheel.addTask(task); return taskId; }并发控制针对的是执行侧的影响。同一类型的任务在某一瞬间集中爆发时如果不做控制十几万个任务同时涌入下游接口下游大概率被打挂。我在执行器这边引入了信号量机制每个任务类型最多同时运行指定数量的任务。比如通知类任务的最大并发是50对账类任务的最大并发是20超出的部分在内存队列里排队等信号量释放。信号量是JVM级的多实例部署时每个实例单独控制。如果要做全局限流需要配合Redis实现分布式信号量但分布式信号量有个麻烦获取信号量超时会阻塞任务线程需要设计好超时策略。我当时对并发要求没那么极端JVM级控制已经够用这里可以根据实际场景取舍。3. 生产落地实战与压测数据理论设计得再完善最终还是要经历生产的考验。这一节记录我从搭建到上线全过程的实测数据、参数调整过程和真实问题。3.1 环境清单与组件选型ax调度引擎生产环境的依赖组件如下组件版本用途JDK8运行环境用了线程池和CompletableFutureRedis6.2Stream队列、去重存储、时间轮状态共享Spring Boot2.6.x业务应用框架SDK接入Apollo/Nacos任一配置动态调整Redis的持久化策略我选了AOF设置appendfsync为everysec。这个模式下持久化性能损耗可控故障时最多丢一秒的数据。调度系统对数据一致性要求较高纯RDB模式在宕机时可能丢分钟级数据无法接受。3.2 容量估算与线程池配置线程数不是拍脑袋定的我做了一组压测来推算。环境是单台8C16G的普通云服务器Redis单独部署。模拟场景是每秒新增2000个任务延迟范围从1秒到30分钟不等执行端是本地模拟处理器每个任务处理耗时约50毫秒。实测下来单消费者组的处理吞吐是每秒4200个任务。要让系统的处理能力大于任务产生速率至少需要每秒处理2000个以上的任务按单消费者每秒4200算一个消费组就够了。但生产环境要留冗余我最终开了两个实例每实例16个线程总处理能力达到每秒13000多个完全覆盖生产峰值。线程池配置方面核心线程数不能等于最大线程数。我设了核心16、最大32队列容量5000。队列满后新增线程处理但是如果持续满负荷队列里的任务等待时间会越来越长。监控参数是队列等待时长的P99超过1秒就要考虑扩容了。3.3 上线后的压测记录压测分了三轮。第一轮每秒1000个任务延迟分散在5秒到60秒之间。运行30分钟任务全部按时触发触发误差的P99是18毫秒整体没有出现积压。这一轮主要验证时间轮和队列的基本链路。第二轮每秒5000个任务延迟时间混合了秒级和小时级。运行1小时发现一个问题任务进入时间轮后如果延迟是小时级的会等待非常长的时间才被触发期间Redis里缓存的这个任务一直没有后续操作。这个设计本身没问题但让我注意到一个隐患——大量长延迟任务积压内存中时间轮槽位链表越来越长触发的效率会下降。优化方式很简单对超过10分钟的长延迟任务不放入内存时间轮而是直接写入一个延迟任务表由扫描线程在目标时间前30秒才放入时间轮。这样既保证大延迟任务的精度又避免内存中堆积过多无关任务。第三轮每秒8000个任务时长压测2小时同时模拟执行端异常和Redis网络抖动。结果发现了一个之前没有覆盖到的场景执行器执行中的任务在Redis网络恢复后状态一直停留在执行中因为我只扫描了pending队列没有扫描执行中超时的任务。后来补上了一个状态巡检线程每30秒扫一次所有状态为执行中的任务超过execution-timeout的强制标记为超时并进入重试。3.4 集群部署与高可用配置调度器虽然是自研的但高可用的思想是唯一的没有单点。时间轮组件运行在两个实例上正常情况下只有主实例的时间轮在工作备实例通过心跳感知主的存活状态一旦发现主失联超过30秒备实例自动抢占接管全部时间轮任务。这种主备切换有个细节备实例接管时时间轮里挂着的任务状态需要重新校准。我的做法是Redis里存一份所有活跃任务的快照备实例接管后先从快照里恢复所有未完成的任务再重新注册到自己的时间轮。快照的写入时机是任务每一次状态变化所以恢复的延迟最大就是上次状态变化的时间点几乎无损。Redis自身的可用性也需要考虑。我当时用的是主从加哨兵的模式故障时自动切换。Redis短暂的不可用会让任务派发暂停但不会丢数据因为Stream里的消息在Redis恢复后可以继续消费。真正要警惕的是Redis挂掉超过AOF刷新周期的情况这个场景下要接受最多一秒的数据丢失。4. 常见问题与排查技巧实录这一节说几个真实遇到的坑和排查思路这些经验在文档里基本找不到。4.1 任务延迟触发误差突然变大现象原本P99误差20毫秒以内上线一段时间后P99涨到了200毫秒以上。排查过程先看时间轮的指针跳动是否正常再查提交到Stream的耗时都正常。最后发现是消费线程池里的Worker被一个慢任务阻塞了。某个任务类型执行了3秒期间该Worker占用的线程一直没有释放造成后续任务排队。解决方案把任务消费和任务执行拆成两个线程池。消费线程只负责把任务从Stream读出来丢进执行线程池执行线程池负责真正跑业务逻辑。消费线程永远不阻塞延迟误差就稳定了。设置执行线程池时要注意拒绝策略我当时用的是CallerRunsPolicy线程池满的时候新任务由提交线程执行保证任务不丢失不过提交线程会被阻塞这个取舍需要根据业务场景来定。4.2 任务莫名重复执行现象下游收到重复的推送消息查日志发现同一任务被执行了两次。排查过程第一反应是Redis Stream的重复消费问题。确认Redis网络发生过抖动后pending列表里有多个未确认消息被重新投递了。但我加了幂等标记为什么幂等没拦住最终定位幂等标记的过期时间是24小时而某条任务延迟了30小时后才被执行前面的临时标记已经过期导致重复执行时SETNX成功幂等判断失效。解决方案幂等标记的过期时间改为比任务最大生命周期更长的时间并且对于特殊的长延迟任务单独设置延长的幂等键。4.3 时间轮心跳线程CPU飙高现象实例的CPU使用率平稳运行一段时间后突然飙升到80%以上。排查过程用top定位到某个线程CPU异常线程名是time-wheel-ticker。打印线程堆栈后发现时间轮的指针跳动在空转时没有做休眠而是用极短的时间间隔循环导致CPU空耗。原因时间轮跳动的实现是循环中调用Thread.sleep(10)但Java的sleep精度在Windows和Linux上表现不一样Linux上实际可能只有1~2毫秒的精度。当槽位为空时CPU白白空转了大量循环。解决方案改为在指针跳动后优先计算下一个非空槽位的剩余时间并用LockSupport.parkNanos精确暂停到需要跳到的时刻。CPU占用从80%降到了3%以内。这个问题的教训是任何循环轮询的轮子都必须考虑空转时的CPU占用而不是简单依靠sleep来控制节奏。4.4 任务积压告警设置积压问题的反馈链路必须提前做好。我设置了两级告警任务堆积数超过10000时发WARN级别告警超过50000时发ERROR级别告警并触发扩容流程。排查积压问题时核心是定位瓶颈在消费侧还是执行侧。消费侧瓶颈的典型特征是Stream的pending数量疯狂上涨但消费线程CPU很低执行侧瓶颈的特征是执行线程池队列持续占用率高线程池任务等待时间超过几百毫秒。区分后对症处理消费侧瓶颈就加消费者实例或加消费线程数执行侧瓶颈就对慢任务做拆分、优化处理器性能或者临时调高的最大线程数快速消化积压。4.5 时间不准确导致的任务触发错乱在多实例部署时服务器之间的时钟如果不同步任务时间判断可能出现偏差。比如实例A认为时间到了实例B还差500毫秒如果A和B都在消费同一个时间轮的槽位任务可能会被提前触发或延后触发。解决方案有两步一是在生产环境配置NTP时间同步保证各实例时间误差在几十毫秒以内二是任务触发时不依赖实例本地时间而是以Redis的时间为参考或者任务里记录目标触发的绝对时间戳执行前再次校验时间戳已经超过触发时刻的任务直接执行未到时刻的重新放回时间轮。第二步这个二次校验非常关键它把触发时机从依赖某一个实例的时钟变成依赖任务自身的时间戳从根上规避了时钟漂移的问题。5. 使用心得与几点补充建议ax调度这套机制我核心是围绕三个点来设计的高可靠性、精确延迟、可观测。整体运行半年多的时间里任务触发成功率稳定在99.99%以上重试机制兜住了绝大多数瞬时故障因为调度系统本身的问题导致的数据异常几乎没有。如果有人想在类似场景里落地这套思路我的建议是不要一上来就追求所有功能完美先跑通最小闭环时间轮加Stream加幂等标记三个组件加起来就能覆盖大部分日常场景。如果生产环境对精确延迟要求没那么苛刻甚至时间轮都可以用Redis的延迟队列替代简单可靠优先。还有一点想强调的是调度系统看起来简单核心就一句话到点干活但真正到了生产环境考验你的全在异常分支。任务重复了怎么办执行挂了怎么办Redis挂了怎么办下游慢了怎么办这些问题如果不在设计阶段就想清楚上线后就是不停救火。把状态机和重试策略设计成栅栏一样清清楚楚其实收益远大于花哨的功能。这个方向后续还可以扩展的点比如任务编排多个任务依赖关系的拓扑执行、定时时间表的可视化配置、以及和指标监控系统打通后的自动扩缩容。我目前正在做的是第一个方向到时候有新经验再开一篇分享。