调度器模式:任务接口、调度策略与执行引擎

调度器模式,说白了就是「谁在什么时候做什么事」。我在做中间件开发那几年,几乎每个系统都离不开它——定时报表、数据同步、批处理任务……你想想看,如果没有一套统一的调度机制,这些散落在代码各处的定时逻辑,迟早会变成一团乱麻。

今天我就带你拆解一下调度器模式的核心结构。它其实就三块:任务接口调度策略执行引擎。搞懂了这三者的关系,你就能自己搭一个轻量级的调度框架。

一、任务接口:定义「做什么」

任务接口是调度器的基础。它规定了每个任务必须暴露什么行为。我个人习惯把任务接口设计得尽量薄,只保留最核心的方法。

// Java 版本
public interface Task {
    String getTaskId();
    void execute(TaskContext context);
}

// C++ 版本
class Task {
public:
    virtual ~Task() = default;
    virtual std::string getTaskId() const = 0;
    virtual void execute(const TaskContext& context) = 0;
};

嗯,这里要注意:TaskContext 里放什么?我建议放执行时间、重试次数、全局配置这些元信息。别把业务数据塞进去,否则接口就脏了。

我的经验: 曾经有个项目,任务接口里加了十几个方法,结果每个实现类都要写一堆空实现。后来我重构时只保留了 execute 和 getTaskId,其他用钩子方法(before/after)扩展,清爽多了。

二、调度策略:决定「什么时候做」

调度策略是调度器的灵魂。它决定了任务的触发时机。常见的策略有:

  • 固定延迟:上次执行完后,等固定时间再执行下一次
  • 固定频率:不管上次执行多久,按固定间隔触发
  • Cron 表达式:精确到秒级的定时触发
  • 一次性延迟:指定一个未来时间点执行一次

我建议把调度策略抽象成接口,这样扩展起来非常方便。

// Java 版本
public interface ScheduleStrategy {
    long nextExecutionTime(long currentTime, TaskContext context);
}

// Cron 策略实现
public class CronScheduleStrategy implements ScheduleStrategy {
    private final CronExpression cron;
    
    @Override
    public long nextExecutionTime(long currentTime, TaskContext context) {
        return cron.nextTimeAfter(currentTime);
    }
}
避坑指南: 我曾经在 Cron 策略里踩过一个坑——夏令时切换导致任务少跑了一次。后来我改用 UTC 时间做计算,只在展示时转成本地时间,问题就解决了。

三、执行引擎:负责「怎么执行」

执行引擎是调度器的核心。它把任务和策略组合起来,管理线程池、处理异常、记录日志。我见过不少团队把调度逻辑散写在业务代码里,结果出了问题根本没法排查。

一个健壮的执行引擎至少要做这几件事:

  1. 任务注册:把任务和它的调度策略绑定
  2. 时间轮询:不断检查哪些任务到了执行时间
  3. 线程池管理:控制并发度,防止资源耗尽
  4. 异常处理:任务挂了不能影响其他任务
  5. 状态监控:记录每次执行的开始、结束、耗时
// Java 版本 - 简化版执行引擎
public class SchedulerEngine {
    private final ScheduledExecutorService executor;
    private final Map<String, ScheduledFuture<?>> tasks = new ConcurrentHashMap<>();
    
    public void register(Task task, ScheduleStrategy strategy) {
        Runnable wrapper = () -> {
            try {
                task.execute(new TaskContext());
            } catch (Exception e) {
                // 记录异常,但不中断调度循环
                log.error("Task {} failed", task.getTaskId(), e);
            }
        };
        ScheduledFuture<?> future = executor.schedule(wrapper, 
            strategy.nextExecutionTime(0, null), TimeUnit.MILLISECONDS);
        tasks.put(task.getTaskId(), future);
    }
}
核心要点: 执行引擎一定要把「调度」和「执行」解耦。调度只负责决定「什么时候触发」,执行只负责「干活」。这样任何一个环节出问题,都不会影响另一个。

四、定时任务与批处理框架

说到定时任务,很多人第一反应是 Quartz、XXL-JOB 这些框架。但我想说,理解调度器模式比会用某个框架重要得多。框架会变,模式不会。

批处理框架其实也是调度器模式的一种特化。它多了几个东西:

  • 分片策略:把大任务拆成小片并行处理
  • 失败重试:某一片失败了,只重试那一片
  • 进度追踪:记录处理了多少条,还剩多少条

我参与过一个日处理千万级数据的批处理系统,就是用调度器模式搭的骨架。任务接口定义数据读取、处理、写入三个步骤,调度策略用固定频率(每 5 分钟拉一次),执行引擎用线程池控制并发。结构清晰,出了问题也容易定位。

五、知识体系图

下面这张图展示了调度器模式的整体结构。你可以看到任务、策略、引擎三者如何协作。

调度器模式核心结构 任务接口 getTaskId() execute(context) 调度策略 Cron / 固定频率 固定延迟 / 一次性 执行引擎 线程池管理 异常处理 / 监控 绑定 触发 回调执行 协作流程 1. 任务实现 Task 接口,注册到执行引擎 2. 调度策略计算下一次执行时间 3. 执行引擎在指定时间调用任务的 execute 方法 4. 执行引擎处理异常、记录日志、管理线程池

六、实战中的取舍

最后聊点实际的。你在设计调度器时,有几个问题要想清楚:

问题 我的建议
单机还是分布式? 单机用 ScheduledExecutorService 就够了。分布式需要引入分布式锁或选主机制。
任务持久化? 如果任务丢了会出大事,就存数据库。否则内存里跑着就行。
任务超时怎么处理? 我习惯给每个任务设一个超时阈值,超时了就强制中断,并记录告警。
要不要支持动态注册? 如果业务经常变,就做成动态的。否则启动时一次性注册更简单。
一个小技巧: 调试调度器时,我经常把时间轮询的间隔设成 1 秒,方便观察任务触发时机。上线前再改回正常的 5 秒或 10 秒。

调度器模式看起来简单,但想做得健壮并不容易。我见过太多「临时写个定时任务」最后变成线上事故的例子。把任务、策略、引擎这三个角色分清楚,你的调度系统就能经得起折腾。


公众号:蓝海资料掘金营,微信deep3321