☰
自研分布式定时任务框架:从时间轮到OpenAPI异步调用的实践
2026/10/21 7:41:55 网站建设 项目流程

定时任务这块,我相信每个后端团队都有过一段折腾史。早些年简单业务我直接塞进程里跑,后来规模大了开始用Quartz集群,再后来发现调度逻辑和业务代码越缠越紧,加上产品那边动不动就提“帮我跑个数据,明天给我结果”,我就知道不能光靠现成框架凑合了。刚好那段时间我们内部要做一项跨系统的数据同步,涉及几十个服务节点、周粒度周期触发、还要对上游开放任务提交接口,市面上那几个开源调度框架要么太重,要么接入成本高,要么任务分发策略不够灵活,于是决定自己动手。这一做就是小半年,从最开始只想要一个调度器,慢慢演变成了一个带负载均衡、支持OpenAPI异步调用的分布式定时任务框架。这篇就把它拆开了聊聊,包括我当时的设计思路、核心模块的落地细节、踩过的坑,以及几个高频问题的排查方法。

1. 内容整体设计与思路拆解

很多人一开始想自己写调度框架,第一反应往往是“照着Quartz抄,加上分布式锁不就行了”。但实际深入之后你会发现,真正难的不是定时触发,而是分布式环境下的任务分配、故障感知、执行隔离,以及对外提供稳定易用的API。

1.1 现有框架的痛点

先说我为什么没有继续用现成方案。我们当时的场景有三个硬需求:一是任务量不算特别大但节点多,希望任务能相对均匀地散到各机器上;二是任务执行时间普遍较长,有的要跑几个小时,不能因为单节点重启就全部重来;三是外部系统要通过API提交任务,而且很多是长耗时的批处理任务,不能同步等结果,必须异步化。

市面上的成熟方案各有偏向。Quartz集群是靠数据库行锁抢任务,逻辑简单但扩展性差,几十个节点同时抢锁会拖慢调度周期,而且Quartz的失败恢复策略对长任务不够友好。ElasticJob的分布式分片能力很强,但引入ZooKeeper做协调,对中小团队来说运维成本偏重。xxl-job的触发模型很清晰,但它默认的调度和执行分离、通过HTTP回调确认结果这套,我们接进来后发现自定义扩展点有限,尤其是想改负载均衡策略和异步API风格时要改底层的代码。评估下来,自己写一个框架的成本其实可控,而且能完全贴合内部系统的调用方式和部署形态。

1.2 目标定位与核心需求

开始动手前,我先把目标拆成了三条硬性能力:第一,分布式定时任务调度,支持cron、固定周期、延迟触发,任务执行状态完整可追踪;第二,任务分发要做负载均衡,不能所有任务都打到一个节点上,节点上下线要能自动感知,正在执行的任务不能因为调度漂移而重复跑;第三,对外提供OpenAPI异步调用接口,业务方提交任务后立刻返回RequestId,之后通过轮询或回调拿到执行结果。

为了不让这个项目一上来就失控,我明确了边界:不追求海量任务吞吐,也不打算做可视化控制台,先解决调度核心和API接入问题。技术选型上用的是Spring Boot、MySQL、Redis,分布式协调这块先用Redis的分布式锁顶住,后期如果节点规模真上来了再引入ZooKeeper。这个取舍很重要,因为Redis方案足够解决我们当时99%的场景,引入更重的协调组件反而会增加部署和维护成本。

1.3 分布式环境下最重要的设计原则

自己实现分布式调度框架,最容易犯的错是把单机思维直接搬到集群里。单机下进程内用一个调度线程轮询就完事了,分布式下要额外考虑三件事:节点间状态同步、任务执行归属、故障转移时的一致性。

执行归属是核心。每个任务在同一时刻只能有一个节点在执行。我采用的方案是“调度与执行分离”:调度节点只负责任务到点触发,触发后把任务请求写入待执行队列,再由执行节点从队列里取走;谁取走谁执行,执行结果通过状态上报回写。这样调度器不做具体业务逻辑,执行节点拿到任务后也不关心是哪个节点触发的,天然解耦。

故障转移的一致性,我用的是“执行前标记+执行后回写”的方式。节点在执行任务之前先写一条RUNNING状态的执行记录,并写入Redis记录当前执行节点。如果执行节点挂了,其他节点发现RUNNING记录超时,可以重新入队执行;前提是任务处理方法本身要支持幂等。这是分布式任务框架的底层共识,一旦你选择自己写,就必须在框架层明确提示使用者“业务代码请自行保证幂等”。

2. 核心模块实现:数据模型、调度器、执行器与负载均衡

整个框架我把它分为五个模块:元数据管理模块、调度触发模块、任务分发模块、任务执行模块、OpenAPI接入模块。前四个模块是骨架,第五个模块是业务方看到的门面。

2.1 数据模型设计与存储选型

任务元数据存在MySQL里。核心表包括任务定义表、任务执行记录表、调度节点注册表、回调记录表。

任务定义表字段大概如下:

字段类型说明
idbigint主键
task_codevarchar业务任务编码,全局唯一
task_namevarchar任务名称
trigger_typetinyint触发类型:cron、fixed_rate、delay
cron_exprvarcharcron表达式,触发器为cron时使用
interval_secondsint固定周期秒数
handler_namevarcharBean名称,指向执行器
load_balance_strategyvarchar分发策略:round_robin、least_active、consistent_hash
timeout_secondsint执行超时时间
retry_timesint失败重试次数
statustinyint启用/停用
create_time / update_timedatetime时间戳

执行记录表则记录每次触发的分配情况:

字段类型说明
idbigint主键
task_idbigint任务ID
request_idvarchar全局唯一请求ID,对外API返回此ID
trigger_timedatetime触发时间
execute_nodevarchar实际执行节点
statustinyintPENDING/DISPATCHED/RUNNING/SUCCESS/FAILED/CANCELED
start_time / end_timedatetime执行起止
error_msgtext失败原因
callback_urlvarchar异步回调地址
callback_statustinyint回调发送状态

索引上重点注意两个方向:一是按任务ID和触发时间查执行记录,二是按状态和触发时间扫描超时任务,要给status + trigger_time建联合索引,否则节点多了之后扫描慢得离谱。

Redis里主要放四类数据:分布式锁、待执行队列、节点心跳、执行中的任务快照。锁用Redisson实现,队列直接用Redis List的LPUSH/BRPOP,心跳就是每个节点定期写一个带过期时间的Key。这套组合的好处是简单可靠,不需要单独部署协调服务。

2.2 调度触发器:从轮询扫表到时间轮

最开始我尝试过每隔几秒扫一次任务表,找出所有到点的任务记录。任务只有几十个时毫无压力,但任务上了百之后,频繁扫库不仅对数据库不友好,触发延迟也做不到秒级。于是引入了时间轮。

时间轮原理不复杂,就是一个环形数组,每个槽位放一个到点任务列表。指针每秒走一格,走到哪个槽就触发槽里所有的任务。相比扫表,时间轮把任务触发的时间复杂度降到了O(1),而且在任务量增长到几千、几万时依然稳定。

我用的实现是类似分层时间轮,一级秒级时间轮60格,一级分钟级时间轮60格,再加24小时的槽位处理跨天任务。新任务进来时先计算延迟秒数,如果一小时内能触发就放秒级轮,否则放分钟级轮,到点前再降级到秒级轮。这样内存占用非常有限。

任务注册和触发流程是:服务启动时,每个调度节点从数据库加载所有启用状态的任务,构建触发器和时间轮映射;当调度节点收到触发信号,会写入一条PENDING记录并推入Redis待执行队列,而不是直接调用执行器。执行节点的消费线程组从队列中BRPOP取出任务,通过负载均衡选出一个目标节点(消费时已经指定了节点)执行。

等等,这里有个细节要展开。有些框架是推模式,调度器直接HTTP调用执行器;有些是拉模式,执行节点主动poll队列。我选择的是拉模式。推模式的优势是延迟低,但调度器必须知道所有执行节点的地址和健康状态;拉模式则天然规避了这个问题,执行节点自己把任务拉走,队列本身就是缓冲。延迟方面,BRPOP可以理解为阻塞读取,任务一入队执行节点立即感知,延迟可以忽略不计。

2.3 执行器与任务生命周期

执行器我抽象成了一个接口,业务方实现一个Handler,类似于:

public interface JobHandler { JobResult execute(JobContext context) throws Exception; }

JobContext里携带RequestId、任务参数、任务ID、重试次数等信息。框架负责横向的事情:执行前检查分布式锁,执行中维持心跳续租,执行后上报状态和回调通知;业务方只需要关心纵向的业务逻辑。

任务生命周期状态流转是这样的:

PENDING -> DISPATCHED -> RUNNING -> SUCCESS -> FAILED -> 重试 -> RUNNING -> TIMEOUT -> 重新入队

超时机制用的是“心跳续租”思路。执行节点在RUNNING状态下周期性写入Redis过期Key,比如默认心跳30秒、租约180秒。如果执行节点宕机,心跳停止,租约到期,其他节点可以把这条任务重新放回队列。如果任务还没跑完但租约快到期了,执行节点续租。这个机制比固定超时时间优雅一点,因为长任务和短任务对超时时间的需求完全不同,你无法预知业务会跑多久。

2.4 负载均衡:任务分发要做到相对均匀且动态自愈

任务分发不能写死节点列表。节点随时可能上下线,写死就失去了分布式意义。

我实现了三种策略:轮询、最少活跃数、一致性哈希。轮询最简单,维护一个AtomicInteger计数器然后对节点列表取模。最少活跃数需要每个节点在Redis里维护当前执行中的任务数量,每次分发做一次递增,执行完递减,选数量最少的节点。一致性哈希用在分片场景,保证同一分片的任务尽量落在同一节点上,减少重复初始化的成本。

分片支持我做了两层。一是按任务的分片参数,比如一个报表任务逻辑上有0~9十个分片,分发时通过一致性哈希将不同分片散到不同节点;二是按数据维度,比如用户ID取模,让同一用户的数据固定由同一节点处理。分片数量变化时要处理重新分片,这里我采用“先标记重新分片,再逐个迁移”的保守策略,避免大量任务同时漂移导致重复执行。

节点上下线感知我通过心跳实现。每个节点每5秒写一次心跳Key,15秒未更新即视为下线,从节点列表剔除,新的任务不会再分给它;正在它上面执行的任务等待租约过期后自动重新入队。节点上线则直接注册心跳,15秒内被框架感知并加入节点列表。这个方案比维护TCP长连接简单得多,也够稳。

注意:心跳探测的间隔和租约时间需要根据实际网络情况调节。间隔太短会增加Redis压力,太长会导致故障转移延迟。我实测下来,5秒心跳、15秒离线判定、180秒任务租约,在几十个节点规模下是比较稳的。

3. OpenAPI异步调用:把任务提交变成一种服务能力

调度框架如果只给内部自己用,价值就少了一半。开放出去,让其他系统通过HTTP API提交任务,才是真正解决协作问题的方式。从架构上看,OpenAPI模块是独立的一层,它不关心任务如何被调度、如何被执行,只负责三件事:把任务请求转化为内部任务单、把任务状态同步给调用方、保障提交过程的可靠性和安全性。

3.1 API设计范式

对外API风格我采用了RESTful,全部走异步模式。核心接口就三个:

POST /api/tasks 提交任务,立即返回 requestId GET /api/tasks/{requestId} 查询任务状态和结果 POST /api/callbacks/register 注册或更新回调地址

提交任务的请求体大致是:

{ "taskCode": "data_sync_report", "taskName": "月度数据同步", "triggerType": "delay", "executeParams": { "bizDate": "2025-03-01", "sourceTable": "order_info" }, "callbackUrl": "https://internal.example.com/callback/receive" }

接口收到请求后立刻返回:

{ "requestId": "req_20250301103000_abc123", "status": "PENDING", "estimatedDelay": 5 }

业务方拿到RequestId后有两种方式获取最终结果:定时轮询,或者等待回调通知。考虑到很多内部系统的代码都是同步风格,接受异步结果需要改造,所以我两个都支持,默认推荐回调。

3.2 异步任务队列与状态机

底层实现是,OpenAPI模块接收请求后,校验参数并将数据插入任务执行记录表,状态为PENDING。同时把RequestId推入Redis队列。调度触发模块消费到这个请求后,走和定时任务一样的执行流转。

这里有个容易踩的坑:业务方提交的任务可能是未来某个时间的,也可能是立即执行的。我的设计是给OpenAPI模块增加一个延迟队列,未来时间触发的任务进入延迟队列,到点再转入待执行队列。实现方式是利用Redis的有序集合,以触发时间戳为score,起一个线程轮询到点的任务。这个线程只有调度节点的一个Leader在跑,通过分布式锁保证,避免多个节点重复取出同一个延迟任务。

异步回调这块,我单独实现了回调发送器和回调记录表。任务结束时框架自动将结果POST到callbackUrl。回调发送采用指数退避重试:第一次发送失败后,5秒、30秒、2分钟、10分钟……一共重试5次,超过重试次数标记为CALLBACK_FAILED,人工排查。回调消息体统一封装状态、RequestId、返回结果、失败原因和任务耗时,方便调用方直接落库。

3.3 幂等、并发控制和安全性

开放出去的接口最怕业务方反复重试,或者恶意提交大量任务。我做三层防护。

第一层是幂等控制。RequestId可以由调用方自己传,也可以由我们生成。如果调用方传,在插入任务记录前先查是否已存在同RequestId记录,存在就直接返回原纪录,不再新建。这样业务方的重试机制不会产生脏数据。

第二层是并发控制。对每个任务的执行并发做了任务级和全局级两个维度限制。任务级限制同一任务同一时间只允许N个实例运行;全局级限制节点总执行线程数。超过阈值时任务进入WAITING状态,而不是直接拒绝,这样可以避免一次性收到大量批处理请求时把整个集群打挂。

第三层是认证鉴权。内部OpenAPI我采用的是简单的Token签名方案。每个接入方分配一个AppId和AppSecret,请求签名用HMAC-SHA256,同时带一个5分钟有效的时间戳防重放。这样既不需要复杂OAuth,又能保证基本的安全性。

4. 实操过程与核心代码实现

理论讲多了容易飘,直接给出几个核心模块的精简实现思路。不是完整代码,但核心结构足够你照着推演。

4.1 时间轮触发器的实现骨架

时间轮的核心结构是数组 + 双向链表。每个槽位存放一个任务链表。我用Java原生的ConcurrentHashMap来存放延迟任务映射,计数器和指针用AtomicLong。

public class TimingWheel { private final long tickDuration; // 每次 tick 的毫秒数 private final int ticksPerWheel; // 槽位数 private final AtomicInteger currentTick = new AtomicInteger(0); private final ConcurrentHashMap<Integer, Queue<TaskTrigger>> slots = new ConcurrentHashMap<>(); public void addTask(TaskTrigger trigger, long delayMs) { int ticks = (int) (delayMs / tickDuration); int targetSlot = (currentTick.get() + ticks) % ticksPerWheel; slots.computeIfAbsent(targetSlot, k -> new ConcurrentLinkedQueue<>()) .add(trigger); } public void advance() { int slot = currentTick.getAndIncrement() % ticksPerWheel; Queue<TaskTrigger> triggers = slots.remove(slot); if (triggers == null) return; for (TaskTrigger trigger : triggers) { trigger.fire(); // 到点触发 } } }

每个调度节点有一个守护线程每秒调用一次advance。注意这里必须保证只有一个节点在推进时间轮,否则会重复触发。我是在节点启动时用Redis锁抢Leader角色,只有Leader节点执行时间轮推进,其他节点做慢启动接管。

4.2 负载均衡算法实现

负载均衡器我封装成一个接口,核心方法是从节点列表选出一个节点。最少活跃数策略实现大致如下:

public class LeastActiveLoadBalancer implements LoadBalancer { // Redis 客户端注入,使用 redis 的 INCR/DECR 维护活跃数 private final StringRedisTemplate redisTemplate; public String select(List<NodeInfo> nodes) { String target = null; int minActive = Integer.MAX_VALUE; for (NodeInfo node : nodes) { Object value = redisTemplate.opsForValue() .get("task:active:" + node.getNodeId()); int active = value == null ? 0 : Integer.parseInt(value.toString()); if (active < minActive) { minActive = active; target = node.getNodeId(); } } return target; } }

分发任务时,在入队前调用select方法,将选中的节点ID和RequestId一起写入队列。执行节点消费队列时,如果发现出队的任务绑定的节点不是自己,就重新推回队列,由对应节点消费。

这个“二次确认”的设计是我后期加的。原因是调度节点选完节点到执行节点取出任务之间存在时间差,如果节点刚好在那一刻下线了,任务就会被错误节点取走。加一个目标节点校验,能有效避免执行节点错位问题。

4.3 OpenAPI异步接口实现

Controller层不写业务逻辑。核心业务逻辑在TaskApiService里:

@PostMapping("/api/tasks") public ResponseEntity<ApiResult> submitTask(@RequestBody TaskSubmitRequest request) { // 1. 幂等校验 TaskRecord existRecord = taskRecordMapper.selectByRequestId(request.getRequestId()); if (existRecord != null) { return ResponseEntity.ok(ApiResult.success(existRecord)); } // 2. 创建任务记录,PENDING TaskRecord task = TaskRecord.builder() .taskCode(request.getTaskCode()) .requestId(UUID.randomUUID().toString().replace("-", "")) .status(TaskStatus.PENDING.getCode()) .callbackUrl(request.getCallbackUrl()) .executeParams(new Gson().toJson(request.getExecuteParams())) .build(); taskRecordMapper.insert(task); // 3. 推入延迟队列(延迟0秒表示立即执行) redisTemplate.opsForZSet().add( "task:delay:queue", task.getRequestId(), System.currentTimeMillis() + request.getDelayMs() ); // 4. 返回 RequestId return ResponseEntity.ok(ApiResult.success(task.getRequestId())); }

这里有一个我强烈建议你留意的细节:第三步推入延迟队列时,入队元素我放的是RequestId而不是完整任务对象。这样做的原因是,就算Redis里数据丢了,执行前还能从MySQL恢复完整任务数据。不要把MySQL和Redis变成双写脏数据的关系,MySQL是唯一数据源,Redis只是加速通道。

4.4 高可用监控埋点

没有监控的调度框架等于裸奔。我这套框架埋了几个关键指标:任务触发延迟、执行时长、失败率、重试次数、队列深度、节点心跳。每个指标都写到Prometheus,配上Grafana面板。队列深度指标最直观,队列堆积意味着调度节点能力不足或执行节点挂了,我给它配了告警规则,连续5分钟超过阈值就拉群。

5. 常见问题与排查技巧实录

自研框架最大的成本就在于你需要承担所有未知的坑。我记录几个我们团队跟踪了很久才定位的典型问题。

5.1 场景一:任务重复执行,根源在锁失效

现象:某个任务明明配置的是单实例运行,监控却发现一段时间内出现了两个节点同时在执行。

排查:先从日志找到两条执行记录的节点和开始时间。进一步追查发现两个节点在执行前都成功获取到了同一个任务的分布式锁。原因在于我们Redis锁的Key设计只包含了任务ID,没有包含触发批次标识;一个长任务还没结束,下一次触发又来了,新的触发批次拿走了锁。

解决:给锁的Key加上triggerTime或者RequestId做后缀,锁的粒度从“任务级”缩小到“单次触发级别”。同时把锁的超时时间显式调大到超过任务最长执行时间。

实操心得:分布式锁不要只想着防并发,还要想清楚“并发的是哪两个对象”。同一次触发只允许一个节点执行,和同一任务不同批次不能同时执行,这是两种不同的锁语义。

5.2 场景二:任务堆积,调度节点成了瓶颈

现象:业务侧反馈异步任务迟迟不执行,监控面板显示Redis队列深度持续上升。

排查:看执行节点的线程池使用率发现执行节点根本没有消费,因为队列消息都堆积在调度节点侧还没入队。后来看到调度节点的CPU和内存都接近满负荷,定位到是时间轮触发的任务里有一个任务执行了耗时较长的数据库查询,阻塞了调度线程。

解决:把所有执行时间超过100毫秒的操作从调度线程里挪出去。调度线程只做“取出任务、封装记录、入队”这三件事,其他一律交给异步线程池。同时把调度节点的时间轮触发进度做了日志埋点,当触发延迟超过500毫秒时打印告警日志。

5.3 场景三:回调丢失,链路耗尽

现象:任务明明执行成功了,调用方却一直没有收到回调通知,检查callback记录表发现回调发送状态还是PENDING。

排查:回调发送器用的是单线程池,发送HTTP请求时遇到对方接口偶发超时,每次超时都占用线程等待SocketTimeout,积压一批之后线程池满,后续回调全部排不上。

解决:回调发送改用多线程池,并设置合理的连接和读取超时,超时时间从15秒降到3秒。另外给回调发送加了快速失败机制:如果回调地址连续3次发送失败,不再傻等下一次,直接标记为CALLBACK_FAILED并通知平台负责人。业务方看到失败回调也能提前介入,而不是无限等待最终态。

5.4 问题速查表

现象可能原因排查方向
任务延迟触发Leader节点时间轮线程被阻塞查看调度节点线程栈,检查是否有耗时操作阻塞
任务被重复执行锁粒度不匹配或租约过期检查锁Key设计、租约时间是否合适
队列深度持续上升执行节点消费能力不足或节点下线查看消费线程池使用率、心跳状态
部分节点无任务心跳注册失败或节点被判定下线查看心跳日志、Redis中节点Key
回调从未收到回调线程池耗尽或URL不可达查看回调记录表状态、HTTP连接日志
任务状态一直RUNNING执行节点宕机且租约未过期等待租约过期或手动标记FAILED
节点上下线切换频繁网络抖动或心跳间隔过短调大心跳间隔和离线判定阈值

6. 根据个人经验再补充几点

经历这一轮从零到一的框架开发,我的个人体会是,不要因为市面上有现成方案就直接否定自研,也不要一上来想着做全做完美。定时任务框架本质是在回答四个问题:谁来触发、谁去执行、失败了怎么办、别人怎么用。把这四个问题用最简单的技术手段跑通,再逐步迭代,才是可控的节奏。

另一个感触是测试的重要性。分布式调度框架是典型的并发问题密集区,单测覆盖不到。我后来针对调度引擎写了专门的并发压测脚本,同时注册几千个任务,模拟多个节点并发触发,检查执行记录是否出现重复。第一次跑出来一堆重复执行的问题,逐步修完之后这个脚本成了每次改动必跑的回归用例。

最后分享一个小技巧:调试负载均衡策略时,直接在节点名里带上启动时间和自增序号,比如node-20250301-01、node-20250301-02,任何时候看到任务被分配到哪个节点,都能快速推断出它是否在预期范围内。这个不起眼的命名习惯,帮我们在初期排查了很多定位难题。

如果你的团队也面临多节点、长任务、外部系统需要接入调度的场景,自己动手做一个轻量级的分布式定时任务框架,是完全走得通的路。重点不在于代码量多复杂,而在于合理拆分模块,并始终把“任务不丢、不重、可追踪”作为设计底线。现在这套框架在我们内部已经稳定运行了大半年,支撑了十几条业务线的数据同步、报表生成和异步批处理任务,后续我打算再往容器调度和任务血缘分析的方向扩一扩,那又是另一段故事了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询