在开发计算密集型业务时,任务调度与结果回收往往比计算本身更让人头疼:节点多、状态散、失败重试逻辑各自为政,时间一长就变成一团乱麻。近期在项目里整理了一套名为 cactus-compute / needle 的轻量级任务编排与计算调度方案,把“什么时候跑、在哪个节点跑、跑完怎么汇报”统一收敛到一个模型里。本文会完整拆解这套方案的架构思路、核心代码、部署步骤与高频坑点,既适合零基础读者理解任务调度概念,也能给后端、数据开发的工程落地提供可以直接参考的写法。
1. 背景与核心概念
1.1 为什么需要 cactus-compute / needle
很多团队的分布式计算一开始很简单:一台机器定时跑脚本,处理完数据写回数据库。但业务量上来之后,单机脚本会迅速碰到几个瓶颈——执行时间变长、资源争抢、单点故障、任务之间依赖关系难以表达。这时候大家会本能地想到引入任务调度框架,但常见调度框架往往偏重“定时触发”,对“把任务分发到多台计算节点并回收结果”这件事覆盖得并不深入。
cactus-compute 可以理解为计算资源管理层:它负责维护一批可用的计算节点,接收上层提交的任务,并在合适的节点上执行。needle 则像一根轻巧的针,负责把任务定义、触发规则、执行状态串起来,让调用方只需要关心“提交任务、查询结果”,而不需要关心底层节点列表和通信协议。
所以这套组合解决的核心问题不是“某一段算法怎么写”,而是“一段算法如何稳定、可控地在分布式环境中运行”。它把任务调度、资源分配、状态上报、失败重试这些通用能力沉淀成一个基础组件,业务侧只需要对接它的 API 即可。
1.2 常见应用场景
从实际项目来看,cactus-compute / needle 适合以下场景:
- 离线数据加工:每天凌晨批量清洗日志、聚合指标、生成报表。
- 异步计算任务:用户提交一个复杂计算请求,后端立即返回任务 ID,计算完成后再通过回调或轮询获取结果。
- 定时巡检脚本:分布式环境下对多台机器执行健康检查、指标采集。
- 工作流中的步骤编排:一个任务完成后自动触发下一个任务,失败时按策略重试或告警。
它和一般的消息队列方案不同。消息队列更侧重削峰填谷和异步解耦,而 cactus-compute / needle 更关注任务生命周期的管理:任务是否被调度、运行在哪个节点、当前进度如何、失败后如何处理。
1.3 关键名词解释
- 任务(Task):一次需要被执行的完整计算单元,包含任务 ID、执行参数、超时时间、重试策略等元数据。
- 调度器(Scheduler):needle 中的核心模块,负责任务触发、优先级排序、依赖关系判断。
- 计算节点(Worker):cactus-compute 中真正执行任务的进程。一个节点同一时刻可能并行执行多个任务。
- 任务状态(Status):通常包括待执行、运行中、成功、失败、超时、已取消等。
这里需要区分“调度”和“执行”两个概念。调度器只回答“任务什么时候该跑、派给谁”,不关心任务的业务逻辑;计算节点才是真正把业务代码跑起来的地方。把这两个职责分开,是这套架构最重要的设计原则。
2. 整体架构与核心设计
2.1 逻辑架构
cactus-compute / needle 的整体逻辑可以拆成四个部分:
- 接入层:面向调用方提供任务提交、状态查询、结果获取的接口。
- 调度层:负责任务触发、依赖校验、负载均衡、失败重试。
- 计算层:由若干工作节点组成,实际执行业务逻辑,并上报心跳和任务状态。
- 存储层:保存任务定义、任务实例、节点信息、执行日志。
调度器和计算节点之间建议通过异步消息通信,而不是同步 RPC 调用。原因在于任务执行时间往往较长,同步等待会占用大量连接资源,也不利于任务的横向扩容。异步通信结合任务表的持久化状态,可以在节点宕机后由调度器重新派发,保证任务最终被执行。
2.2 调度触发方式
needle 支持两种触发方式:
- 定时触发:通过 Cron 表达式或固定间隔触发任务。适合周期性的数据加工、巡检任务。
- 事件触发:上游业务通过接口或消息主动提交任务。适合用户请求触发的异步计算。
在任务量不大时,可以用数据库轮询实现简单调度。任务量增大后,再引入基于时间轮或延迟队列的内存调度器,降低数据库压力。
这里有一个容易踩坑的地方:定时触发和事件触发不要混用同一个任务 ID 生成逻辑。定时任务需要具备幂等性,如果上一次执行还没结束,下一次触发不应该再创建任务实例。建议在任务表中增加“当前执行中”的唯一约束,避免重复执行。
2.3 任务状态流转
一个任务从创建到结束,通常经历以下状态:
创建 -> 待执行 -> 运行中 -> 成功 -> 失败 -> 待重试 -> 运行中 -> 超时 -> 取消状态流转必须只由调度器或计算节点中的一方来变更,避免双写导致状态错乱。实践中推荐:计算节点只负责上报“运行中”“成功”“失败”等执行结果,任务的“待执行”“待重试”“超时”状态由调度器维护。这样可以避免节点网络抖动时误改任务状态。
2.4 结果回收与通知
任务执行完成后,调用方通常需要拿到计算结果,或者至少知道任务成功还是失败。推荐的做法是:
- 计算节点把结果写入存储,并记录结果地址或结果对象。
- 调度器更新任务状态,并根据配置发送回调通知或写入消息队列。
- 调用方通过任务 ID 查询结果,而不是由计算节点直接回调调用方。
这种设计能让结果回收与任务执行解耦,避免调用方接口不可用时导致计算节点阻塞。
3. 环境准备与版本说明
在开始编码之前,先梳理一套可运行的本地环境。本文示例以常见环境为例,重点演示配置思路,具体版本需要根据你的项目实际情况调整。
3.1 基础环境
- JDK 8 或 JDK 11:示例代码使用 Java 编写,JDK 8 即可满足基本需求。
- Maven 3.6+:用于项目构建和依赖管理。
- MySQL 5.7+ / 8.0:用于保存任务定义、任务实例、节点信息。
- Redis 5.0+(可选):用于分布式锁、延迟队列优化。
- IDEA 或 Eclipse:本地开发调试。
如果你更熟悉 Python,也可以参考同样的设计思路使用 Celery + Redis 实现,但本文的代码示例将以 Java + Spring Boot 为主。
3.2 核心依赖
下面是一份最小化的 Maven 依赖清单,包含 Web 接口、MySQL 访问、连接池等基础组件。
<dependencies> <!-- Web 层 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <!-- MyBatis 或 Spring Data JPA,按团队习惯选择 --> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.2</version> </dependency> <!-- 工具包 --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>注意:上述依赖版本只是示例,实际使用时建议以 Maven 中央仓库的稳定版本为准,并保持与 Spring Boot 父版本一致。
3.3 项目结构规划
推荐按模块拆分,不要在同一个包里塞下所有逻辑。
cactus-needle-demo ├── pom.xml ├── sql │ └── init.sql └── src/main/java/com/example/needle ├── NeedleApplication.java ├── controller │ └── TaskController.java ├── service │ ├── TaskService.java │ └── WorkerService.java ├── scheduler │ └── TaskScheduler.java ├── worker │ └── TaskExecutor.java ├── entity │ ├── TaskInstance.java │ └── WorkerNode.java └── mapper ├── TaskInstanceMapper.java └── WorkerNodeMapper.java这样的结构清晰划分了接入层、调度层、计算层和存储层,后续扩展也方便。
4. 完整实战:搭建一套最小可运行的任务调度系统
下面从零开始搭建一个最小可运行示例。这个示例会实现:一个 HTTP 接口用于提交计算任务,调度器扫描待执行任务并分发给本地模拟的 Worker 执行,执行完成后把结果写回任务记录。
4.1 初始化数据库表结构
首先创建任务表和节点表。为了演示方便,这里只保留核心字段。
-- 文件路径:sql/init.sql CREATE DATABASE IF NOT EXISTS cactus_needle DEFAULT CHARACTER SET utf8mb4; USE cactus_needle; -- 任务实例表 CREATE TABLE task_instance ( id BIGINT PRIMARY KEY AUTO_INCREMENT, task_id VARCHAR(64) NOT NULL COMMENT '业务侧任务 ID', task_name VARCHAR(128) NOT NULL COMMENT '任务名称', task_type VARCHAR(32) NOT NULL DEFAULT 'once' COMMENT '任务类型:once 单次,cron 定时', status TINYINT NOT NULL DEFAULT 0 COMMENT '0 待执行,1 运行中,2 成功,3 失败,4 超时', params TEXT COMMENT '任务参数,JSON 格式', result TEXT COMMENT '任务结果', retry_count INT NOT NULL DEFAULT 0 COMMENT '已重试次数', max_retry INT NOT NULL DEFAULT 3 COMMENT '最大重试次数', worker_ip VARCHAR(64) COMMENT '执行节点 IP', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_status_create_time (status, create_time) ) ENGINE=InnoDB COMMENT='任务实例表'; -- 计算节点表 CREATE TABLE worker_node ( id BIGINT PRIMARY KEY AUTO_INCREMENT, node_id VARCHAR(64) NOT NULL COMMENT '节点 ID', node_ip VARCHAR(64) NOT NULL COMMENT '节点 IP', status TINYINT NOT NULL DEFAULT 1 COMMENT '1 在线,0 离线', last_heartbeat DATETIME NOT NULL COMMENT '最近心跳时间', UNIQUE KEY uk_node_id (node_id) ) ENGINE=InnoDB COMMENT='计算节点表';这里把 status 字段设计为 TINYINT 类型,并在表上建立了idx_status_create_time索引,因为调度器最频繁的查询就是“找到待执行且创建时间最早的任务”。这个索引能明显提升任务量增大后的扫描效率。
4.2 创建 Spring Boot 工程并配置数据源
创建名为cactus-needle-demo的 Maven 工程,在application.yml中配置数据源:
# 文件路径:src/main/resources/application.yml server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/cactus_needle?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.needle.entity configuration: map-underscore-to-camel-case: truemap-underscore-to-camel-case这个配置比较关键。数据库字段是下划线命名,Java 实体是驼峰命名,开启自动映射后能省去大量手写 ResultMap 的工作。
4.3 编写任务实体类
// 文件路径:src/main/java/com/example/needle/entity/TaskInstance.java package com.example.needle.entity; import lombok.Data; import java.time.LocalDateTime; @Data public class TaskInstance { private Long id; private String taskId; private String taskName; private String taskType; private Integer status; private String params; private String result; private Integer retryCount; private Integer maxRetry; private String workerIp; private LocalDateTime createTime; private LocalDateTime updateTime; }实体类字段和数据库表字段一一对应。@Data注解来自 Lombok,能自动生成 getter/setter,让代码更简洁。
4.4 编写 Mapper 接口
// 文件路径:src/main/java/com/example/needle/mapper/TaskInstanceMapper.java package com.example.needle.mapper; import com.example.needle.entity.TaskInstance; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import java.util.List; @Mapper public interface TaskInstanceMapper { int insert(TaskInstance taskInstance); TaskInstance findByTaskId(@Param("taskId") String taskId); List<TaskInstance> findPendingTasks(@Param("limit") int limit); int updateStatus(@Param("id") Long id, @Param("status") Integer status, @Param("workerIp") String workerIp); int updateResult(@Param("id") Long id, @Param("status") Integer status, @Param("result") String result); int updateRetry(@Param("id") Long id); }对应的 Mapper XML 如下:
<!-- 文件路径:src/main/resources/mapper/TaskInstanceMapper.xml --> <?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.example.needle.mapper.TaskInstanceMapper"> <insert id="insert" parameterType="com.example.needle.entity.TaskInstance" useGeneratedKeys="true" keyProperty="id"> INSERT INTO task_instance ( task_id, task_name, task_type, status, params, retry_count, max_retry ) VALUES ( #{taskId}, #{taskName}, #{taskType}, #{status}, #{params}, #{retryCount}, #{maxRetry} ) </insert> <select id="findByTaskId" resultType="com.example.needle.entity.TaskInstance"> SELECT * FROM task_instance WHERE task_id = #{taskId} LIMIT 1 </select> <select id="findPendingTasks" resultType="com.example.needle.entity.TaskInstance"> SELECT * FROM task_instance WHERE status = 0 ORDER BY create_time ASC LIMIT #{limit} </select> <update id="updateStatus"> UPDATE task_instance SET status = #{status} <if test="workerIp != null"> , worker_ip = #{workerIp} </if> WHERE id = #{id} </update> <update id="updateResult"> UPDATE task_instance SET status = #{status}, result = #{result} WHERE id = #{id} </update> <update id="updateRetry"> UPDATE task_instance SET retry_count = retry_count + 1, status = 0 WHERE id = #{id} </update> </mapper>4.5 编写调度器
调度器是 needle 的核心。这里用一个简单的定时线程池模拟调度循环:每 2 秒扫描一次待执行任务,并将任务状态更新为运行中。
// 文件路径:src/main/java/com/example/needle/scheduler/TaskScheduler.java package com.example.needle.scheduler; import com.example.needle.entity.TaskInstance; import com.example.needle.mapper.TaskInstanceMapper; import com.example.needle.worker.TaskExecutor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import java.util.List; @Slf4j @Component public class TaskScheduler { private static final int SCAN_LIMIT = 10; @Autowired private TaskInstanceMapper taskInstanceMapper; @Autowired private TaskExecutor taskExecutor; @Scheduled(fixedDelay = 2000) public void scanPendingTasks() { List<TaskInstance> pendingTasks = taskInstanceMapper.findPendingTasks(SCAN_LIMIT); for (TaskInstance task : pendingTasks) { // 先将任务状态改为运行中,避免其他调度节点重复扫描 taskInstanceMapper.updateStatus(task.getId(), 1, "127.0.0.1"); // 执行业务逻辑,实际场景中应提交给线程池或计算节点 taskExecutor.execute(task); } } }这里要特别说明“先改状态再执行”的原因:分布式环境下可能有多个调度器实例同时扫描,如果先查出任务再执行、最后再改状态,两个实例可能同时拿到同一个任务,导致重复执行。先把状态改为运行中,相当于给任务加了一个抢占锁。
这个示例中的@Scheduled(fixedDelay = 2000)表示任务执行完成后间隔 2 秒再扫描。如果希望严格每 2 秒扫描一次,可以使用fixedRate,但要注意避免上一次扫描未结束导致任务堆积。
4.6 编写任务执行器
任务执行器模拟真正执行业务逻辑的 Worker。这里直接用一个线程池异步执行,通过Thread.sleep模拟耗时操作。
// 文件路径:src/main/java/com/example/needle/worker/TaskExecutor.java package com.example.needle.worker; import com.alibaba.fastjson.JSON; import com.example.needle.entity.TaskInstance; import com.example.needle.mapper.TaskInstanceMapper; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; import java.util.Map; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; @Slf4j @Component public class TaskExecutor { private ThreadPoolExecutor executor; @Autowired private TaskInstanceMapper taskInstanceMapper; @PostConstruct public void init() { executor = new ThreadPoolExecutor( 4, 8, 60, TimeUnit.SECONDS, new ArrayBlockingQueue<>(1000) ); } @PreDestroy public void shutdown() { executor.shutdown(); } public void execute(TaskInstance task) { executor.submit(() -> { try { log.info("开始执行任务: {}", task.getTaskId()); // 模拟业务计算过程 Thread.sleep(3000); Map<String, Object> params = JSON.parseObject(task.getParams()); Object input = params.get("input"); int result = Integer.parseInt(input.toString()) * 2; taskInstanceMapper.updateResult(task.getId(), 2, String.valueOf(result)); log.info("任务执行成功: {}, result={}", task.getTaskId(), result); } catch (Exception e) { log.error("任务执行失败: {}", task.getTaskId(), e); handleFailure(task); } }); } private void handleFailure(TaskInstance task) { if (task.getRetryCount() < task.getMaxRetry()) { taskInstanceMapper.updateRetry(task.getId()); } else { taskInstanceMapper.updateResult(task.getId(), 3, "任务执行失败,已超过最大重试次数"); } } }这里需要关注的细节是线程池的拒绝策略。当任务量超过队列容量时,默认的AbortPolicy会抛出异常,导致任务丢失。生产环境中建议使用CallerRunsPolicy,把无法交给线程池的任务由调用线程直接执行,保证任务不丢失,同时也能起到天然限流的作用。
4.7 编写任务提交与查询接口
有了调度器和执行器,再提供 HTTP 接口给调用方使用。
// 文件路径:src/main/java/com/example/needle/controller/TaskController.java package com.example.needle.controller; import com.example.needle.entity.TaskInstance; import com.example.needle.service.TaskService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; import java.util.Map; @RestController @RequestMapping("/task") public class TaskController { @Autowired private TaskService taskService; @PostMapping("/submit") public Map<String, String> submit(@RequestBody Map<String, Object> request) { String taskName = (String) request.get("taskName"); String params = request.get("params") == null ? "{}" : request.get("params").toString(); String taskId = taskService.submitTask(taskName, params); return Map.of("taskId", taskId, "message", "任务提交成功"); } @GetMapping("/{taskId}/result") public Map<String, Object> getResult(@PathVariable("taskId") String taskId) { TaskInstance task = taskService.getByTaskId(taskId); if (task == null) { return Map.of("success", false, "message", "任务不存在"); } return Map.of( "success", true, "taskId", taskId, "status", task.getStatus(), "result", task.getResult() == null ? "" : task.getResult() ); } }TaskService 的实现如下:
// 文件路径:src/main/java/com/example/needle/service/TaskService.java package com.example.needle.service; import com.example.needle.entity.TaskInstance; import com.example.needle.mapper.TaskInstanceMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.util.UUID; @Service public class TaskService { @Autowired private TaskInstanceMapper taskInstanceMapper; public String submitTask(String taskName, String params) { TaskInstance task = new TaskInstance(); task.setTaskId(UUID.randomUUID().toString().replace("-", "")); task.setTaskName(taskName); task.setTaskType("once"); task.setStatus(0); task.setParams(params); task.setRetryCount(0); task.setMaxRetry(3); taskInstanceMapper.insert(task); return task.getTaskId(); } public TaskInstance getByTaskId(String taskId) { return taskInstanceMapper.findByTaskId(taskId); } }4.8 启动入口
最后是启动类。由于使用了@Scheduled注解,需要在启动类上增加@EnableScheduling。
// 文件路径:src/main/java/com/example/needle/NeedleApplication.java package com.example.needle; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.scheduling.annotation.EnableScheduling; @EnableScheduling @SpringBootApplication public class NeedleApplication { public static void main(String[] args) { SpringApplication.run(NeedleApplication.class, args); } }4.9 运行与验证
启动应用后,按以下步骤验证:
- 调用任务提交接口:
curl -X POST http://localhost:8080/task/submit \ -H "Content-Type: application/json" \ -d '{"taskName": "计算翻倍任务", "params": "{\"input\": 21}"}'预期返回:
{"taskId":"3f2a1c8e9b1d4f7a8e2c6b5d0f1a2b3c","message":"任务提交成功"}- 查询任务结果:
curl http://localhost:8080/task/3f2a1c8e9b1d4f7a8e2c6b5d0f1a2b3c/result任务刚提交时状态为 0,约 3 秒后再次查询,预期返回:
{"success":true,"taskId":"3f2a1c8e9b1d4f7a8e2c6b5d0f1a2b3c","status":2,"result":"42"}到这里,一套最小的 cactus-compute / needle 雏形就跑通了。调度器每 2 秒扫描一次待执行任务,交给线程池执行,执行完成写回结果,调用方通过任务 ID 查询。
5. 核心机制进阶:分布式与可靠性的关键设计
上面的示例是单机演示。真正接入生产环境时,还需要补齐几个关键能力。
5.1 调度器的分布式抢占
单机调度器一旦宕机,所有任务都会停止。生产环境至少部署两个调度器实例。多个实例同时扫描任务表时,需要避免同一个任务被多个实例重复执行。除了“先改状态再执行”之外,还可以使用数据库行锁或 Redis 分布式锁。
使用 MySQL 行锁的 SQL 可以这样写:
SELECT * FROM task_instance WHERE status = 0 ORDER BY create_time ASC LIMIT 1 FOR UPDATE SKIP LOCKED;FOR UPDATE SKIP LOCKED是 MySQL 8.0 支持的语法,可以跳过已经被其他事务锁定的行,非常适合任务队列场景。如果你的数据库版本低于 8.0,则只能通过状态字段的乐观更新来避免重复执行。
5.2 心跳检测与节点感知
cactus-compute 中,计算节点需要定时上报心跳,调度器才能知道哪些节点可用。心跳信息包括节点 ID、节点 IP、当前负载、最近心跳时间。如果超过一定时间没有收到心跳,调度器应把节点标记为离线,并将该节点上的任务重新派发。
心跳上报的接口设计可以参考:
// 文件路径:src/main/java/com/example/needle/controller/WorkerController.java package com.example.needle.controller; import com.example.needle.entity.WorkerNode; import com.example.needle.service.WorkerService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/worker") public class WorkerController { @Autowired private WorkerService workerService; @PostMapping("/heartbeat") public Map<String, Object> heartbeat(@RequestBody WorkerNode node) { workerService.reportHeartbeat(node); return Map.of("success", true); } }WorkerService 中维护节点状态,并定期清理离线节点。
5.3 任务幂等与去重
任务提交接口需要防重。调用方可能因为网络超时而重试提交,如果不做幂等,会产生重复任务。常见做法是让调用方提交一个业务幂等 ID,任务表中对幂等 ID 建立唯一索引,重复提交时直接返回已有任务 ID。
ALTER TABLE task_instance ADD COLUMN biz_id VARCHAR(64) DEFAULT NULL; ALTER TABLE task_instance ADD UNIQUE KEY uk_biz_id (biz_id);5.4 超时控制
一个任务运行超过规定时间后,应该被标记为超时,并由调度器重新派发或直接失败。执行超时可以在线程池层面使用 Future 的get(timeout)实现,也可以由 Worker 在执行前设定任务过期时间,定期检查。
在单机示例中,可以在 TaskExecutor 中增加超时判断:
Future<?> future = executor.submit(taskRunnable); try { future.get(60, TimeUnit.SECONDS); } catch (TimeoutException e) { future.cancel(true); taskInstanceMapper.updateResult(task.getId(), 4, "任务执行超时"); }6. 常见问题与排查思路
在落地 cactus-compute / needle 的过程中,以下问题出现频率最高:
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 任务一直处于待执行状态 | 调度器定时任务未启动,或扫描 SQL 查不到数据 | 检查@EnableScheduling是否添加;确认任务表 status 是否为 0;查看数据库连接是否正常 |
| 同一个任务被重复执行 | 多调度器实例同时扫描,状态未先更新 | 在任务执行前先UPDATE status=1;使用FOR UPDATE SKIP LOCKED或 Redis 锁 |
| 任务执行成功但状态没更新 | Worker 中 SQL 异常,事务未提交 | 查看 Worker 日志;确认 updateResult 是否真正执行;检查 Mapper 参数映射 |
| 任务失败后没有重试 | 重试逻辑写在 catch 中,但抛错前状态已是运行中 | 确认失败处理中是否调用 updateRetry;检查retry_count和max_retry判断逻辑 |
| 任务积压严重 | 调度扫描频率太慢或 Worker 线程池过小 | 调大fixedDelay扫描频率;增加 Worker 线程池核心线程数;增加更多 Worker 节点 |
| 数据库连接被占满 | 每个任务占一个事务,长任务持锁过久 | 缩短事务范围;任务执行不要在数据库事务中做耗时操作 |
| 结果查询为空 | 调用方查询的是业务自身 ID,不是任务 ID | 在提交接口返回的任务 ID 与业务 ID 之间建立映射,便于追踪 |
排查这类任务调度问题时,最有效的手段是日志。建议在任务提交、调度扫描、Worker 开始执行、执行成功、执行失败这几个节点分别打印日志,并带上 taskId,方便串联全链路。
7. 最佳实践与工程建议
7.1 任务表设计建议
- status 字段使用整数枚举,不要使用字符串状态,节省空间且查询更快。
- 根据实际查询模式建立联合索引,比如
(status, create_time)。 - 任务表保存完整参数和结果,有利于排查问题,但要注意字段长度。结果过大时建议拆分到独立的结果表,或用文件存储后保存地址。
- 任务表数据量增长很快,建议定期归档已完成的历史任务。
7.2 异常处理与重试策略
- 重试要区分“可重试异常”和“不可重试异常”。参数错误、数据不存在这类异常重试没有意义,应该直接失败;网络抖动、依赖超时这类异常才值得重试。
- 重试间隔建议使用退避策略,例如第一次失败后等 10 秒,第二次等 30 秒,第三次等 60 秒,避免失败任务集中重试压垮下游系统。
- 所有重试操作都要有最大次数限制,防止死循环。
7.3 安全与权限控制
- 任务提交接口和服务端接口不能直接暴露在公网,至少需要内部网络访问控制。
- 参数内容如果包含敏感数据,在写入任务表前要加密,日志中不要打印明文参数。
- 任务执行命令如果涉及 Shell 脚本或动态加载代码,必须校验执行来源,防止恶意任务注入。
- 生产环境中的节点上下线、任务表清理等操作,应通过后台管理平台执行,并保留操作审计日志。
7.4 可观测性建设
- 为任务调度系统增加指标监控:待执行任务数、运行中任务数、任务成功率、平均执行耗时、重试次数分布。
- 任务长时间处于待执行状态或运行中超时,要触发告警。
- 每次任务执行记录开始时间、结束时间、执行节点,方便事后分析任务耗时趋势。
一个比较实用的告警阈值是:如果待执行任务数持续超过运行中任务数的 5 倍,或者任务成功率低于 99%,就应该立即检查 Worker 节点和下游依赖。
7.5 从单机演化到分布式集群
建议的演进路径是:
- 单机调度 + 单机执行:验证任务调度的业务逻辑。
- 单机调度 + 多 Worker 执行:通过心跳注册和任务派发,提升执行能力。
- 多调度器 + 多 Worker:引入数据库锁或分布式锁,解决重复调度问题。
- 增加任务分片能力:大任务拆分为多个子任务,在不同 Worker 上并行执行。
每一步都对应新的可靠性问题。先解决重复执行,再解决节点感知,最后解决任务分片,避免一次性引入过多复杂度。
8. 总结与后续学习方向
本文从 cactus-compute / needle 的概念讲起,明确了调度与执行分离的设计原则,然后搭建了一套最小可运行的任务调度示例,涉及任务表设计、调度器扫描、Worker 执行、结果回写、重试机制等核心环节。建议你动手跑一遍示例代码,把提交任务、查询结果、模拟失败的流程都走一遍,排查问题的手感会明显提升。
接下来的进阶方向包括:任务依赖编排(DAG)、任务分片执行、调度器高可用、基于时间轮的延迟调度、执行日志的可视化查询。无论是自研一套轻量级调度组件,还是去理解市面上成熟的分布式调度系统,这套基础模型都能帮你快速建立全局认识。
如果这篇实战笔记对你有所帮助,可以收藏备用。实际落地过程中遇到任何问题,欢迎在评论区交流你的场景和报错信息,一起完善这套方案。