CC 咖啡猫的工作空间 Coding Space

定时任务实践指南

定时任务(Scheduled Task / Cron Job)是后端开发中不可或缺的基础设施,从单机的 cron 表达式触发到分布式的任务分片与故障转移,其技术选型直接影响系统的可靠性和可扩展性。本文档覆盖从单机到分布式的完整实践路径。


一、基础概念与单机方案

1.1 什么是定时任务

定时任务指在指定时间点或按固定周期自动执行的代码逻辑,常见场景包括:

场景 示例 关注点
数据同步 每天凌晨全量同步用户数据到数仓 执行时长、失败重试
状态扫描 每 30 秒扫描超时未支付订单并自动取消 执行频率、扫描效率、幂等
报表生成 每周一 8:00 生成上周运营周报并发送邮件 任务依赖、资源消耗
缓存刷新 每 5 分钟刷新首页热点数据到缓存 执行耗时、数据一致性
心跳检测 每 10 秒检测下游服务健康状况 轻量高频、告警联动
临时修复 一次性补偿处理某段时间的异常数据 执行一次、可随时停止和重新触发

1.2 单机方案对比

Java 生态中单机定时任务的演进路线:

方案 核心机制 优势 劣势 适用阶段
Timer 单线程 + 优先队列,按堆排序找到最近要执行的任务 JDK 内置,零依赖 单线程,一个任务抛异常则整个 Timer 终止;不支持 Cron 表达式;只支持绝对时间或固定延迟 已淘汰,不建议使用
ScheduledExecutorService 线程池 + 延迟工作队列(DelayedWorkQueue),支持多线程并发执行 线程池管理,一个任务异常不影响其他任务;支持 fixedRate / fixedDelay 不支持 Cron 表达式;没有持久化;服务器重启任务丢失 简单的内存级定时逻辑
@Scheduled(Spring) 基于 ScheduledExecutorService 封装,支持 Cron 表达式和 fixedRate / fixedDelay 注解驱动,开发体验好;支持 Cron;与 Spring 生态无缝集成 同样没有持久化和集群能力;默认单线程串行执行(需显式配置线程池);fixedRate 是任务开始时刻的间隔(可能重叠),fixedDelay 是任务结束后的等待 中小型 Spring 应用的常规选择

@Scheduled 的核心参数区分:

fixedRate = 5000:  |──任务(2s)──|──等待3s──|──任务(2s)──|
                      ↑ 每 5s 开始一次,任务执行时间算在间隔内

fixedDelay = 5000: |──任务(2s)──|────等待5s────|──任务(2s)──|
                      ↑ 任务结束后等 5s 再开始下一次

注意:Spring 默认使用单线程执行 @Scheduled 任务。如果某个任务执行时间超过其间隔,会一直占用唯一线程导致后续任务推迟。需通过实现 SchedulingConfigurer 或配置 spring.task.scheduling.pool.size 显式指定线程池大小。

1.3 Cron 表达式速查

 ┌────────── 秒 (0-59)
 │ ┌────────── 分 (0-59)
 │ │ ┌────────── 时 (0-23)
 │ │ │ ┌────────── 日 (1-31)
 │ │ │ │ ┌────────── 月 (1-12)
 │ │ │ │ │ ┌────────── 周 (0-7, 0 和 7 都表示周日)
 │ │ │ │ │ │
 * * * * * *
表达式 含义
0 0 2 * * ? 每天凌晨 2:00
0 0/5 * * * ? 每 5 分钟
0 30 9 ? * MON-FRI 工作日 9:30
0 0 0 1 * ? 每月 1 号 0:00
0 0 0 L * ? 每月最后一天 0:00(L = Last)

二、Quartz——单机走向持久化的桥梁

Quartz 是 Java 生态中最成熟的定时任务调度框架,在 Spring @Scheduled 的基础上补充了任务持久化、集群调度、事务支持、任务管理 API 等关键能力。

2.1 核心架构

Quartz 由三个核心组件构成:

Scheduler(调度器)
    │
    ├── JobDetail(任务定义:做什么)
    │     - JobClass:指定执行逻辑的类
    │     - JobDataMap:传递任务参数(K-V 结构,序列化存储到数据库)
    │     - durability:是否持久化(true = 即使无 Trigger 关联也保留)
    │
    └── Trigger(触发器:什么时候做)
          - SimpleTrigger:固定间隔或执行 N 次
          - CronTrigger:基于 Cron 表达式
          - DailyTimeIntervalTrigger:每天某个时间段内的定时间隔
          - CalendarIntervalTrigger:基于日历的间隔

Job 与 Trigger 的关联关系:

  • 一个 JobDetail 可以绑定多个 Trigger(同一任务在不同时间点触发)
  • Trigger 和 JobDetail 是一对多关系(一个 Trigger 只绑定一个 JobDetail)
  • "松耦合"设计:JobDetail 和 Trigger 分离,可以独立管理——修改调度时间不需要改动 Job 代码,只需要调整 Trigger 配置

2.2 数据库持久化(JDBC JobStore)

Quartz 的默认存储是内存(RAMJobStore),服务重启后所有任务丢失。切换到 JDBC JobStore 后,任务定义和调度状态持久化到数据库,实现:

  • 服务重启后任务自动恢复
  • 基于数据库行锁的集群互斥执行(同一任务在集群中只有一个节点执行)

核心表结构(11 张表)与职责:

QRTZ_JOB_DETAILS         → 存储 JobDetail 定义
QRTZ_TRIGGERS            → 存储 Trigger 定义及其下次触发时间(含 Trigger 类型、关联的 Job 名称)
QRTZ_CRON_TRIGGERS       → 存储 Cron 表达式
QRTZ_SIMPLE_TRIGGERS     → 存储 SimpleTrigger 的重复次数和间隔
QRTZ_FIRED_TRIGGERS      → 存储正在执行中的 Trigger(运行时状态)
QRTZ_SCHEDULER_STATE     → 记录集群各节点的状态和最后检入时间
QRTZ_LOCKS               → 集群模式下的悲观锁表,保证同一时刻只有一个节点获取到触发权

Quartz 集群的调度流程:

Scheduler-A                        Scheduler-B
    │                                  │
    ├─ acquireNextTrigger() ──────────┤  (竞争 QRTZ_LOCKS 表的行锁)
    │   获取锁成功                      │   获取锁失败,继续等待
    │   SELECT ... FOR UPDATE (锁住该 Trigger 行)   
    │   更新 NEXT_FIRE_TIME              │
    │   插入 FIRED_TRIGGERS 记录          │
    │   释放锁                           │
    │                                  │
    ├─ execute(Job)                    │
    │                                  │
    ├─ 执行完成后:                       │
    │   删除 FIRED_TRIGGERS 记录          │
    │   更新 TRIGGER 的 NEXT_FIRE_TIME    │
    │                                  │

关键点:Quartz 的集群不是"负载均衡"而是"互斥竞争"。同一任务同一时刻只有一个节点抢到触发权并执行。如果希望同一任务的不同数据分片被多个节点并行处理,需要借助下面的分布式调度框架。

2.3 Quartz 的局限

局限 具体表现 导致的问题
无分片能力 一个任务实例只能在一个节点运行 海量数据处理时(如千万级对账),单节点压力大,无法水平扩展
无内置监控 没有 Dashboard,缺少任务执行状态可视化 排查失败原因只能查数据库和日志
失败处理薄弱 失败后无法自动重试,也无告警机制 需要手动编写补偿逻辑和监控
运维复杂 新增/暂停/恢复任务需代码或直接操作数据库 没有开箱即用的管理界面

三、XXL-JOB——轻量级分布式任务调度平台

XXL-JOB 是大众点评(许雪里)开源的分布式任务调度框架,采用中心化调度设计,由调度中心统一管理和分发任务,执行器负责接收调度指令并执行,是目前国内使用最广泛的开源分布式调度方案。

3.1 核心架构

┌──────────────────────────────────────┐
│          调度中心(Admin)              │
│  ┌────────────────────────────────┐   │
│  │  Web 管理界面(任务 CRUD、日志、报表) │   │
│  ├────────────────────────────────┤   │
│  │  调度引擎(Trigger + Router)      │   │
│  ├────────────────────────────────┤   │
│  │  注册中心(管理执行器上下线)         │   │
│  ├────────────────────────────────┤   │
│  │  RPC 通信层(HTTP,调度中心调用执行器) │   │
│  └────────────────────────────────┘   │
└──────────────┬───────────────────────┘
               │ HTTP 调度请求(任务参数、分片信息)
    ┌──────────┼──────────┐
    ▼          ▼          ▼
┌────────┐┌────────┐┌────────┐
│执行器 A ││执行器 B ││执行器 C │   ← 每个执行器是一个独立的应用实例
│(app-1) ││(app-2) ││(app-3) │
│glue代码 ││glue代码 ││glue代码 │
└────────┘└────────┘└────────┘

调度中心职责

  • 管理所有任务(新增、修改、删除、暂停、恢复、手动触发)
  • 基于 Cron 表达式计算任务的触发时间
  • 通过路由策略选择目标执行器
  • 向执行器发送 HTTP 调度请求,接收执行结果回调
  • 记录任务执行日志、提供 Dashboard 报表和失败告警

执行器职责

  • 启动时注册到调度中心(注册心跳 30 秒间隔)
  • 接收调度中心的 HTTP 请求,执行对应的 JobHandler
  • 执行完成后回调调度中心上报结果
  • 无状态设计:执行器不保存任务定义,完全由调度中心下发

3.2 核心概念

概念 说明
JobHandler 执行器中的任务处理类,通过 @XxlJob("handlerName") 注解声明,调度中心通过 handler 名称来调度
GLUE 模式 支持在线编辑和执行任务代码(Groovy 脚本),代码存储在调度中心,执行器从调度中心拉取并执行。适用于快速验证和临时任务,无需重启执行器
路由策略 决定将任务分配给哪个执行器的规则。包含:FIRST(第一个)、LAST(最后一个)、ROUND(轮询)、RANDOM(随机)、CONSISTENT_HASH(一致性哈希)、LEAST_FREQUENTLY_USED(最不经常使用)、LEAST_RECENTLY_USED(最近最久未使用)、FAILOVER(故障转移)、BUSYOVER(忙碌转移)、SHARDING_BROADCAST(分片广播)
阻塞处理策略 任务执行时间过长、上一个调度还没跑完时新调度到达的策略:SERIAL_EXECUTION(串行,排队等待)、DISCARD_LATER(丢弃后续调度)、COVER_EARLY(覆盖之前未执行的调度)
任务超时 任务执行超过指定时间后自动中断(底层调用 Thread.interrupt(),需要任务代码响应中断信号)
失败重试 执行失败后自动重试,可配置重试次数。重试不会占用新的 Cron 调度时间片

路由策略详解与选型建议:

策略 行为 适用场景
FIRST 固定发给第一个注册的执行器 只有一个执行器需要处理的任务(如单节点报表生成)
ROUND 轮询分配 无状态任务、每个执行器都能独立完成、不关心落在哪个节点
FAILOVER 主选一个执行器,失败后自动切换到另一个 对任务执行成功率要求高的场景
SHARDING_BROADCAST 所有执行器都收到调度请求,各自根据分片参数处理自己负责的数据段 分片任务的核心策略,见下文详解
CONSISTENT_HASH 相同任务参数(如相同的 taskId)始终路由到同一个执行器 需要"粘性"执行的场景(如利用本地缓存)

3.3 分片广播——XXL-JOB 的核心能力

分片广播(SHARDING_BROADCAST)是 XXL-JOB 区别于 Quartz 的最大特性。调度中心将任务广播给所有执行器,每个执行器根据分片参数处理自己负责的数据子集,实现并行处理。

分片参数传递:

执行器在 @XxlJob 方法中通过 XxlJobHelper.getShardIndex() 获取当前分片索引(从 0 开始),通过 XxlJobHelper.getShardTotal() 获取总分片数。

调度中心
   │
   ├── 执行器-A:shardIndex=0, shardTotal=3  → 处理 id % 3 == 0 的数据
   ├── 执行器-B:shardIndex=1, shardTotal=3  → 处理 id % 3 == 1 的数据
   └── 执行器-C:shardIndex=2, shardTotal=3  → 处理 id % 3 == 2 的数据

分片任务代码示例:

@Component
public class DataSyncJob {

    @XxlJob("syncUserDataHandler")
    public void syncUserData() {
        // 获取分片参数
        int shardIndex = XxlJobHelper.getShardIndex();   // 当前分片:0, 1, 2...
        int shardTotal = XxlJobHelper.getShardTotal();   // 总分片数:3

        // 基于分片参数查询各自负责的数据子集
        List<User> users = userService.getUsersByShard(shardIndex, shardTotal);

        for (User user : users) {
            processUser(user);
        }

        XxlJobHelper.handleSuccess("分片 " + shardIndex +
            " 处理完成,共 " + users.size() + " 条");
    }
}
-- 对应的 SQL 分片查询(id 取模)
SELECT * FROM user
WHERE status = 'ACTIVE'
  AND MOD(id, #{shardTotal}) = #{shardIndex}

分片设计的注意事项:

  • 数据分片键(如 id)应均匀分布,避免数据倾斜导致部分执行器负载过高
  • 执行器数量变化(扩缩容)时,分片总数随之变化,MOD(id, 3) 变为 MOD(id, 4) 会导致相同数据落入不同分片。需要配合一致性哈希预分片(固定分片数,如 1024,执行器动态认领分片)来避免
  • 分片任务应确保单次执行时长可控,否则一个执行器慢会拖慢整个批次的完成时间

3.4 XXL-JOB 的局限性

局限 具体表现
中心化调度 调度中心有单点风险,虽然可以通过 Nginx + MySQL 做主备,但本身不是去中心化的
无法动态扩缩容 执行器实例数变动后,分片总数变化可能导致数据倾斜或重复处理,需要配合预分片方案
无作业流编排 无法定义任务间的 DAG 依赖(A 成功后才执行 B),只能通过手动串联或外部编排
弹性能力弱 不支持根据任务积压量或系统负载动态调整执行器数量(需手动扩缩)

四、Elastic-Job——去中心化的弹性调度

Elastic-Job 是当当网开源的分布式调度方案(后进入 Apache ShardingSphere 生态),与 XXL-JOB 的中心化调度不同,它采用去中心化设计,每个执行节点通过 ZooKeeper 协调分片和故障转移。

4.1 核心架构

┌──────────────────────────────────────┐
│            ZooKeeper 集群             │
│  ┌────────────────────────────────┐   │
│  │ /elastic-job/                  │   │
│  │   ├── /config/{jobName}        │   │ ← 任务配置(Cron、分片数、参数)
│  │   ├── /instances/{jobName}     │   │ ← 在线实例列表(临时节点)
│  │   ├── /sharding/{jobName}      │   │ ← 分片分配结果(哪个实例负责哪个分片)
│  │   ├── /leader/election/{jobName}│  │ ← Leader 选举
│  │   └── /guarantee/{jobName}     │   │ ← 分布式屏障(保证所有分片启动后统一开始)
│  │                                  │   │
│  └────────────────────────────────┘   │
└──────────────┬───────────────────────┘
               │
    ┌──────────┼──────────┐
    ▼          ▼          ▼
┌────────┐┌────────┐┌────────┐
│ 节点 A  ││ 节点 B  ││ 节点 C  │
│ 分片 0  ││ 分片 1  ││ 分片 2  │  ← 各节点根据 ZK 分配结果认领分片
└────────┘└────────┘└────────┘

去中心化的工作流程:

  1. 所有节点注册到 ZooKeeper(创建临时节点表示在线)
  2. 通过 ZooKeeper 选举出一个 Leader 节点(临时顺序节点最小者)
  3. Leader 负责将分片分配给所有在线节点,并将分配结果写入 ZooKeeper
  4. 每个节点从 ZooKeeper 读取自己负责的分片编号,执行对应的数据处理逻辑
  5. 故障转移:某节点宕机 → 临时节点消失 → ZooKeeper 触发重新选举和分片重分配 → 宕机节点的分片被分配到其他健康节点

4.2 任务分片策略

Elastic-Job 提供三种分片策略,适用于不同场景:

分片策略 机制 适用场景
AverageAllocationJobShardingStrategy(默认) 平均分配,如 3 个节点 8 个分片 → [0,1,2] / [3,4,5] / [6,7] 通用场景,各节点处理能力相当
OdevityShardingByNameJobShardingStrategy 根据 IP 末尾奇偶性分配,奇数 IP 认领奇数分片、偶数 IP 认领偶数分片 有主备机房,需按奇偶特性分配
RotateServerByNameJobShardingStrategy 轮询分配 各节点处理能力差异较大,希望均分

4.3 与 XXL-JOB 的架构对比

对比维度 XXL-JOB Elastic-Job
调度模式 中心化(调度中心 → 执行器) 去中心化(每个节点自行触发,通过 ZK 协调分片)
依赖组件 MySQL(任务和日志存储) ZooKeeper(注册中心 + 协调)
分片方式 分片广播(调度中心下发分片参数) ZK 分片分配(Leader 分配 + 各节点认领)
故障转移 调度中心检测执行器心跳超时,分配给其他执行器 ZooKeeper 感知临时节点消失,触发分片重分配
监控 UI 自带完整的 Web 管理界面 需要额外接入(如 Elastic-Job-Lite-Console)
作业流编排 不支持 不支持(两者都无原生 DAG)
弹性伸缩 弱(执行器扩缩影响分片一致性) 强(节点变化自动触发分片重分配,配合 ZooKeeper 实现弹性)
学习成本 低,开箱即用 中,需理解 ZK 协调机制

选型建议:如果团队追求快速落地和管理便捷,XXL-JOB 是首选。如果需要弹性扩缩容和去中心化高可用,或者已经在使用 ZooKeeper(降低额外依赖),Elastic-Job 更合适。


五、更高阶的选择

5.1 PowerJob(原 OhMyScheduler)

新一代分布式调度框架,在 XXL-JOB 的基础上补充了MapReduce 任务、工作流 DAG、容器化部署等能力:

  • 支持 Map/MapReduce 编程模型,将海量数据拆分到多个 Worker 并行处理并聚合结果
  • 内建工作流引擎,支持任务间的 DAG 依赖编排(A → B → C 或 A + B → C)
  • 支持执行器动态发现和负载均衡,对 K8s 环境更友好

5.2 云原生场景的 CronJob

在 Kubernetes 环境下,可用 CronJob 资源替代传统的调度框架:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: data-sync
spec:
  schedule: "0 2 * * *"         # 每天凌晨 2 点
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: sync
            image: data-sync:latest
          restartPolicy: OnFailure

K8s CronJob 的优势:天然支持资源隔离(每个任务独立 Pod)、失败重试(backoffLimit)、并发策略(Allow / Forbid / Replace)、历史记录上限(successfulJobsHistoryLimit)。但缺乏任务管理界面和分片能力,且调度粒度受限于 K8s 调度器性能。


六、分布式定时任务的共性问题

无论选择哪个框架,以下问题在所有分布式调度场景中都需要重点关注:

6.1 任务幂等性

同一任务可能因为框架重试、手动触发、故障恢复等原因,在同一数据上执行多次。必须设计好幂等:

  • 数据库唯一约束:为每批数据加处理标记(如 processed = false),通过 UPDATE ... WHERE processed = false 确保一次处理
  • 执行流水号:每次调度携带唯一的执行 ID(如 traceId + shardIndex),处理数据前检查该 ID 是否已处理过
  • 乐观锁(版本号):更新数据时带版本号条件,防止并发覆盖

6.2 任务耗时过长导致的重叠执行

  • 阻塞处理策略:在 XXL-JOB 中选择 SERIAL_EXECUTION(排队)或 DISCARD_LATER(丢弃)
  • 任务拆分:将大任务拆分为小块(如按 ID 范围分批次),每批次快速完成,避免长时间占用
  • 超时中断:设置合理的任务超时时间,防止"僵尸"任务一直占用线程

6.3 时间漂移与时钟同步

分布式环境中,各节点的系统时钟可能存在偏差。Cron 表达式的计算基于服务器本地时间:

  • 所有节点配置 NTP(Network Time Protocol) 自动校时
  • 对于强时间依赖的业务(如秒杀开始时间),不要依赖各节点的本地时钟,而是从调度中心统一传递 triggerTime 参数

6.4 数据库扫描效率

高频任务(每 5 秒扫描一次待处理数据)对数据库的压力不可忽视:

  • 为扫描字段(如 status = PENDING AND next_execute_time < NOW())建立复合索引
  • 使用标记字段 + limit 分批处理,避免一次性 SELECT *
  • 避免在高峰期执行大批量扫描,错峰调度

6.5 调度中心高可用

框架 高可用方案
XXL-JOB 调度中心多实例部署 + 共享 MySQL 数据库。调度逻辑通过数据库行锁(SET SQL_SELECT_LIMIT = 1 + for update)实现互斥,只有一个调度中心实例执行调度
Elastic-Job 去中心化,每个节点各自运行,通过 ZooKeeper 选举 Leader 负责分片分配。无单点:Leader 宕机后自动重新选举
Quartz JDBC JobStore + SELECT ... FOR UPDATE 行锁实现集群互斥

七、方案选型决策

定时任务规模如何?
├─ 单机即可,任务数量 < 10,无分布式需求
│   └─ Spring @Scheduled(配置线程池)
│
├─ 需要持久化和集群互斥,但不需要分片
│   └─ Quartz + JDBC JobStore
│
├─ 需要分片并行处理 + 可视化管理 + 快速落地
│   └─ XXL-JOB(中心化调度,自带管理界面)
│
├─ 需要弹性扩缩容 + 去中心化 + 已在用 ZK
│   └─ Elastic-Job(去中心化,依赖 ZK 协调)
│
├─ 需要 DAG 工作流编排 + MapReduce 并行计算
│   └─ PowerJob
│
└─ K8s 环境 + 任务逻辑简单 + 不需要管理界面
    └─ K8s CronJob

八、核心原则总结

  1. 幂等是底线:定时任务天然存在重复执行可能(重试、手动触发、故障恢复),每个任务必须在设计阶段就考虑幂等
  2. 监控先行:任务上线前先配置好执行时长监控、失败率告警和僵尸任务检测。没有监控的定时任务就是一颗定时炸弹
  3. 单次执行要快:尽量减少单次调度的执行时间。长时间执行的任务应考虑分片或拆分为多个子任务
  4. 避免依赖执行顺序:不要在不同定时任务之间隐式依赖执行顺序。如果有依赖,使用工作流 DAG 显式编排
  5. 时钟是分布式的基础:确保所有节点的 NTP 时间同步,不要依赖本地时钟做跨节点排序
  6. 扫描类任务要有索引:高频扫描数据库的任务,其 WHERE 条件必须有对应的复合索引,否则随着数据量增长任务只会越来越慢