☰
从零实现分布式定时任务框架:调度、负载均衡与异步实践
2026/10/21 21:18:43 网站建设 项目流程

在团队里聊起“自己写一个分布式定时任务框架”,很多人第一反应是:XXL-Job 不香吗?ElasticJob 不好用吗?确实,成熟框架功能齐全,文档也多,直接拿来用是最稳的。但如果你是那种喜欢把核心逻辑捏在手里、想彻底搞清楚调度中心到底是怎么把事情分派出去的开发者,自己从零搭一个绝对是一段非常有价值的经历。

这次我不打算只是贴代码,而是把当时做这个模拟项目X的完整思考过程、模块设计、负载均衡策略选型、OpenAPI异步接口的落地方式,以及折腾过程中踩过的那些坑,全部摊开讲一遍。这既是对自己思路的一次复盘,也给后面想动手的人留一份可以对照的实战笔记。

1. 整体架构与设计思路

开始写代码之前,最关键的问题是:这个框架到底要承担什么职责,服务调用方和任务执行方怎么划分,调度中心要管到什么粒度。这些想清楚了,后面写代码才不会反复推倒重来。

1.1 模块边界拆分

当时手头的需求其实很明确:公司内部有几个业务系统需要定时跑数据同步、报表生成、缓存预热这类任务,另外还有一部分异步处理场景,外部系统提交任务后不希望一直阻塞等待结果。因此整个框架需要同时具备定时触发和异步调用两条主线。

第一版架构我拆成了三个核心角色:

  • 调度中心(Scheduler):负责任务元数据管理、触发时间计算、任务分发,是整个框架的大脑。
  • 执行节点(Worker):真正干活的地方,接收调度中心推送过来的任务,执行业务逻辑,把结果上报回去。
  • 任务存储(TaskStore):任务定义、触发记录、执行日志都要落库,否则调度中心一重启,所有定时任务全失忆了。

对外单独留了一个OpenAPI 服务层,负责接收外部系统的任务提交请求、查询任务状态。这个服务层不直接执行任务,而是把请求封装成内部任务,丢给调度中心去处理。

1.2 为什么不用 Zookeeper 而选 Redis

早期很多分布式调度框架依赖 Zookeeper 做节点协调和选主,比如 ElasticJob 就是这么设计的。Zookeeper 确实稳定,分布式协调能力很强,但前提是你得额外维护一套 ZK 集群,这在小团队里其实挺重的。

我们的基础设施里已经有 Redis 了,而且只用了缓存和分布式锁这些基础功能,负载压力不大,所以我把节点注册、心跳检测、Leader 选举都落在了 Redis 上。虽然它在强一致性和故障恢复速度上不如 ZK,但胜在运维简单、上手成本低,而且这个框架定位是轻量级内部调度,不是金融级别的强一致场景。

1.3 走异步路线的原因

最初的设计是同步调用:调度中心把任务发给 Worker,Worker 同步执行完再返回结果。这种方式写起来简单,但问题是 Worker 执行一个任务可能要几十秒甚至几分钟,调度中心如果同步等待,一个线程就被长时间占住,后续任务的触发全部被堵住。

改成异步之后就顺畅多了:

  • 调度中心通过 HTTP 或消息队列把任务投递给 Worker,投递成功即视为完成自己的职责,立即去处理下一个任务。
  • Worker 执行完成后,通过回调接口把状态和结果异步上报。
  • 外部系统通过 OpenAPI 提交任务后,只拿到一个taskId,不用在 HTTP 连接上干等,任务完成后可以轮询查询结果,或者等系统回调通知。

这种模式其实就是典型的异步编排,线程的利用率大幅提升,吞吐量也随之变好。

2. 调度核心:分布式定时任务框架的关键设计

定时任务框架最容易踩的坑是什么?任务重复执行、触发时间不准、节点宕机导致任务丢失。这几个问题背后,核心都在调度模块的设计里。

2.1 任务状态机

任务从创建到最终结束,必须有一整套明确的状态变化规则,状态机设计得越清晰,后面处理边界情况就越不容易出错。

PENDING -> READY -> DISPATCHING -> RUNNING -> SUCCESS -> RUNNING -> FAILED -> RETRYING -> RUNNING -> RUNNING -> TIMEOUT
  • PENDING:任务刚创建,等待调度触发。
  • READY:已经到达触发时间,可以被分发出去。
  • DISPATCHING:调度中心正在把任务投递给 Worker。
  • RUNNING:Worker 已经开始执行。
  • SUCCESS / FAILED:执行结果。
  • RETRYING:任务失败后进入重试流程,重试次数用尽后最终标记为 FAILED。

每个状态变更都做了一件事:记录变更时间。这个字段非常重要,后面排查超时任务、卡死任务时,全靠它来判断问题出在哪个环节。

2.2 定时触发的两种方式

定时任务框架最核心的能力是“到点触发”。这里我参考了业界两种常见方案,也亲自都实现了一遍做对比。

第一种:数据库轮询。

调度线程每隔几秒扫描一次任务表,把触发时间在[now - 1s, now + 1s]范围内的任务捞出来,进入待分发队列。这种方式简单,但是任务量一旦大了,数据库压力会非常大,而且秒级误差是必然的。

第二种:时间轮(Time Wheel)。

时间轮可以把任务的触发精度提高到毫秒级,内存中维护一个环形队列,每个槽位代表一个时间刻度,指针每秒转动一格,对应槽位上的任务全部触发。这种方式很适合高频、短周期的任务调度,但实现复杂度明显高。

业务场景里大多数定时任务都是分钟级或者小时级的,精度要求并不苛刻,数据库轮询完全够用。但为了拓展性,我把触发模块做成接口,下面挂两个实现类,默认走轮询,配置切换后走时间轮。后面任务量上来了可以直接切换,不用改其他代码。

2.3 调度中心选主

分布式环境下,调度中心不可能只有一个节点。如果两个调度节点同时扫描任务表并分发,同一个任务就会被投递两次,造成重复执行。

常见的解决方案是做 Leader 选举,只有 Leader 节点才有权限去扫描任务和分发任务。其他节点作为 Follower 保持热备状态,Leader 挂了之后立刻接管。

利用 Redis 实现选主逻辑非常简单:

  • 每个调度中心节点启动时尝试向 Redis 写入一个锁,key 是scheduler:leader,value 是节点ID,带上过期时间。
  • 写入成功的节点成为 Leader,其他节点进入监听等待状态。
  • Leader 节点周期性续期,防止锁过期被其他节点抢走。
  • 如果 Leader 宕机,锁到期后自动释放,其他节点重新竞争。

这个过程本质上就是分布式锁控制下的主从切换。Redis 不是强一致的,但应用在这种选主场景里足够可靠。要注意的关键点是:锁的过期时间不能太短,否则正常 Leader 续期不及时会被误伤;也不能太长,否则 Leader 挂了之后系统长时间没有调度能力。我当时设置的过期时间是 30 秒,续期周期 10 秒,实测下来稳定性不错。

2.4 任务分发的可靠性保证

任务从数据库中扫描出来后,投递给 Worker 的过程必须可靠。如果直接 HTTP 调用 Worker,网络抖动导致调用失败,任务是应该丢弃还是重试?

我的处理思路是:任务投递前先把状态改成DISPATCHING,然后执行投递。投递成功后,等待 Worker 的 ACK 消息;如果超时未收到 ACK,任务进入重试队列。重试超过 3 次后标记为失败,并触发告警通知。

这个逻辑用状态机和消息队列配合实现,流程相当清晰。每个任务在任意时刻都处于明确状态,系统永远知道它该从哪里继续处理。

3. 分发策略:手写负载均衡的四种方案

任务要被分发到多个 Worker 节点上执行,那么“分给谁”就变成了一个策略问题。如果所有任务都发给同一个节点,其他节点空闲,那这系统还能叫分布式吗?

负载均衡层要做的事情,就是在一组可用的 Worker 节点中,按照某种规则选出一个最合适的节点来执行任务。我实现了四种策略,每种策略的适用场景差别挺大。

3.1 轮询与加权轮询

最简单的策略是轮询:所有 Worker 节点排成一个队列,任务分发时从头到尾依次选择,选完一圈再从头开始。这个策略能够保证每个节点收到的任务数量基本均等,适用于节点配置相近的场景。

现实情况往往没那么理想。有些 Worker 机器配置好,有些机器配置差,如果一视同仁地轮询,差的机器会先扛不住。所以轮询必须扩展成加权轮询:配置高的节点分配更大的权重,配置低的节点权重调小。

具体的实现思路不复杂:假如三个节点的权重分别是[5, 3, 2],当前轮询总权重为 10,每次分发时按顺序累加权重,落到区间上的节点被选中。我为了简化计算,用的是平滑加权轮询算法,它在权重分布的基础上做了平滑处理,避免权重高的节点被连续命中,整体分布更均匀。

public class SmoothWeightedRobin { private final List<Node> nodes; private final int totalWeight; public SmoothWeightedRobin(List<Node> nodes) { this.nodes = nodes; this.totalWeight = nodes.stream().mapToInt(n -> n.weight).sum(); } public synchronized Node next() { Node selected = null; for (Node node : nodes) { node.currentWeight += node.weight; if (selected == null || node.currentWeight > selected.currentWeight) { selected = node; } } if (selected != null) { selected.currentWeight -= totalWeight; } return selected; } static class Node { String nodeId; int weight; int currentWeight; Node(String nodeId, int weight) { this.nodeId = nodeId; this.weight = weight; this.currentWeight = 0; } } }

3.2 一致性哈希

轮询和加权轮询能把流量平均分配出去,但在某些场景下不是最优解。比如框架里支持“同一个业务类型的数据尽量落在同一个 Worker 上”,因为 Worker 本地会做一些数据预处理和缓存复用,哈希到同一个节点可以提升命中率。

一致性哈希的核心思路是:把 Worker 节点通过哈希函数映射到一个哈希环上,任务计算完哈希值后,沿着哈希环顺时针找最近的节点。哈希环上节点不均衡时,可以通过虚拟节点(一个物理节点映射为多个虚拟节点)来打散分布。

这里容易踩的坑是:物理节点列表变化会导致大量任务重新映射,进而引起大规模重复执行。

比如新增一个节点,哈希环上会增加一个新的哈希点。原本落在环上不同位置的任务,有一部分会被重新分配给新节点。对定时任务系统来说,这会导致一部分任务被分发到新节点,而旧节点上可能已经有一份在跑。解决方案有两个:要么在分发时做一个短时间窗口的幂等判断,要么在 Worker 端执行前通过 Redis 锁做一次唯一性校验。我采用了后者,后面会详细讲。

3.3 最少活跃调用

最有实际意义的策略是最少活跃调用。它的逻辑很直观:每个 Worker 节点当前正在执行的任务数量(活跃数)会被实时统计,新的任务分发给当前活跃数最少的节点。

这个策略的妙处在于它没有一味地追求任务数量均衡,而是追求负载压力均衡。有些任务执行时间长,有些任务执行时间短,单纯看数量没有意义。比如一个节点虽然收到了 20 个任务,但都是毫秒级完成;另一个节点只收到 2 个任务,但每个都要跑 10 分钟。从数量上看前者任务多,但从压力上看后者更繁忙。最少活跃调用选的就是后者活跃数更高的节点,反而避开了它。

执行节点注册时会上报一个/metrics接口或者在心跳包里携带当前活跃任务数,调度中心缓存这个数字。分发时从缓存中取最小值即可。这个策略在业务任务处理时长差异较大的场景下表现最好,是生产环境中非常好用的负载均衡策略。

3.4 服务发现与心跳检测

前面所有策略,前提都是一个可用节点列表。节点宕机后必须从列表里摘除,新节点上线后必须自动加入,这些都是通过服务注册与健康检查机制实现的。

Worker 启动时会向 Redis 写入一条注册信息,key 是worker:register下存一个 hash,字段是节点ID,值是节点信息 JSON。之后每隔 5 秒向一个worker:heartbeat的集合发送心跳消息,实际上就是刷新自己在 Redis 里的过期时间。

调度中心后台有一个定时线程,每隔 10 秒扫描一次:如果某个 Worker 节点的心跳超时超过 20 秒,就把它从可用列表中移除。被移除的节点会进入“隔离观察”状态,如果后续心跳恢复,可以重新注册回来。

这一套逻辑没有用复杂的注册中心,完全靠 Redis 的基本特性实现,落地的成本非常低。

4. OpenAPI 异步调用:对外能力封装

定时任务框架解决了“内部任务到点执行”的问题,但外部系统有时候也需要向框架提交一些异步任务,比如生成复杂报表、批量导入数据。如果让外部系统直接面对调度中心和 Worker,既暴露了内部细节,又不利于后续权限治理。所以我在框架外层封装了一个 OpenAPI 服务层。

4.1 接口设计

对外提供的接口设计遵循 RESTful 风格,核心有四个:

  • POST /api/tasks:提交异步任务,立即返回taskId。
  • GET /api/tasks/{taskId}:查询任务状态和结果。
  • POST /api/tasks/{taskId}/cancel:取消尚未开始执行的任务。
  • POST /api/tasks/{taskId}/ack:消费方主动确认任务处理完成,一般用于回调推送结果的场景。

这里有个容易忽略的细节:状态码的选择。提交任务成功不应该返回 200 + 任务详情,而是应该返回202 Accepted,表示接受请求但处理仍在进行。200 通常给人的直觉是“请求已经处理完成”,语义上容易误导调用方。我用 202 + 任务ID 的组合,语义清晰。

4.2 异步任务编排的内部实现

外部系统提交一个任务后,链路是这样的:

OpenAPI 接收请求 -> 生成 taskId,任务状态置为 PENDING -> 任务信息写入数据库 -> 同时向 Kafka/Redis Stream 发送一条消息 提示调用方提交成功

框架的异步处理线程收到消息后,从数据库中捞取任务详情,执行分发逻辑。这个过程中最核心的数据结构是Future和回调机制。

我用CompletableFuture做了任务编排,每个任务提交后立即返回一个CompletableFuture<TaskResult>,由后台线程池执行完整的分发-执行-结果上报流程。调用方可以注册回调函数,任务完成时自动触发回调,把结果推送出去。

public CompletableFuture<TaskResult> submitAsync(TaskRequest request) { TaskEntity entity = savePendingTask(request); CompletableFuture<TaskResult> future = CompletableFuture.supplyAsync(() -> { try { dispatchAndWait(entity); return loadResult(entity.getId()); } catch (Exception e) { handleFailure(entity, e); throw e; } }, taskExecutor); return future; }

这种写法让外部系统提交任务之后,不用苦苦等待,而是可以立即去做自己的事情。结果准备好了之后,要么调用方主动来查,要么通过 Webhook 推过去,整个链路干净利落。

4.3 线程池与队列的选择

异步调用如果想当然地使用Executors.newFixedThreadPool()就会有隐患:默认的队列是LinkedBlockingQueue,无界队列会让积压任务永远不触发拒绝策略,最终内存被撑爆。

我最终自定义了线程池参数,根据任务类型分了两套配置:

  • CPU 密集型任务:核心线程数 = CPU 核数 + 1,队列长度 200。
  • IO 密集型任务:核心线程数 = CPU 核数 * 2,队列长度 500。

队列满时执行CallerRunsPolicy拒绝策略:提交任务的线程自己去执行这个任务。这个策略的好处是不会丢弃任何任务,代价仅仅是提交线程被阻塞一会儿。在异步框架里,这对调用方是有一定压力的,但相比任务丢失,这种压力可以接受。

注意:如果你在代码里看到某处直接使用Executors.newCachedThreadPool()处理异步任务,一定要警惕。这个线程池的最大线程数是 Integer.MAX_VALUE,线程数在异常流量下会无限增长,直接把机器内存拖垮。

5. 核心流程实践:注册、选举、分发、执行

理论上把方案讲得再丰满,不落代码都是空谈。这里把最核心的几段实现逻辑放出来,按照实际业务链路一步步说明。

5.1 Worker 注册与心跳

Worker 启动时的注册逻辑,核心代码并不复杂,但细节在异常场景的处理上。机器重启、断网重连、Redis 连接闪断,这些情况都需要覆盖。

@Component public class WorkerRegistrar { private static final String WORKER_REGISTER_KEY = "worker:register"; private static final long HEARTBEAT_INTERVAL_SECONDS = 5; private final StringRedisTemplate redisTemplate; private final WorkerInfo workerInfo; private final ScheduledExecutorService heartbeatExecutor; public WorkerRegistrar(StringRedisTemplate redisTemplate, WorkerInfo workerInfo) { this.redisTemplate = redisTemplate; this.workerInfo = workerInfo; this.heartbeatExecutor = Executors.newSingleThreadScheduledExecutor(); } public void register() { Map<String, String> workerMap = new HashMap<>(); workerMap.put("nodeId", workerInfo.getNodeId()); workerMap.put("ip", workerInfo.getIp()); workerMap.put("port", String.valueOf(workerInfo.getPort())); workerMap.put("status", "UP"); redisTemplate.opsForHash().putAll(WORKER_REGISTER_KEY, workerMap); redisTemplate.expire(WORKER_REGISTER_KEY, 30, TimeUnit.SECONDS); } public void startHeartbeat() { heartbeatExecutor.scheduleAtFixedRate(() -> { redisTemplate.opsForHash().put(WORKER_REGISTER_KEY, workerInfo.getNodeId(), workerInfo.toJson()); redisTemplate.expire(WORKER_REGISTER_KEY, 30, TimeUnit.SECONDS); log.info("Heartbeat sent: {}", workerInfo.getNodeId()); }, HEARTBEAT_INTERVAL_SECONDS, HEARTBEAT_INTERVAL_SECONDS, TimeUnit.SECONDS); } }

这里用了 Redis 的 hash 结构和整体过期时间。每次心跳都会刷新整个 key 的过期时间,所有注册节点共享同一个过期时间。运行时环境相对稳定,这个机制完全够用。

5.2 Leader 选举实现

调度中心的选主逻辑在 2.3 中提到过,核心就是 Redis 分布式锁。这套选主机制虽然简单,但有几个边界情况必须考虑清楚:

  • 节点正常停止时需要主动释放锁。
  • 节点非正常宕机时拿不到释放锁的机会,只能等待锁过期。
  • 锁续期必须持续进行,所以需要一个守护线程定时续约。
public class SchedulerLeaderElection { private static final String LEADER_LOCK_KEY = "scheduler:leader"; private static final long LOCK_EXPIRE_SECONDS = 30; private final StringRedisTemplate redisTemplate; private final String nodeId; private volatile boolean isLeader = false; public boolean tryAcquireLeadership() { for (int attempt = 0; attempt < 3; attempt++) { boolean locked = redisTemplate.opsForValue() .setIfAbsent(LEADER_LOCK_KEY, nodeId, LOCK_EXPIRE_SECONDS, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { isLeader = true; startRenewTask(); log.info("Node {} became leader", nodeId); return true; } String currentLeader = redisTemplate.opsForValue().get(LEADER_LOCK_KEY); log.info("Current leader is {}, node {} waiting", currentLeader, nodeId); try { Thread.sleep(5000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } return false; } private void startRenewTask() { ScheduledExecutorService renewExecutor = Executors.newSingleThreadScheduledExecutor(); renewExecutor.scheduleAtFixedRate(() -> { if (StringUtils.equals(redisTemplate.opsForValue().get(LEADER_LOCK_KEY), nodeId) && isLeader) { redisTemplate.expire(LEADER_LOCK_KEY, LOCK_EXPIRE_SECONDS, TimeUnit.SECONDS); } }, 10, 10, TimeUnit.SECONDS); } }

选主逻辑稳定运行的前提是所有节点的时钟相对一致。如果某个节点系统时间比其他人快了 1 分钟,它执行续期的时间点就会偏晚,可能造成锁提前过期。

5.3 任务分发与幂等控制

任务分发是高并发场景下最容易出错的一环。分发动作本身不是问题,问题在于同一任务被重复分发时如何保证不重复执行。

我的处理方式是这样的:

  1. 调度中心从数据库捞到到期任务后,先把任务ID推送给 Redis 的task:idempotency集合,利用setIfAbsent方法保证同时只有一个调度节点能拿到这个任务的执行权。
  2. 拿到执行权的节点,把任务下发给选中的 Worker。
  3. Worker 收到任务后,先检查 Redis 里有没有这个任务正在执行的标记。有就直接返回“任务处理中”;没有就写入标记并开始执行。

这个幂等判断相当于在“分发”和“执行”两个关键节点上各加了一道锁,彻底规避了重复执行的问题。

public boolean claimTask(String taskId) { Boolean success = redisTemplate.opsForValue() .setIfAbsent("task:" + taskId + ":claim", "1", 10, TimeUnit.MINUTES); return Boolean.TRUE.equals(success); }

5.4 超时任务回收

任务在执行过程中如果 Worker 节点突然宕机,任务会一直卡在 RUNNING 状态,永远不会被收尾。这种情况需要有一个巡检机制兜底。

我在调度中心里加了一个巡检线程,每 60 秒扫描一次处于 RUNNING 状态且最后更新时间超过超时阈值(默认 10 分钟)的任务。发现后立刻把任务状态改为TIMEOUT,然后重新把任务放入待分发队列,触发重试。

这个阈值不能设置的太短。有些长时间运行的数据批处理任务本身就要跑几十分钟,如果巡检线程 10 分钟内看到没有更新就强制判定超时,会误伤大量正常任务。我给任务增加了timeout配置字段,允许业务方按需设置。

6. 实战中的常见问题与排查思路

这部分内容来源于我实际测试和线上运行中遇到的高频问题,整理成一个速查表,方便后面有类似困扰的直接对照处理。

6.1 Redis 锁超时引起双主

压测时发现,调度中心的 Leader 锁偶尔会出现两个节点同时认为自己是 Leader 的情况。归根结底是锁的过期时间和续期节奏没有对齐。

场景是这样的:Leader 节点发生一次长时间 GC,整个线程暂停了几十秒,续期线程也被暂停了。Redis 中的锁到了 30 秒的过期时间自动删除,另一个节点抢到了锁。GC 结束后,老 Leader 并不知道自己已经失主,继续执行调度逻辑,于是两个“Leader”同时在工作,产生任务重复分发。

解决思路:

  • 把锁续期从固定间隔改成“看门狗”模式,即每次续期前先确认自己还是 Leader,再做续期。
  • 在真正分发任务前,再做一次 Redis 中的锁持有者校验,确认自己仍然拥有锁。

6.2 时钟漂移导致延迟触发

这个坑出现在定时任务框架的触发时间校验上。整套框架假设不同机器的时间是一致的,结果有几台测试机的系统时间快了几十秒,任务全都在未到设定时间的状态下被扫出来,提前执行了。

解决方式:给框架增加一个时间校准机制。调度中心在启动时从统一时间源同步标准时间,计算本机时间偏差并校正。另外,在扫描任务时我加了一个安全校验:任务的触发时间必须满足triggerAt <= now + 3s才能被拉取。这一步过滤掉了因为时钟偏差导致的“未来任务”。

6.3 外部系统拿到 TaskId 后查询不到结果

OpenAPI 异步链路中有一个常见问题:外部系统调用提交接口后,立刻通过GET /api/tasks/{taskId}查询状态,返回的是任务不存在。

原因在于任务提交后先写 Redis、后写数据库,中间存在一个时间差。查询接口读取的是数据库,外部查询在写入完成之前到达,自然查不到记录。

后来我把查询逻辑改成了先查 Redis 缓存,查不到再去数据库查询;写入逻辑改成先落数据库,再写 Redis。这样一来,查询接口永远能拿到至少一个来源的结果,不会再出现“任务已提交但查不到”的怪现象。

6.4 负载不均衡:新节点上线后依旧打不满

加了 3 台新 Worker,总以为流量会自动均衡过去,实际观察一段时间发现新节点几乎没收到任务。排查后发现是策略选择的问题:

用轮询策略时,节点列表更新存在缓存延迟,调度中心缓存的是旧节点列表,反复把任务分发给旧节点。而缓存更新时间要等下一次心跳扫描完才触发,新节点可能等了很久才进入列表。

解决方式:把可用节点列表的缓存过期时间缩短,同时在新节点注册后主动触发一次列表刷新,而不是等下一次定时扫描。

6.5 任务失败重试风暴

某个业务任务执行时依赖的外部数据源宕机,执行持续失败。但任务本身配置了重试机制,每次失败后 1 分钟重试一次。几分钟后,所有依赖这个数据源的任务全部进入重试队列,重试风暴一下子把消息队列打满了。

这个场景里的核心教训是:重试必须带上退避策略,不能固定间隔重试。我给重试机制增加了一个指数退避:第一次重试等待 1 分钟,第二次 2 分钟,第三次 4 分钟,最多重试 5 次。另外,当同一个业务类型任务失败率达到阈值时,要触发熔断,暂停同类任务的重试,避免重试风暴扩大。

7. 框架的运行效果与个人复盘

整个框架从设计到落地、测试、联调,大概花了两周多时间。真正跑起来之后,稳定性和扩展性都达到了预期,内部定时任务开始逐步迁移到这个自研框架上,OpenAPI 异步调用接口也被几个外部系统接入使用。

复盘整个项目,有几个心得值得再单独写一下。

选择性的引入约束规则。定时任务框架虽然是我自己写的,但并没有尝试去实现所有功能。比如复杂的 Cron 表达式解析、任务分片、动态扩容这些,第一版都没有做,而是集中精力解决最核心的调度、分发、异步调用三个问题。后面真遇到需要分片的场景,再在这个框架基础上逐步迭代。

负载均衡策略不要一开始就做得太重。先实现轮询,能跑通业务之后再加加权和最少活跃。前期过度设计会让调试和定位问题变得十分痛苦。

异步化是性能瓶颈的解药,也是排障难题的源头。异步链路一旦拉长,出问题时排查链路的手段就跟不上了。所以日志里一定要带上 taskId,并且全链路透传,否则一个任务出问题,调度日志、分发日志、Worker 日志根本串不起来。

最后想说的是:自己写一个分布式定时任务框架,本质上不是为了替代成熟产品,而是通过亲手实现来理解分布式系统中最核心的那些问题——共识、协调、容错、一致性、负载均衡。这份经验在工作里面临任务调度需求时非常有价值,能让你做出更合理的架构决策。

框架目前还在实际使用的业务中持续迭代,后续还计划加上任务编排和 DAG 依赖支持,把那几个复杂场景再继续打磨下去。

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

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

立即咨询