定时任务
定时任务:从 Timer 到 XXL-JOB 再到时间轮
每天凌晨跑报表、每隔 5 分钟扫描超时订单、每周一发周报邮件——定时任务无处不在。但同样是"定时执行",用 Timer 和用 XXL-JOB 之间的差距,就像用闹钟和用智能日程管理系统的差距。这篇文章带你从最原始的方案走到最成熟的方案,彻底搞懂定时任务。
一、Java 原生方案
1.1 Timer:最简单但最脆弱
Timer 是 JDK 自带的定时调度器,核心就两个东西:
- TaskQueue:按执行时间排序的任务队列(小顶堆)
- TimerThread:一个后台线程,不断从队列中取最早的任务执行
工作原理:TimerThread 内部跑一个 while(true) 循环,每次取出队列头部的任务,看看执行时间到了没——到了就执行,没到就 wait 等一会儿。新任务加入队列后会 notify 唤醒线程重新检查。
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("定时任务执行了");
}
}, 1000, 5000); // 延迟 1 秒后开始,每 5 秒执行一次Timer 的五大坑:
- 单线程:所有任务串行执行,一个任务慢了其他全被拖住
- 异常不隔离:一个任务抛出未捕获异常,整个 Timer 线程挂掉,所有任务全完
- 精度差:依赖系统时间,任务执行时间可能不准
- 内存泄漏:cancel 只是标记取消,任务对象仍在队列中占位
- 纯内存:应用重启后所有任务丢失
生活类比:Timer 就像一个只有一个闹钟的人,闹钟响了他去做第一件事,做完了再看下一个闹钟。如果第一件事做了一小时,后面的闹钟全白响了。
1.2 ScheduledThreadPoolExecutor:Timer 的升级版
JDK 5 引入的 ScheduledThreadPoolExecutor 解决了 Timer 的单线程和异常问题:
ScheduledExecutorService executor = Executors.newScheduledThreadPool(4);
executor.scheduleAtFixedRate(() -> {
System.out.println("任务执行");
}, 0, 5, TimeUnit.SECONDS);优势:
- 多线程:线程池执行,一个任务慢了不影响其他任务
- 异常隔离:一个任务异常不会影响其他任务和线程池本身
- 更灵活:支持
scheduleAtFixedRate(固定频率)和scheduleWithFixedDelay(固定延迟)
但它仍然是基于 JVM 内存的,重启就没了,也不支持分布式。适合单机上的轻量级定时任务。
1.3 DelayQueue
基于延迟时间的无界阻塞队列,元素必须实现 Delayed 接口。从队列中取元素时,如果延迟时间没到就阻塞等待。
适合实现"延迟多久后执行"这类场景(比如订单 30 分钟未支付自动关闭),但同样是纯内存方案。
二、Spring @Scheduled
Spring 提供了 @Scheduled 注解,是最常用的轻量级定时任务方案:
@Component
public class ReportJob {
@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨 2 点
public void generateDailyReport() {
// 生成日报
}
@Scheduled(fixedRate = 300000) // 每 5 分钟
public void checkTimeoutOrders() {
// 扫描超时订单
}
}优点:简单方便,加个注解就能用,不需要引入额外中间件。
致命缺点:
| 问题 | 说明 |
|---|---|
| 单机执行 | 集群中每台机器都会执行,导致任务重复 |
| 不可持久化 | 基于 JVM 内存,重启后调度状态丢失 |
| 不可动态修改 | 想改 cron 表达式得改代码重新部署 |
| 无故障转移 | 机器挂了任务就挂了 |
| 无可视化 | 没有控制台,看不到执行记录和错误信息 |
集群并发问题的临时方案:加分布式锁。但这把简单问题复杂化了——不如直接上分布式任务框架。
三、XXL-JOB:分布式定时任务的标杆
3.1 核心架构
XXL-JOB 由两部分组成:
- 调度中心(xxl-job-admin):负责任务管理和调度触发,有 Web 控制台
- 执行器(你的应用):接收调度中心的调度请求,执行具体任务
3.2 相比 @Scheduled 的六大优势
| 维度 | @Scheduled | XXL-JOB |
|---|---|---|
| 分布式支持 | 无,集群会重复执行 | 通过 DB 锁保证只触发一次 |
| 持久化 | 无 | 调度数据存数据库 |
| 故障转移 | 无 | 执行器挂了自动切换到其他实例 |
| 可视化 | 无 | Web 控制台,查看配置/日志/报错 |
| 动态修改 | 改代码重部署 | 控制台改配置即时生效 |
| 分片任务 | 不支持 | 支持,多台机器并行处理 |
3.3 一致性保证:DB 锁
XXL-JOB 如何保证集群中同一任务只触发一次?答案是数据库悲观锁。
在 JobScheduleHelper 的调度线程中,执行任务前会先获取锁:
SELECT * FROM xxl_job_lock WHERE lock_name = 'schedule_lock' FOR UPDATE只有拿到锁的那个调度中心实例才能执行调度,其他实例会被阻塞。锁在事务提交时释放。
使用前需要建
xxl_job_lock表并插入一条schedule_lock记录。
3.4 分片任务:集群的真正威力
分片模式可以把一个大任务拆成多个子任务,分配给集群中的多台机器并行处理。比如处理 100 万条数据,10 台机器每台只需处理 10 万条。
@XxlJob("shardingJob")
public void execute() {
int shardIndex = XxlJobHelper.getShardIndex(); // 当前分片索引
int shardTotal = XxlJobHelper.getShardTotal(); // 总分片数
// 用取模的方式分配数据
List<Order> orders = orderDao.selectByMod(shardIndex, shardTotal);
for (Order order : orders) {
processOrder(order);
}
}XXL-JOB 的分片是静态分片——分片数等于执行器实例数,需要手动配置。如果新增或减少机器,需要调整分片参数。
3.5 退避策略
任务失败后不能立即疯狂重试,否则会加剧下游压力。常见的退避策略:
| 策略 | 说明 | 适用场景 |
|---|---|---|
| 不退避 | 立即重试 | 偶发性小故障 |
| 固定退避 | 每次等相同时间(如 5 秒) | 简单场景 |
| 指数退避 | 等待时间翻倍增长:1s → 2s → 4s → 8s | 持续性故障 |
| 指数随机退避 | 指数增长 + 随机因子 | 避免多个客户端同时重试(惊群效应) |
很多 MQ 的消费失败重投就用的指数退避——失败次数越多,下次投递的间隔越长。
四、时间轮算法
不管是 Timer、ScheduledThreadPoolExecutor 还是 XXL-JOB,底层都需要一种高效的方式来管理"什么时间该执行什么任务"。时间轮就是其中最优雅的数据结构之一。
4.1 基本原理
想象一个钟表表盘,上面有 60 个刻度(槽位),每个刻度代表 1 秒。一个指针每秒钟走一格。
当你要在 3 秒后执行一个任务,就把这个任务挂到"第 3 格"的链表上。指针走到第 3 格时,执行链表上的所有任务。
4.2 超过一圈怎么办
60 个槽位只能表示 60 秒内的任务。如果要 200 秒后执行怎么办?
方案一:round 标记
给任务加一个 round 字段:round = 200 / 60 = 3,放到 slot = 200 % 60 = 20 的位置。
每次指针到达 slot 20 时,检查任务的 round 是否为 0:
- 不为 0 → round 减 1,跳过
- 为 0 → 执行任务
问题:每次都要遍历槽位上的所有任务检查 round,效率不够高。
方案二:分层时间轮(推荐)
设计多层时间轮,就像钟表有秒针、分针、时针:
- 秒级时间轮:60 个槽位,每秒走一格
- 分钟级时间轮:60 个槽位,每分钟走一格
- 小时级时间轮:24 个槽位,每小时走一格
200 秒后执行的任务:先放到分钟级时间轮的"第 3 分钟"槽位。当分钟级指针走到第 3 分钟时,把任务"降级"到秒级时间轮的"第 20 秒"槽位。
分层时间轮避免了 round 方案的遍历开销,是 Kafka、Netty 等框架采用的方案。
4.3 典型应用
| 框架/库 | 用途 |
|---|---|
| Netty | HashedWheelTimer,管理连接超时和重连 |
| Kafka | 管理消息过期和清理 |
| XXL-JOB | 7.28 版本后替换 Quartz,用时间轮做任务调度 |
| Akka | 调度器中管理并发任务 |
五、定时任务扫表的优化
"定时任务 + 扫表"是最常见的业务模式(比如扫描超时订单、重试失败消息)。当数据量增长后,会遇到三个典型问题:
问题一:数据量大,扫表慢
解决方案:
- 在 state 字段上加索引。虽然区分度不高,但 INIT 状态的数据通常只占 10%,索引能大幅提升过滤效率
- 多线程并行扫表,通过 ID 分段或 bizId 取模隔离,避免多线程扫到同一条数据
- 下游处理做好幂等,兜底重复执行的情况
问题二:集中扫表压垮数据库
解决方案:
- 扫备库而不是主库,备库没有业务写入压力
- 分库后每个库的压力降低,连接数和 IO 都能分摊
问题三:定时任务有延迟
定时任务是"到点才检查",天然存在延迟。如果对实时性要求高,可以用延迟消息替代扫表——创建订单时发一条 30 分钟后投递的延迟消息,消息到期后自动触发关闭逻辑。
还有一个实用技巧:同步转异步。在同步接口中先尝试执行一次,成功了就直接推进状态;失败了也不怕,异步定时任务会兜底重试。这样大多数请求在同步阶段就完成了,定时任务只需要处理少量失败的长尾数据。
六、面试高频题
题目一:Java 中有哪些实现定时任务的方式?
由轻到重:Timer → ScheduledThreadPoolExecutor → DelayQueue(JDK 原生)→ Spring @Scheduled → Quartz → XXL-JOB / Elastic-Job / PowerJob(分布式框架)。单机轻量场景用 @Scheduled,分布式场景用 XXL-JOB。
题目二:XXL-JOB 相比 @Scheduled 有什么优势?
六个方面:分布式支持(DB 锁保证一致性)、任务持久化、故障转移、可视化控制台、动态配置修改、分片任务。@Scheduled 是单机方案,XXL-JOB 是分布式方案。
题目三:什么是时间轮?
一种高效的定时任务调度数据结构。把时间划分成固定数量的槽位,任务挂到对应槽位上,指针每走一格就执行该槽位上的任务。超过一圈的任务通过分层时间轮解决——类似钟表的秒针/分针/时针机制。Netty、Kafka、XXL-JOB 都有应用。
题目四:定时任务扫表有什么缺点?怎么优化?
三个问题:数据量大扫得慢、集中扫表压垮数据库、定时执行有延迟。优化方案:加索引 + 多线程分段扫描、扫备库或分库、用延迟消息替代扫表实现准实时触发。
小结
| 知识点 | 一句话记忆 |
|---|---|
| Timer | 单线程 + 异常不隔离,生产环境别用 |
| ScheduledThreadPoolExecutor | Timer 的多线程升级版,适合单机轻量任务 |
| @Scheduled | Spring 注解式定时任务,简单但只能单机 |
| XXL-JOB | 分布式定时任务标杆,DB 锁 + 可视化 + 分片 |
| 时间轮 | 高效调度数据结构,分层解决超时间范围问题 |
| 扫表优化 | 索引 + 分段扫 + 扫备库 + 延迟消息 |
定时任务看似简单,但在分布式环境下要考虑一致性、容错、性能——选对方案比写代码重要得多。
补充:XXL-JOB 实战配置
快速接入步骤
第一步:部署调度中心
XXL-JOB 的调度中心是一个独立的 Spring Boot 应用(xxl-job-admin),需要单独部署。它依赖一个 MySQL 数据库来存储任务配置和调度记录。
-- 需要提前执行建表 SQL(官方仓库中的 tables_xxl_job.sql)
-- 包含以下核心表:
-- xxl_job_info 任务信息表
-- xxl_job_log 任务日志表
-- xxl_job_lock 任务调度锁表
-- xxl_job_group 执行器信息表
-- xxl_job_registry 执行器注册表第二步:执行器接入(你的业务应用)
在 Spring Boot 项目中引入依赖:
<dependency>
<groupId>com.xuxueli</groupId>
<artifactId>xxl-job-core</artifactId>
<version>2.4.0</version>
</dependency>配置执行器:
xxl:
job:
admin:
addresses: http://xxl-job-admin:8080/xxl-job-admin
accessToken: your-token
executor:
appname: your-app-name
port: 9999
logpath: /data/applogs/xxl-job
logretentiondays: 30第三步:编写任务 Handler
@Component
public class DemoJobHandler {
@XxlJob("demoJobHandler")
public void execute() throws Exception {
XxlJobHelper.log("任务开始执行");
// 你的业务逻辑
String param = XxlJobHelper.getJobParam(); // 获取任务参数
processData(param);
XxlJobHelper.log("任务执行完成");
}
}第四步:在控制台配置任务
登录 xxl-job-admin 控制台,新增任务:
- 执行器:选择你的应用
- 任务描述:每日订单对账
- Cron:
0 0 2 * * ?(每天凌晨 2 点) - JobHandler:
demoJobHandler - 路由策略:轮询/随机/分片广播等
路由策略
XXL-JOB 提供多种路由策略,决定集群中哪台机器执行任务:
| 策略 | 说明 | 适用场景 |
|---|---|---|
| FIRST | 固定第一台 | 简单场景 |
| LAST | 固定最后一台 | 简单场景 |
| ROUND | 轮询 | 均匀分布 |
| RANDOM | 随机 | 均匀分布 |
| CONSISTENT_HASH | 一致性哈希 | 同一参数路由到同一台机器 |
| LEAST_FREQUENTLY_USED | 最不经常使用 | 负载均衡 |
| LEAST_RECENTLY_USED | 最近最久未使用 | 负载均衡 |
| FAILOVER | 故障转移 | 高可用场景 |
| BUSYOVER | 忙碌转移 | 避免发到忙碌实例 |
| SHARDING_BROADCAST | 分片广播 | 分片任务 |
最常用的是 ROUND(轮询)和 FAILOVER(故障转移)。分片任务用 SHARDING_BROADCAST。
任务超时和重试
任务超时时间:30(秒)
失败重试次数:3如果任务执行超过 30 秒,会被自动终止。失败后自动重试 3 次。配合退避策略使用效果更好。
报警配置
XXL-JOB 支持任务执行失败时发送报警邮件。在 xxl-job-admin 的配置文件中配置邮件服务:
spring:
mail:
host: smtp.example.com
port: 465
username: alarm@example.com
password: xxx在任务配置中填写报警邮件地址,任务失败时会自动发送邮件通知。
补充:PowerJob 与动态分片
PowerJob vs XXL-JOB
PowerJob 是一个更年轻的分布式任务调度框架,相比 XXL-JOB 有几个独特的优势:
无锁化设计:XXL-JOB 依赖数据库悲观锁来保证调度一致性,数据库是瓶颈。PowerJob 使用无锁化设计,通过 Akka 的 Actor 模型实现分布式协调,性能更好。
动态分片(MapReduce 模式):XXL-JOB 的分片是静态的——分片数等于执行器实例数,新增机器需要手动调整。PowerJob 的 MapReduce 模式是动态的——根据数据量自动生成子任务,然后均匀分配给所有执行器。
@Component
public class DataProcessJob extends MapReduceProcessor {
@Override
protected ProcessResult map(TaskContext context, List<SubTask> subTasks) {
// 动态生成子任务:比如按用户 ID 范围分片
List<Long> allUserIds = userService.getAllUserIds();
List<List<Long>> partitions = Lists.partition(allUserIds, 1000);
for (int i = 0; i < partitions.size(); i++) {
subTasks.add(SubTask.create("batch-" + i, partitions.get(i)));
}
return new ProcessResult(true);
}
@Override
protected ProcessResult reduce(TaskContext context, List<TaskResult> results) {
long successCount = results.stream().filter(TaskResult::isSuccess).count();
return new ProcessResult(true,
String.format("完成 %d/%d 批次", successCount, results.size()));
}
}DAG 工作流:PowerJob 支持 DAG(有向无环图)工作流,可以定义多个任务之间的依赖关系——A 完成后执行 B 和 C,B 和 C 都完成后执行 D。XXL-JOB 不支持这种复杂的编排。
选型建议:
- 如果团队已经在用 XXL-JOB,且当前满足需求,不需要迁移
- 如果是新项目,且有分片任务或工作流需求,PowerJob 值得考虑
- 如果只是简单的定时任务(每天跑个报表),XXL-JOB 足矣
补充:Cron 表达式速查
Cron 表达式是定时任务配置的通用语言,格式为 秒 分 时 日 月 周 [年]:
* * * * * *
秒 分 时 日 月 周| 符号 | 含义 | 示例 |
|---|---|---|
| * | 任意值 | * = 每秒/每分/每时... |
| ? | 不指定(日和周互斥时用) | 日=? 表示不按日触发 |
| - | 范围 | 1-5 = 1到5 |
| , | 列举 | 1,3,5 = 第1、3、5 |
| / | 步长 | 0/15 = 从0开始每15 |
| L | 最后 | 日=L 表示月末最后一天 |
| W | 最近工作日 | 日=15W 表示最近15号的工作日 |
常用 Cron 表达式:
# 每天凌晨 2 点
0 0 2 * * ?
# 每隔 5 分钟
0 0/5 * * * ?
# 每隔 30 秒
0/30 * * * * ?
# 每天上午 10 点和下午 2 点
0 0 10,14 * * ?
# 每月最后一天凌晨 1 点
0 0 1 L * ?
# 工作日(周一到周五)早上 9 点
0 0 9 ? * MON-FRI
# 每周一早上 8 点
0 0 8 ? * MON在线 Cron 表达式验证工具很多,写完后一定要验证一下下次触发时间是否符合预期,避免配错导致任务不执行或频繁执行。
补充:定时任务的数据结构总结
实现定时任务的底层,无非依赖这几种数据结构:
小顶堆(Priority Queue)
Java 中的 Timer、DelayQueue、ScheduledThreadPoolExecutor 都基于小顶堆。任务按执行时间排序,堆顶永远是最先要执行的任务。
- 插入任务:O(log n)
- 取出最近任务:O(1)
- 删除/更新任务:O(log n)
优点是实现简单,缺点是当任务量很大时,每次插入都要做堆调整。
时间轮(Time Wheel)
前面已经详细介绍过。核心优势是插入和删除任务都是 O(1),适合大量定时任务的场景。Netty 的 HashedWheelTimer、Kafka 的定时器都用的时间轮。
红黑树(TreeMap/TreeSet)
Linux 内核的定时器用的是红黑树。任务按过期时间排序存储在红黑树中,查找、插入、删除都是 O(log n)。
对比
| 数据结构 | 插入 | 查询最近 | 删除 | 典型应用 |
|---|---|---|---|---|
| 小顶堆 | O(log n) | O(1) | O(log n) | Timer, DelayQueue |
| 时间轮 | O(1) | O(1) | O(1) | Netty, Kafka |
| 红黑树 | O(log n) | O(log n) | O(log n) | Linux 内核 |
时间轮在各维度都是 O(1),但它有一个前提:时间粒度是离散的(按槽位划分),精度受限于槽位数量。对于需要高精度、任务量不大的场景,小顶堆更合适。
补充:定时任务的幂等性
定时任务重复执行是常态——机器重启、框架重试、分布式锁释放后被其他节点捞起来再执行一次。所以定时任务的处理逻辑必须做好幂等。
幂等的常见实现方式
方式一:状态机控制
给每条数据一个状态字段,处理前先检查状态,处理完更新状态。用数据库的乐观锁保证不会重复处理:
// 先查出 INIT 状态的数据
List<Message> messages = messageDao.findByStatus("INIT");
for (Message msg : messages) {
// 用 CAS 方式更新状态(乐观锁)
int affected = messageDao.updateStatus(msg.getId(), "INIT", "PROCESSING");
if (affected == 0) {
// 已经被其他线程/实例处理了,跳过
continue;
}
try {
processMessage(msg);
messageDao.updateStatus(msg.getId(), "PROCESSING", "SUCCESS");
} catch (Exception e) {
messageDao.updateStatus(msg.getId(), "PROCESSING", "FAILED");
}
}UPDATE message SET status = 'PROCESSING', update_time = NOW()
WHERE id = #{id} AND status = 'INIT'方式二:唯一键去重
在结果表中建立唯一键,重复执行时数据库会拒绝插入:
CREATE UNIQUE INDEX uk_order_date ON daily_report(order_id, report_date);方式三:分布式锁 + 标记
用 Redis 分布式锁控制并发,用标记位(flag)控制幂等:
String lockKey = "job:closeOrder:" + orderId;
boolean locked = redisLock.tryLock(lockKey, 30, TimeUnit.SECONDS);
if (!locked) {
return; // 其他实例在处理,跳过
}
try {
Order order = orderDao.findById(orderId);
if (!"PENDING".equals(order.getStatus())) {
return; // 已经被处理过了(幂等检查)
}
closeOrder(order);
} finally {
redisLock.unlock(lockKey);
}定时任务的监控和报警
生产环境的定时任务必须有监控:
- 执行耗时监控:任务执行时间突然变长,可能是数据量暴增或数据库变慢
- 执行结果监控:成功/失败/跳过的数量,失败率异常时报警
- 空跑监控:任务执行了但处理了 0 条数据,可能是上游数据没到
- 积压监控:待处理的数据量持续增长,说明处理速度跟不上
XXL-JOB 和 PowerJob 都提供了基础的监控和报警能力,但对于核心业务,建议接入公司统一的监控平台(比如 Prometheus + Grafana),自定义更细粒度的指标。
定时任务的灰度发布
修改定时任务逻辑后,不要一次性全量发布。推荐的灰度策略:
- 先在一台机器上发布新版本
- 把定时任务路由到这台机器上执行(XXL-JOB 支持指定机器)
- 观察执行结果,确认无误后再全量发布
- 如果有问题,直接把路由切回老版本的机器
对于分片任务,可以先只在一个分片上使用新逻辑,其他分片保持老逻辑。确认没问题后逐步扩大范围。