news 2026/9/26 20:48:58

自研分布式任务调度系统ax:时间轮、分布式锁与重试机制实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
自研分布式任务调度系统ax:时间轮、分布式锁与重试机制实战

前阵子把内部系统里的任务调度模块彻底重写了一遍,项目代号取了个简洁的名字: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 Queue<Task>[] 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是全局唯一的任务标识,也是分布式锁的key;delayMillis表示延迟执行时间;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 taskId=order:close:123456, lock acquired, start execute [ax-node-1] 14:00:02.101 taskId=order:close:123456, lock not acquired, skip [ax-node-0] 14:00:04.200 taskId=order: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调度的实战记录,能帮你在设计自己的调度系统时少踩几个坑。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 20:48:16

Qwen2.5-7B中文对话LoRA微调实战指南

1. 这不是“调参游戏”&#xff0c;而是一次中文对话能力的精准手术你手头有一台刚组装好的7B级大模型&#xff0c;它能背《论语》、会写Python、甚至能分析财报——但一聊起“上海地铁早高峰怎么避开3号线换乘”或者“我妈总说‘你这孩子怎么不听劝’&#xff0c;我该怎么回”…

作者头像 李华
网站建设 2026/9/26 20:43:20

多模态内容生成系统实战:从零搭建图文音视频协同管线

多模态内容生成“对的这就是我老婆&#xff0c;别太羡慕了”——这句话如果发在技术社区&#xff0c;底下肯定一堆人问&#xff1a;你这是什么梗&#xff1f;其实这个标题特别贴切地描述了我最近大半年一直在折腾的一个项目&#xff1a;一套自建的多模态内容生成系统。你可以把…

作者头像 李华
网站建设 2026/9/26 20:42:52

MySQL底层架构详解:从SQL执行流程到B+树索引优化

1. 从一次线上事故说起&#xff1a;为什么必须懂底层架构 我之前在维护一套订单系统的时候&#xff0c;遇到过一件挺诡异的事&#xff1a;数据库CPU占用率并不高&#xff0c;内存也充足&#xff0c;但业务接口偶尔会卡上几秒钟。当时团队里几位开发的第一反应是"加索引&qu…

作者头像 李华
网站建设 2026/9/26 20:39:00

Atlas 300V 24G NPU推理卡部署YOLO实战:从CANN环境到OM模型转换全流程

如果你最近在闲鱼或某个IT机柜角落里看到一张印着“Atlas”字样的卡&#xff0c;十有八九是Atlas 300V 24G。很多人第一反应是&#xff1a;这玩意儿是GPU吗&#xff1f;能玩游戏吗&#xff1f;能拿来跑YOLO吗&#xff1f;我今天就把这张卡的底细和完整部署流程拆开聊透&#xf…

作者头像 李华
网站建设 2026/9/26 20:37:38

餐盘营养分析实战:图像分类与语义分割的完整技术链路

简介&#xff1a;一套面向智能饮食分析方向的完整项目资源&#xff0c;基于图像识别与语义分割技术&#xff0c;可通过手机照片或上传的食材图片自动识别食材成分与部位&#xff0c;并结合营养数据库、用户健康信息为不同人群生成个性化食谱&#xff0c;适用于计算机视觉、营养…

作者头像 李华
网站建设 2026/9/26 20:35:22

数据类型与运算符:从7/2到跨语言类型转换的避坑指南

说实话&#xff0c;我第一次被“数据类型和运算符”这个问题打脸&#xff0c;是在刚入行写 C 串口解析程序的时候。当时拿着两个int变量做除法&#xff0c;怎么算都少一位小数&#xff0c;排查到怀疑人生&#xff0c;最后发现不是算法错了&#xff0c;是7 / 2在 C 语言里压根不…

作者头像 李华