Skip to content

定时任务选型与实现:从单机到分布式 ​

看在前面 ​

定时任务题的标准问法是"你们用的什么调度框架,为什么"。同目录已有分布式定时任务对比逐个介绍框架,这篇补选型的推理过程。先按需求维度拆解(单机/分布式/秒级/海量)。再讲两个原理考点——时间轮(HashedWheelTimer 与 JDK DelayQueue 对比,与 Kafka 时间轮同一原理)与 XXL-Job 的调度架构(分片广播)。面试要的是"给我四个参数我能推出选型",不是报菜名。

一、需求维度拆解:选型先问四个问题 ​

text
Q1 部署形态:单机 or 集群?
    单机   → @Scheduled / ScheduledExecutorService / 时间轮,到此为止
    集群   → 必须回答"多实例会不会重复执行" → 锁(DB/Redis)或调度中心 → Q2

Q2 时间精度:分钟级 or 秒级 or 准点?
    分钟级 cron → 任何方案都行(重复度低、容错窗口大)
    秒级/海量延迟(订单超时、重试退避)→ 优先级队列/时间轮的战场 → 第三节

Q3 任务量级:几十个 or 海量(万级/分钟)?
    几十个 cron → Quartz/XXL-Job 调度中心轻松覆盖
    海量一次性延迟任务 → 每任务一条 DB 记录 + 轮询扫描 = 数据库灾难 → 时间轮/MQ 延迟消息

Q4 治理需求:要手工触发/重跑/灰度/告警吗?
    要 → 带控制台的调度平台(XXL-Job)几乎必选
    不要 → Spring @Scheduled + 分布式锁的最小方案

选型结论速查(按组合直接落地):

场景方案一句话理由
单机、少量@Scheduled / ScheduledExecutorService零组件;注意 @Scheduled 默认单线程串行
集群、少量、快速实现@Scheduled + Redis/DB 分布式锁复用现有设施;锁要带过期与续期
集群、要治理、分钟级业务任务XXL-Job调度/执行分离 + 控制台 + 分片
海量延迟/重试时间轮(Netty HashedWheelTimer)/ MQ 延迟消息O(1) 插入,扫描成本与任务数解耦
复杂依赖编排(DAG)Airflow/DolphinScheduler 一类cron 语义不够用,要任务依赖图

二、单机基石:JDK 的三个选择与它们的问题 ​

java
// 1. Timer:单线程执行所有任务,一个任务抛异常 → 整个 Timer 线程死掉(已淘汰)
// 2. ScheduledExecutorService:多线程池化,任务异常只杀死自身,但仍是"堆"实现
scheduler.scheduleAtFixedRate(() -> reconcile(), 0, 5, TimeUnit.MINUTES);
// 3. Spring @Scheduled:默认单线程调度器,慢任务会饿死后续任务
@Scheduled(cron = "0 0 2 * * ?")
public void dailyReport() { ... }
// 生产必配:spring.task.scheduling.pool.size > 1,且任务体 try-catch 全包

ScheduledThreadPoolExecutor 的底层就是 DelayQueue(基于优先级堆):插入/删除 O(log n),到期取头 O(log n)(取完要调整堆)。所有任务的出入竞争同一把锁。几十个任务是完美的。当任务量到十万、百万级,且高频插入/取消(连接超时、重试退避、心跳检测),堆的两个代价被放大。两个代价是 O(log n) 的调整成本和全局锁竞争。这正是时间轮被发明出来的场景。

三、时间轮原理:用空间换时间,O(1) 的定时器 ​

text
HashedWheelTimer(Netty 实现)

     0    1    2    3    4    5    6    7      ← 槽位数 wheel = 8
   ┌────┬────┬────┬────┬────┬────┬────┬────┐
   │    │task│    │    │    │task│task│    │    每个槽 = 到期 tick 相同的任务链表
   └────┴────┴────┴────┴────┴────┴────┴────┘
      ▲
      └─ currentTime 指针,worker 线程每 tickDuration(如 100ms) 前进一格
         走到哪格 → 遍历该格链表 → remainingRounds == 0 的执行,否则 rounds-1

插入 deadline=550ms 的任务:
  ticks = 550 / 100 = 5.5 → 5 圈内  →  槽 = (当前指针 + 5) % 8,rounds = 0
插入 deadline=1050ms:
  ticks = 10.5 → 10 → 超过一圈(8) →  槽 = (当前指针 + 10 % 8),rounds = 10 / 8 = 1
  指针每经过该槽一次 rounds-1,减到 0 且指针再到达时执行

与 JDK DelayQueue 的对比(面试核心表):

维度DelayQueue(堆)HashedWheelTimer
插入/删除O(log n),全量一把锁O(1)(定位槽 + 链表头插),锁竞争分散
到期触发take 阻塞在队头,精度高(无固定 tick)指针按 tick 前进,精度 = tickDuration(有量化误差)
大量已插入任务的取消从堆中摘除要调整堆链表节点标 canceled,顺带摘除 O(1)
适用任务量小、精度要求高、要严格全局时间序任务量巨大(10w+)、可接受百 ms 级误差、增删频繁
代表ScheduledThreadPoolExecutorNetty IdleState 心跳、Kafka 延迟操作、RocketMQ 定时消息

两个必须能答的边界。精度由 tickDuration 与轮长的乘积决定,要秒级精度就把 tick 调小、槽位加多(内存换精度)。持久化——HashedWheelTimer 是纯内存结构,进程重启任务全丢。业务上要么允许丢(心跳类本来就依赖对端重连)。要么配存储兜底(落库 + 启动恢复,或改用 MQ 延迟消息)。

四、分布式调度:XXL-Job 架构与分片广播 ​

text
        ┌────────────────────────────┐
        │   调度中心 xxl-job-admin    │  集群部署(DB 行锁保证调度不重复)
        │  cron 表达式/失败重试/告警   │  控制台:手工触发/终止/查执行日志
        └──────┬─────────────────────┘
               │ 1. 到点向执行器发 HTTP 触发(不是 MQ,是直连 RPC)
               ▼
   ┌───────────┴───────────┐
   ▼                       ▼
┌─────────┐  ┌─────────┐  ┌─────────┐   执行器集群(业务服务内嵌 xxl-job-core)
│ 执行器A  │  │ 执行器B  │  │ 执行器C  │   自动注册到 admin,故障摘除
└─────────┘  └─────────┘  └─────────┘
路由策略:轮询 / 故障转移 / 分片广播 ...
  • 调度与执行分离:cron 触发只发生在 admin(集群下靠 DB 锁保证一台触发),执行器只管跑。对比 Quartz 集群"每个节点都是完整调度器 + 数据库锁竞争",XXL-Job 把治理面收敛到一处,执行器无状态。
  • 分片广播(大任务并行处理的招牌能力):一次触发广播给全部执行器。每个执行器拿到 shardIndex/shardTotal,各自处理数据的一片:
java
@XxlJob("syncOrderHandler")
public void syncOrder() {
    int shardIndex = XxlJobHelper.getShardIndex();   // 本实例编号 0,1,2...
    int shardTotal = XxlJobHelper.getShardTotal();   // 在线执行器总数
    // 按 id 取模切数据:1000 万订单 → 3 台各扫 ~330 万,总耗时近似除以机器数
    orderMapper.scanByShard(shardIndex, shardTotal); // where id % total = index
}

分片的两个工程注意。分片总数 = 触发时刻的在线执行器数,扩缩容后下一次触发自动变化。任务逻辑必须只依赖 id % shardTotal 这类当下取模(不能缓存"上次分到哪些 id")。一片失败只影响该片:admin 按执行器粒度记成功失败,重试也只重试失败分片。这是它对比"MQ 广播分流"的治理优势。

追问链 ​

面试官:@Scheduled 加个 Redis 锁不就行了,为什么还要调度中心?

锁方案能防重复,防不了其余四件事。一是错过补跑(实例重启错过 2 点的任务,锁方案没人补触发)。二是手工触发/重跑(出问题想立刻跑一次验证)。三是执行历史与告警(失败了谁通知你)。四是动态改 cron(改配置 vs 改代码发版)。

锁是"防重"的最小解,调度中心是"治理"的完整解。按需求维度拆解(第一节 Q4)再选,别把锁方案吹成万能。

面试官:时间轮的 remainingRounds 不维护行不行,直接多级时间轮?

单层轮的槽位正比于"最大延迟/精度",要支持小时级 + 百 ms 精度就得几万槽。多级时间轮(Kafka 的实现:秒级轮 + 分级轮 + 时级轮)用"降级迁移"替代 rounds。高层轮到期后任务迁入低层轮,每层槽位数恒定(Kafka 每层 64 槽却能覆盖任意延迟)。remainingRounds 是单层轮的省内存近似,多级轮是它的正解升级,两者是同一思想的两个工程档位。

面试官:XXL-Job 调度中心挂了会怎样?

不再有新触发(执行器无状态、不感知 cron),已触发的任务照常跑完。admin 是集群 + DB,单点故障自动切换,全挂属于运维事故。对比 elastic-job 无中心化(ZK 协调、每个节点都能调度)。中心化牺牲一个组件换治理收敛,无中心化把复杂度摊到协调系统。这两派的取舍本身是面试考点。

面试官:秒级精度的海量延迟任务,时间轮和 MQ 延迟消息怎么选?

延迟消息(RocketMQ 定时消息)自带持久化与投递可靠性。海量下依赖 broker 容量,且 RocketMQ 4.x 只有固定级别(18 档)到不了任意秒。时间轮精度任意、吞吐高,但纯内存要自己做持久化恢复。默认判断:可丢/可重算的(超时关单有对账兜底)用时间轮,不可丢的(通知类)用 MQ。两层的组合(时间轮到点投 MQ)也是常见架构。

面试官:定时任务跑一半实例挂了,任务怎么保证不丢?

先分语义:幂等可重跑的任务直接整片重跑(分片取模保证覆盖完整)。不可重入的要断点续跑(处理进度落库,重启从 checkpoint 继续)。框架层的失败重试(XXL-Job 按分片重试)只是触发层保障。业务层必须自己回答"重跑安全吗"——和消息队列消费幂等是同一道题。

面试官视角小结 ​

  1. 选型四问:部署形态(单机/集群)、精度(分钟/秒级)。量级(几十/海量)、治理(控制台/重跑/告警)。四答定方案,比背框架名值钱;
  2. JDK 三件:Timer 已淘汰(单线程+异常全灭)、ScheduledExecutorService(堆实现)、@Scheduled 默认单线程要配池;
  3. 时间轮:槽 + tick 指针 + remainingRounds,插入删除 O(1)。对比 DelayQueue(O(log n) 堆、精度高、全局锁)——海量高增删选轮、小量高精度选堆。纯内存不持久化是边界;
  4. XXL-Job:调度(admin 集群 + DB 锁)与执行(无状态执行器)分离。分片广播 = 全量触发 + id % total 取模切片,扩缩容自动重分,失败按片重试;
  5. 可靠性收口:错过补跑/断点续跑/幂等重跑是框架不管的业务语义。最后一问永远落在"重跑安全吗"。

2026-10 新增(Round1 补缺)。