
1. 为什么放弃现成调度框架自己撸了一套ax调度事情要从一次线上事故讲起。团队当时做的是一个面向C端用户的权益发放系统每逢整点活动、会员日、大促系统要一次性触发几万个定时任务。原来的方案是拿现成的分布式任务调度框架顶上结果活动刚开始三分钟任务积压报警就红了。打开后台一看调度器把任务一批批地丢给执行器执行器还在处理前一批新的任务又压过来了数据库连接池直接被打满。更离谱的是因为任务触发时间精度只有秒级有些需要严格按时间顺序执行的权益分发先后顺序全乱套了用户端出现了先领到的新人券覆盖了满减券这种事故。那段时间我们几乎天天在线排查。后来把现成框架的源码翻了个底朝天发现问题不在对方的代码质量上而是它和我们业务场景根本不匹配。我们要的是高吞吐、精准触发、延迟可控的纯调度能力而现成框架往往把自己绑定了一整套分布式协调组件调度和执行器的通信用的是长连接心跳任务量一上来心跳报文本身就把带宽吃掉了大半。所以当时拍板做了一件在很多人看起来没必要的事自研一套调度引擎代号就叫 ax。ax 的设计目标很简单不搞花活不做完整的工作流编排只解决一件事——把什么时间执行什么任务这个决策做得足够快、足够准、足够扛压。现在回头看这个决定是值得的。这篇就把 ax 调度核心部分的设计思路、踩坑经历、调优参数一块儿端出来给想自研或者正在为调度系统头疼的朋友一个参考。先说清楚 ax 的适用边界。它适合的典型场景包括定时赠券、定时解锁、延迟关闭订单、定时给用户推送站内信、定时刷新缓存等。它的不适合场景也很明确需要DAG编排、需要人工审批环节、需要复杂重试策略的任务流找专业的流程引擎更靠谱。把调度和流程分开这是 ax 能够把调度延迟压到毫秒级的前提。2. 核心模型拆解任务抽象、延迟队列和时间轮的三层配合2.1 任务模型的抽象一个任务到底该存哪些字段很多自研调度系统把任务表设计得过于简单一个task_id、一个trigger_time、一个status就完事了。跑一段时间就会发现加需求的时候全是坑。比如你想知道这个任务被哪个节点抢走了、抢了几次、上一次执行成功没有表里根本查不出来。ax 的任务模型是经过至少三轮迭代之后定下来的核心字段如下task_id全局唯一ID不用自增用雪花算法生成避免多节点生成冲突。task_type任务类型对应不同的执行器处理器。trigger_time计划触发时间精确到毫秒这是调度的核心依据。status任务状态机用枚举值表示CREATED、DELAYED、READY、DISPATCHING、RUNNING、SUCCESS、FAILED、CANCELLED。retry_count已重试次数。max_retry最大重试次数。callback_url或processor_name任务执行的处理方式二选一。payload业务数据快照比如优惠券模板ID、用户ID、商品SKU。last_run_node上一次执行该任务的节点标识。priority优先级。timeout_ms任务执行的超时时间。这里有个比较重要的点状态里一定要有DELAYED这个中间态。它表示任务已经进入调度队列但还没到触发时间。把状态拆细调度过程中任何一步出问题你都能判断卡在哪个环节了而不是两眼一抹黑。任务表里字段多了对应的索引设计就要跟上。ax 的索引设计经验是不要建太多联合索引写多读多的表索引越少越稳。我们就保留两个核心索引一个是(trigger_time, status)用于扫描到期任务一个是(task_type, status)用于执行器长轮询拉取任务时快速定位。-- 核心索引设计示例 CREATE INDEX idx_time_status ON scheduled_task (trigger_time, status); CREATE INDEX idx_type_status ON scheduled_task (task_type, status);2.2 延迟队列与时间轮为什么不直接用数据库扫表早期版本踩过一个典型的坑用一个定时任务每隔200毫秒去数据库扫表SELECT * FROM scheduled_task WHERE trigger_time NOW() AND status CREATED。5000个任务以内数据看着还行等任务量到了十万、百万级那这个扫表SQL的延迟和数据库IO开销根本扛不住。每次扫表都要全量扫索引任务量越大扫描耗时越长等扫描完的时候一批到期任务已经被延迟了好几百毫秒。ax 改成了多层队列配合的触发模型第一层是内存时间轮负责毫秒级精准触发只承担当前时间点前后几秒内即将到期或已经到期的任务数据量非常小。第二层是延迟队列负责承载未来一段时间内要到期的任务时间轮空了就去延迟队列拿数据。第三层才是数据库持久化所有任务数据但是数据库只负责落库和分钟级兜底扫描不再承担高频触发职责。时间轮把判断一堆任务该不该现在执行从O(n)的扫描变成了O(1)的数组下标定位。具体实现不复杂用Netty的HashedWheelTimer思路或者自己用DelayQueue加一个循环线程也行。我这边用的是嵌套时间轮// 简化版时间轮实现思想 public class TimingWheel { private final int tickDuration; // 每个格子代表的时间跨度例如 10ms private final int wheelSize; // 格子数量 private final AtomicInteger currentTick new AtomicInteger(0); private final QueueTaskWrapper[] slots; public void addTask(TaskWrapper task, long delayMs) { int ticks (int) (delayMs / tickDuration); int slotIndex (currentTick.get() ticks) % wheelSize; slots[slotIndex].offer(task); } }任务到期时由时间轮的指针拨到对应的槽位把槽位里的任务取出来放到一个DispatchQueue然后由独立的线程池提交给对应的执行器。整个触发路径上没有任何一个环节需要遍历大量数据所以任务量翻十倍触发延迟也基本稳定。2.3 任务状态流转一个任务从创建到完成的完整生命周期这里把 ax 任务状态机的流转图画不出来编码层面就是状态枚举加一个状态机的校验类但逻辑必须说清楚一个任务从 API 接入开始写入数据库状态是CREATED。紧接着调度组件判断触发时间如果触发时间在当前时间之后状态变成DELAYED任务被同时写入时间轮如果触发时间比较近或者延迟队列如果触发时间比较远。如果触发时间已经过了状态直接变READY进入分发流程。到点之后时间轮把任务弹出来调度器把任务提交给执行器状态变成DISPATCHING表示已经交给执行器了在等待执行器确认。执行器接受到任务后反馈一个ack状态变RUNNING。执行完成后根据结果置为SUCCESS或者FAILED如果失败且剩余重试次数大于0任务会重新回到DELAYED并且计算出下一次的触发时间通常带指数退避。如果需要人工介入置为CANCELLED。这个状态机的容错关键是DISPATCHING状态不能一直卡死。如果执行器挂了任务会一直停在DISPATCHING。所以 ax 里加了一个心跳超时重派机制任务进入DISPATCHING之后如果超过timeout_ms还没有变成RUNNING调度器认为派发失败任务重新变回READY再尝试派发给另一个执行器。3. 分布式调度细节锁策略、节点选择与重复执行防护3.1 数据库行锁还是Redis分布式锁最终选择了各司其职自研调度系统的核心难点不在到点触发而在同一个任务在多节点同时运行的环境下会不会被重复执行。ax 一开始用过 Redis 分布式锁用SET key NX EX给任务ID加锁谁抢到锁谁执行。看着没毛病但后来发现一个问题Redis 锁的粒度不好控制。如果 Redis 本身发生主从切换锁信息可能还没同步到新主节点新主节点上另一个调度器就能抢到同一个任务的锁重复执行就这么产生了。后来我们换成了数据库行锁 Redis 预判锁的混合方案。具体做法是所有调度器节点在分发任务之前先更新任务行的状态。用一条带条件更新的SQL只有状态是READY或DELAYED的任务才允许改成DISPATCHING。同时更新last_run_node为自己的节点ID这样谁抢到了、在哪个节点上执行全都有据可查。Redis 锁仍保留但用途变了。它不负责任务唯一性只负责抢任务的资格。多个节点同时去更新数据库行状态时Redis 锁保证只有拿到锁的节点才发起那条 update SQL减少行锁竞争。-- 核心抢单SQL保证幂等 UPDATE scheduled_task SET status DISPATCHING, last_run_node #{currentNode}, updated_at NOW() WHERE task_id #{taskId} AND status IN (READY, DELAYED)Update 影响行数为1就说明这个任务被当前节点合法抢到了。影响行数为0说明被别人抢走了这个任务就不需要再往执行器投递了。这一套下来重复执行的概率被压到了非常低——因为不管 Redis 锁怎么抖动最后一道判定标准永远以数据库行的状态变更结果为最终结论。3.2 调度节点如何选择一致性哈希与负载因子的取舍调度节点拿到一批到期任务之后要把任务派发出去派发给谁最省事的办法是随机派发或者轮询派发。轮询适合任务执行耗时长且节点性能一致的场景。但实际业务里执行器节点的硬件配置经常不一样有些跑在8核机器上有些还是4核。轮询派发会把耗时长的任务发给慢节点快节点闲得慌慢节点忙不过来。ax 采用了一种带权重的节点选择策略。每个执行器节点在注册时上报自己的可用线程数、队列积压数调度器节点维护一个节点健康列表然后通过加权随机的方式选择执行器节点。权重计算公式大致是weight 可用线程数 / (当前积压任务数 1)积压任务数越高权重越低被选中的概率越低。这个方案比一致性哈希更适合调度系统因为任务没有亲和性需求——一个任务发给哪个执行器都一样不需要同一个任务必须发往同一节点。一致性哈希主要适用于有状态服务的路由调度系统的任务是天然无状态的用加权随机更合适。派发链路的时间特别重要。调度器与执行器之间的通信协议选型上最开始走的是 HTTP 调用一次 RPC 的耗时在局域网内大约 2-5 毫秒。任务量大了之后HTTP 连接本身的开销也不小后来改成了repurposed 模式就是执行器维护一个到调度器的长连接调度器不主动调执行器而是把任务放入执行器待拉取队列执行器基于长轮询主动来拉取任务。这样调度的网络开销从每次2-5毫秒降到了 0.2 毫秒以内而且调度器对执行器的健康状态感知更实时了。3.3 时钟不一致问题调度系统最隐蔽的坑分布式调度系统有一个巨坑新人在设计时几乎都会忽略——节点时钟不一致。如果你的调度器节点A的时钟比节点B快了几秒那么任务的实际触发时间在A、B两个节点看来就不同了。A认为任务已经到点B认为还没到。如果A把任务抢过去『提前执行』了用户端的行为就早了那么几秒反过来如果时钟慢的节点抢到了任务任务就延迟了。ax 的应对策略是双管齐下所有调度节点统一启用 NTP 时间同步并且监控脚本定时检查各节点的时间偏移量超过50毫秒就告警。兜底策略任务触发时以数据库时间为准不以节点本地时间为准。调度节点从数据库取到期任务时SQL 条件用的是数据库函数NOW()而不是在代码里计算好时间再传参。这样即使节点时钟有偏差判定是否到期的最终标准始终是数据库那台机器的时钟从根源上消除了节点时钟漂移带来的影响。-- 使用数据库时钟判定任务到期 SELECT * FROM scheduled_task WHERE trigger_time DATE_SUB(NOW(), INTERVAL 1 SECOND) AND status IN (READY, DELAYED) LIMIT 200;4. 上线后的真实踩坑排查过程三个典型问题的完整链路4.1 问题一任务大量堆积但执行器明明很空闲ax 上线的第二周运营反馈一条链路晚上八点的定时推送任务到了八点十分还没推完。排查时先看监控大盘调度器这边显示一切正常任务已经全部派发到执行器了。打开执行器监控一看线程池的活跃线程数很低几乎都在WAITING状态。这个现象相当反常。一开始怀疑是线程池配置有问题翻配置发现核心线程数、最大线程数都合理。后来在代码里加了一条日志发现执行器拉取任务的线程在调用blockingDeque.take()时明明队列里有任务却经常一停就是几秒钟。问题定位到了执行器和调度器之间的通信协议上——当时用的是 HTTP 长轮询每个执行器节点会定期轮询调度器的待派发队列但轮询的超时时间设置得太短了导致在执行器刚发出一次轮询请求还没拿到响应的窗口期调度器把新任务放进了队列而执行器这边却进入了一个短暂的等待间隔。这个间隔在单个任务上毫秒级别但当任务总数上万时累积延迟就非常明显。修复方案把轮询改成了持久长连接并且执行器拉取任务时带上当前已拉取任务数的标记让调度器能按需返回——如果任务确实没有连接保持空转等待一旦有新任务进来立即通过连接推送过去。改完之后相同量级的任务堆积延迟从十分钟级别降到了秒级。4.2 问题二任务偶发重复执行而且无法稳定复现重复执行是调度系统最不能忍的问题。有个用户反馈同一天收到了两条内容完全一样的推送。排查组花了好几轮先从任务表里筛这个用户的记录发现确实有两条task_id不同的任务但是 payload 一模一样触发时间也相同。继续往上查发现这个任务的来源是上游订单创建服务。订单服务在处理回调时因为网络超时重试机制把同一个创建请求投递了两次。也就是说重复执行不是 ax 调度器的问题而是任务生产方的幂等没有做好。ax 把同样的 payload 当成两个独立任务这本身就是正确的行为问题出在上游没有生成业务唯一键。这个问题的根因很有意思也很典型。调度系统能保证同一个任务只执行一次但无法保证同一个业务操作只产生一个任务。排查到最后整改落在上游生产方根据用户ID和活动ID生成biz_key入库前去重同时 ax 这边加入了任务指纹校验——同一task_type下如果两个任务的payload相同且触发时间相差不到5秒后一个直接丢弃。这两层防护加完类似问题再也没有出现过。这次排查的教训是重复执行的锅大概率不在调度系统本身而在于任务生产方的重试机制缺少幂等设计。4.3 问题三慢任务拖垮了整个调度线程池调度系统的另一个经典问题一个任务执行超时把线程池的线程全占住了其他任务全部排队。早期 ax 的执行器线程池用的是ThreadPoolExecutor核心线程10个最大线程20个队列用的是无界队列。某个外部接口响应变慢平均耗时从100毫秒涨到5秒结果20个线程全被这个慢接口的任务占着其他所有类型任务全部积压。这个坑的教训是调度系统的执行线程池必须做隔离。ax 现在把执行器线程池拆成多个独立线程池核心做法是按任务类型分组high_priority_pool处理高优先级任务比如退款触发、支付回调补单核心线程数8。default_pool处理普通定时任务比如推送、短信核心线程数16。slow_pool处理耗时任务比如报表生成、批量对账核心线程数8。每个线程池的队列都改用有界队列队列满了走拒绝策略触发拒绝之后任务回写数据库等下一轮调度重新派发。这个设计牺牲了一点吞吐量但换来了稳定性——永远不因单个业务任务的慢而影响整个调度链路。5. 性能调优与可观测性建设参数、指标和告警的最终沉淀5.1 线程池参数怎么定不能拍脑袋要按量级和耗时推算自研调度系统最终要回答一个问题我的机器到底能扛多少任务量ax 给出一套可复用的推算方法。假设单台执行器节点的核心线程数是T单个任务平均耗时为d毫秒那么该节点的理论吞吐量约等于1000 * T / d个每秒。如果我们的目标峰值是每秒2000个任务平均任务耗时为200毫秒那么需要的核心线程数约为每秒并发数 1000 / 200 5 个任务/线程/秒 目标线程数 2000 / 5 400 个线程单机400个线程显然不合理那就需要横向扩展执行器节点。把目标调整为单机100个线程单机吞吐约500个任务每秒4台机器就可以覆盖2000每秒的目标。这个推算过程可以帮你在上线前就估算好需要部署多少执行器节点而不是等压测发现问题再临时堆机器。线程池的队列长度建议设置为核心线程数 * 200减少任务排队导致的延迟同时对内存的压力也控制在可接受范围。5.2 指标采集清单哪些指标必须看、怎么定位问题ax 上线之后可观测性指标体系经历了从粗略到精准的迭代。现在保留的指标分为三个层面每个指标都有明确的意义调度层schedule_delay任务实际触发时间减去计划触发时间的差值、schedule_batch_size每轮扫描的批量大小、wheel_queue_depth时间轮队列深度。派发层dispatch_success_rate派发成功率、dispatch_latency派发耗时、ack_timeout_rate执行器确认超时比例。执行层execute_success_rate执行成功率、execute_timeout_rate执行超时比例、task_retry_count任务重试次数分布。这些指标全部通过 Micrometer 暴露给 PrometheusGrafana 面板直接可视化。其中最重要的一个指标是schedule_delay它衡量的是用户视角下任务到底晚点了多少。ax 的告警阈值设置为schedule_delay超过2秒持续1分钟触发 P2 告警超过10秒直接 P1 告警值班同学必须马上介入。5.3 兜底巡检任务数据库扫表为什么不能完全放弃虽然时间轮和延迟队列解决了高频触发问题但无论在内存里玩得多花数据库扫描这部分兜底不能少。内存里的任务数据一旦因为节点崩溃丢失了恢复的唯一来源就是数据库。ax 的兜底扫描策略是有一个独立的守护进程每30秒扫描一次数据库找出trigger_time在过去5分钟内且状态还是DELAYED的数据。扫描到的任务放入一个单独的recovery_queue由恢复线程负责重新入时间轮。这个兜底扫描的频率不能太高否则又回到最初的扫表问题。它的作用不是触发任务而是发现那些内存里丢了但数据库还在的任务重新纳入调度。这套兜底机制上线后节点崩溃导致的任务丢失率直接降到了零。之前测试过人为杀掉一个调度器节点30秒内它负责的到期任务全部由另一个节点的恢复线程接管并重新触发。6. 写在最后的实操心得调度系统的边界感和取舍自己从零做一套调度引擎最大的心得不是技术有多牛而是要有边界感。很多团队一上来就想着把调度做成全流程编排平台把 DAG、审批、重试、审计全部塞进去最后往往变成了一个谁都用不顺手的四不像。ax 从头到尾只解决触发时机这一个问题把怎么执行怎么编排完全交给业务方自带的执行器去处理。这个取舍让 ax 的代码始终维持在可控范围内出了问题也能快速定位。另外在幂等设计上我特别想强调一句调度系统再怎么优化都无法替代业务方做幂等。任务系统能做的就是尽量减少重复派发但网络抖动、执行器超时重试这些因素永远存在。所以业务执行器在做实际动作之前一定要有一个判断依据比如 Redis 去重、数据库唯一键约束、或者对账标记。把幂等做在业务侧调度侧才能安心地追求极致性能。如果看到这里你也想动手自己搞一套调度系统我建议从一个小场景切入比如只做一个延迟关闭订单的定时任务跑通了再逐步加上分布式、时间轮、故障转移。别一开始就整大而全的架构调度系统这种链路越简单越好维护。