如何实现一个定时任务
如何实现一个定时任务
- 定时任务:在指定时间点执行特定的任务,例如每天早上 8 点,每周一下午 3 点等。定时任务可以用来做一些周期性的工作,如数据备份,日志清理,报表生成等。
- 延时任务:一定的延迟时间后执行特定的任务,例如 10 分钟后,3 小时后等。延时任务可以用来做一些异步的工作,如订单取消,推送通知,红包撤回等。
尽管二者的适用场景有所区别,但它们的核心思想都是将任务的执行时间安排在未来的某个点上,以达到预期的调度效果
那么我们如何实现一个定时任务呢
Timer
java.util.Timer
是 JDK 1.3 开始就已经支持的一种定时任务的实现方式。
Timer
内部使用一个叫做 TaskQueue
的类存放定时任务,它是一个基于最小堆实现的优先级队列。TaskQueue
会按照任务距离下一次执行时间的大小将任务排序,保证在堆顶的任务最先执行。这样在需要执行任务时,每次只需要取出堆顶的任务运行即可!
Timer
使用起来比较简单,通过下面的方式我们就能创建一个 1s 之后执行的定时任务。
// 示例代码:
TimerTask task = new TimerTask() {public void run() {System.out.println("当前时间: " + new Date() + "n" +"线程名称: " + Thread.currentThread().getName());}
};
System.out.println("当前时间: " + new Date() + "n" +"线程名称: " + Thread.currentThread().getName());
Timer timer = new Timer("Timer");
long delay = 1000L;
timer.schedule(task, delay);//输出:
当前时间: Fri May 28 15:18:47 CST 2021n线程名称: main
当前时间: Fri May 28 15:18:48 CST 2021n线程名称: Timer
不过这个timer是一个线程 只能串行执行 效率太低
ScheduledExecutorService
它是一个接口 有许多实现类 而常用的是ScheduledThreadPoolExecutor
而ScheduledThreadPoolExecutor由线程池实现 天生支持并发场景
// 示例代码:
TimerTask repeatedTask = new TimerTask() {@SneakyThrowspublic void run() {System.out.println("当前时间: " + new Date() + "n" +"线程名称: " + Thread.currentThread().getName());}
};
System.out.println("当前时间: " + new Date() + "n" +"线程名称: " + Thread.currentThread().getName());
ScheduledExecutorService executor = Executors.newScheduledThreadPool(3);
long delay = 1000L;
long period = 1000L;
executor.scheduleAtFixedRate(repeatedTask, delay, period, TimeUnit.MILLISECONDS);
Thread.sleep(delay + period * 5);
executor.shutdown();
//输出:
当前时间: Fri May 28 15:40:46 CST 2021n线程名称: main
当前时间: Fri May 28 15:40:47 CST 2021n线程名称: pool-1-thread-1
当前时间: Fri May 28 15:40:48 CST 2021n线程名称: pool-1-thread-1
当前时间: Fri May 28 15:40:49 CST 2021n线程名称: pool-1-thread-2
当前时间: Fri May 28 15:40:50 CST 2021n线程名称: pool-1-thread-2
当前时间: Fri May 28 15:40:51 CST 2021n线程名称: pool-1-thread-2
当前时间: Fri May 28 15:40:52 CST 2021n线程名称: pool-1-thread-2
不论是使用 Timer
还是 ScheduledExecutorService
都无法使用 Cron 表达式指定任务执行的具体时间。
DelayQueue
DelayQueue是JUC包提供的延迟队列 一般用来实现延时任务类似订单下单取消 基于PrioritQueue实现 DelayQueue
的实现是线程安全的,它通过 ReentrantLock
实现了互斥访问和 Condition
实现了线程间的等待和唤醒操作,可以保证多线程环境下的安全性和可靠性。
DelayQueue基于优先级队列和堆排序算法实现的,可以实现多个任务按照时间先后顺序执行 Timer
是基于单线程实现的,只能按照任务的执行顺序依次执行,如果某个任务执行时间过长,会影响其他任务的执行。
SpringTask
我们可以直接通过spring提供的@Scheduled来直接实现定时(不得不说spring框架的考虑真全面 实现基本都有)
/*** cron:使用Cron表达式。 每分钟的1,2秒运行*/
@Scheduled(cron = "1-2 * * * * ? ")
public void reportCurrentTimeWithCronExpression() {log.info("Cron Expression: The time is now {}", dateFormat.format(new Date()));
}
在苍穹外卖就是用的Spring task做的定时任务哈哈哈哈
并且天生支持cron表达式 简化操作 加注解就可以实现了
时间轮
时间轮简单来说就是一个环形的队列(底层一般基于数组实现),队列中的每一个元素(时间格)都可以存放一个定时任务列表。
时间轮中的每个时间格代表了时间轮的基本时间跨度或者说时间精度,假如时间一秒走一个时间格的话,那么这个时间轮的最高精度就是 1 秒(也就是说 3 s 和 3.9s 会在同一个时间格中)。
1. 初始化一个“轮盘”
- 假设有 N 个格子(比如 60 个)
- 每个格子代表固定的时间间隔(比如 1 秒)
- 指针从第 0 格开始,周期性转动
2. 添加定时任务
- 任务有个超时时间,比如 70 秒
- 计算这个任务放在哪个格子:
- 任务等待多少“ticks” = 超时时间 / 时间间隔 → 70 / 1 = 70
- 任务要走多少圈 = 70 / N → 70 / 60 = 1 余 10
- 任务放入第 10 格子,并记录还需要 1 圈才执行
3. 时间指针走动(Tick)
- 时间轮每过 1 秒(tick),指针往下一个格子走
- 到达某个格子时,检查该格子所有任务:
- 如果任务圈数为 0 → 任务到期,执行
- 任务圈数 > 0 → 圈数减 1,继续等下一圈
4. 任务执行与移除
- 到期任务执行后,从格子里移除
- 新任务不断加进来,旧任务不断执行,时间轮循环往复
时间轮比较适合任务数量比较多的定时任务场景,它的任务写入和执行的时间复杂度都是 0(1)。
分布式系统下
刚刚我们讲的全部都是单体任务下的 那么如果是分布式系统中 我们就需要另想办法了
redis
Redis 是可以用来做延时任务的,基于 Redis 实现延时任务的功能无非就下面两种方案:
- Redis 过期事件监听
- Redisson 内置的延时队列
1. Redis 过期事件监听(Key Expire Event)
核心思路:
- 利用 Redis 的key 过期事件通知(
keyevent@<db>__:expired
) - 任务存到 Redis,设置一个过期时间(就是延时时间)
- Redis key 过期时触发通知,程序监听这个通知拿到任务,执行逻辑
实现流程:
- 任务写入 Redis
- 任务 ID 或信息作为 key,value 可以是任务详情
- 设置 key 的 TTL(过期时间),就是你延迟的时间
- 订阅 Redis 过期事件频道
- Redis 配置要开启
notify-keyspace-events
,设置为Ex
或者KEA
- 程序启动时订阅
__keyevent@0__:expired
(0 是数据库编号)
- Redis 配置要开启
- 接收过期事件
- 当 key 过期被删除时,Redis 发布事件
- 监听端收到消息,拿到 key 名称,进而查任务信息或直接执行对应任务逻辑
优点:
- 轻量,基于 Redis 原生特性
- 不需要额外依赖
- 延迟准确度较好(Redis 的过期时间相当精确)
缺点:
- 过期事件只能通知 key 过期了,不带值,需要额外存任务信息(比如在 Hash 或数据库)
- 如果 Redis 宕机或过期事件丢失,任务会漏执行
- 不适合海量任务,过期事件通知量大时会压垮 Redis
- 过期时间有误差,Redis 是惰性过期和定期过期结合,极端情况下延时不准
2. Redisson 内置延时队列(DelayQueue)
核心思路:
- Redisson 提供了一个基于 Redis Sorted Set(有序集合)的延时队列
- 任务放入有序集合,score 是执行时间(时间戳)
- Redisson 客户端后台线程循环扫描,时间到的任务出队执行
实现流程:
- 任务入队
- 任务序列化后加入 Redis 的有序集合
- score = 任务预计执行时间(比如当前时间戳 + 延迟毫秒数)
- 任务消费
- Redisson 的延时队列有后台线程持续扫描有序集合
- 当当前时间 >= score 时,任务被弹出消费
- 任务处理
- 你实现消费逻辑,比如执行定时任务或消息推送
优点:
- 支持大量任务,性能稳定
- 内置重试机制,避免任务丢失
- 可扩展分布式场景,客户端间协调消费
- 延迟时间精度高,基于时间戳精准控制
缺点:
- 依赖 Redisson 客户端库
- 需要客户端一直运行,持续扫描消费任务
- 代码实现比过期事件复杂一点
MQ
大部分消息队列,例如 RocketMQ、RabbitMQ,都支持定时/延时消息。定时消息和延时消息本质其实是相同的,都是服务端根据消息设置的定时时间在某一固定时刻将消息投递给消费者消费。
不过,在使用 MQ 定时消息之前一定要看清楚其使用限制,以免不适合项目需求,例如 RocketMQ 定时时长最大值默认为 24 小时且不支持自定义修改、只支持 18 个 Level 的延时并不支持任意时间。
优缺点总结:
- 优点:可以与 Spring 集成、支持分布式、支持集群、性能不错
- 缺点:功能性较差、不灵活、需要保障消息可靠性
我来介绍几个常见的实现方式(目前只会这一个….)
RabbitMQ:基于 TTL + 死信队列
[生产者] →queueA(设置 TTL,绑定 DLX) →消息过期后 →进入死信队列 queueB →[消费者监听 queueB 处理任务]
假设我们有一个定时场景 下单后 30 分钟未支付 → 自动取消订单(之前用spring task实现)
创建两个交换机和队列
- 订单创建队列(延时队列)queue.order.ttl
- 死信队列(消费队列)queue.order.dlx
@Bean
public Queue orderDelayQueue() {Map<String, Object> args = new HashMap<>();args.put("x-dead-letter-exchange", "order.dlx.exchange"); // 死信交换机args.put("x-dead-letter-routing-key", "order.dlx.routing"); // 死信路由args.put("x-message-ttl", 1800000); // TTL 30分钟 = 1800000msreturn new Queue("order.ttl.queue", true, false, false, args);
}@Bean
public Queue orderDLXQueue() {return new Queue("order.dlx.queue", true);
}
声明两个交换机
@Bean
public DirectExchange ttlExchange() {return new DirectExchange("order.ttl.exchange");
}@Bean
public DirectExchange dlxExchange() {return new DirectExchange("order.dlx.exchange");
}
绑定队列和交换机
@Bean
public Binding ttlBinding() {return BindingBuilder.bind(orderDelayQueue()).to(ttlExchange()).with("order.ttl.routing");
}@Bean
public Binding dlxBinding() {return BindingBuilder.bind(orderDLXQueue()).to(dlxExchange()).with("order.dlx.routing");
}
4. 发送消息(业务端)
// 创建订单时发送一条延时消息
rabbitTemplate.convertAndSend("order.ttl.exchange","order.ttl.routing",orderId
);
5. 消费者监听死信队列(30分钟后处理)
@RabbitListener(queues = "order.dlx.queue")
public void handleOrderTimeout(Long orderId) {log.info("订单超时未支付,准备关闭订单:{}", orderId);orderService.closeOrder(orderId);
}
适合用 RabbitMQ 延时队列的场景:
- 自动关单、自动退款
- 活动到点结束、发优惠券
- 下单后 N 分钟发提醒
- 用户注册 X 分钟没激活账号清除
分布式任务调度框架
如果我们需要一些高级特性比如支持任务在分布式场景下的分片和高可用的话,我们就需要用到分布式任务调度框架了。
通常情况下,一个分布式定时任务的执行往往涉及到下面这些角色:
- 任务:首先肯定是要执行的任务,这个任务就是具体的业务逻辑比如定时发送文章。
- 调度器:其次是调度中心,调度中心主要负责任务管理,会分配任务给执行器。
- 执行器:最后就是执行器,执行器接收调度器分派的任务并执行。