定时任务
业务背景
在稍微复杂点业务系统中,不可避免会碰到做定时任务的需求,比如淘宝的交易超时自动关闭订单、超时自动确认收货等等。对于一些定时作业比较多的系统,通常都会搭建专门的调度平台来管理,通过创建定时器来周期性执行任务。
实现方式
- 数据库轮询
- DelayQueue
- Timer 与 TimerTask
- 时间轮 (kafka)
- RabbitMQ
- Quartz
- Redis缓存
数据库轮询
通常是在小型项目中使用,即通过一个线程定时的去扫描数据库,通过订单时间来判断是否有超时的订单,然后进行 update 或 delete 等操作。
- 优点:实现简单,支持集群操作
- 缺点:
- 对服务器内存消耗大
- 存在延迟,比如你每隔 3 分钟扫描一次,那最坏的延迟时间就是 3 分钟
- 假设你的订单有几千万条,每隔几分钟这样扫描一次,数据库损耗极大
DelayQueue
利用 JDK 自带的 DelayQueue 来实现,这是一个无界阻塞队列,该队列只有在延迟期满的时候才能从中获取元素,放入 DelayQueue 中的对象,是必须实现 Delayed 接口的。 DelayedQueue 实现工作流程如下图所示:

其中 Poll() 获取并移除队列的超时元素,没有则返回空。 take() 获取并移除队列的超时元素,如果没有则 wait 当前线程,直到有元素满足超时条件,返回结果。
Timer 与 TimerTask

- TaskQueue 中的排序是对 TimerTask 中的下一次执行时间进行堆排序,每次去取数组第一个。
- 而delayQueue 是对 queue 中的元素的 getDelay() 结果进行排序
- Timer 是一种定时器工具,用来在一个后台线程计划执行指定任务。它可以计划执行一个任务一次或反复多次

时间轮 (kafka)
- 时间格:环形结构中用于存放延迟任务的区块;
- 指针 (CurrentTime):指向当前操作的时间格,代表当前时间
- 格数 (ticksPerWheel):为时间轮中时间格的个数
- 间隔 (tickDuration):每个时间格之间的间隔
- 总间隔 (interval):当前时间轮总间隔,也就是等于 ticksPerWheel*tickDuration

时间轮算法可以类比于时钟,如上图箭头(指针)按某一个方向按固定频率轮动,每一次跳动称为一个 tick。这样可以看出定时轮由个 3 个重要的属性参数,ticksPerWheel (一轮的 tick 数),tickDuration (一个 tick 的持续时间)以及 timeUnit (时间单位),例如当 ticksPerWheel=60,tickDuration=1,timeUnit= 秒,这就和现实中的始终的秒针走动完全类似了。
如果当前指针指在 1 上面,我有一个任务需要 4 秒以后执行,那么这个执行的线程回调或者消息将会被放在 5 上。那如果需要在 20 秒之后执行怎么办,由于这个环形结构槽数只到 8,如果要 20 秒,指针需要多转 2 圈。位置是在 2 圈之后的 5 上面 (20 % 8 + 1)。
- 优点:效率高,任务触发时间延迟时间比 delayQueue 低,代码复杂度比 delayQueue 低。
- 缺点:
- 服务器重启后,数据全部消失,怕宕机
- 集群扩展相当麻烦
- 因为内存条件限制的原因,比如下单未付款的订单数太多,那么很容易就出现 OOM 异常
RabbitMQ
RabbitMQ 本身没有直接支持延迟队列功能,但是可以通过以下特性模拟出延迟队列的功能。
- RabbitMQ 可以针对 Queue 和 Message 设置 x-message-tt,来控制消息的生存时间,如果超时,则消息变为 dead letter
RabbitMQ 针对队列中的消息过期时间有两种方法可以设置。
- A: 通过队列属性设置,队列中所有消息都有相同的过期时间。
- B: 对消息进行单独设置,每条消息 TTL 可以不同。

- 优点: 高效,可以利用 RabbitMQ 的分布式特性轻易的进行横向扩展,消息支持持久化增加了可靠性。
- 缺点:本身的易用度要依赖于 RabbitMQ 的运维。因为要引用 RabbitMQ,所以复杂度和成本变高
Quartz
Quartz 是一个任务调度框架(库),它几乎可以集成到任何应用系统中,还可以与 Spring 集成(重点)。Quartz 是非常轻量级的,只需要非常少的配置,它还具有容错机制,并且可以在重启服务的时候持久化(“记忆”)你的定时任务,你的任务也不会丢失。
为什么不用 Timer?
- Timers 没有持久化机制,也不灵活(只可以设置开始时间和重复间隔,不是基于时间、日期、天等(秒、分、时)的),而且不能利用线程池,一个timer一个线程,没有真正的管理计划。
Redis 缓存
方法一: 利用 redis 的 zset,zset 是一个有序集合,每一个元素 (member) 都关联了一个 score,通过 score 排序来取集合中的值 zset常用命令
# 添加单个元素
redis> ZADD page_rank 10 google.com
(integer) 1
# 添加多个元素
redis> ZADD page_rank 9 baidu.com 8 bing.com
(integer) 2
redis> ZRANGE page_rank 0 -1 WITHSCORES
1) "bing.com"
2) "8"
3) "baidu.com"
4) "9"
5) "google.com"
6) "10"
# 查询元素的 score 值
redis> ZSCORE page_rank bing.com
"8"
# 移除单个元素
redis> ZREM page_rank google.com
(integer) 1
redis> ZRANGE page_rank 0 -1 WITHSCORES
1) "bing.com"
2) "8"
3) "baidu.com"
4) "9"
那么如何实现呢?我们将订单超时时间戳与订单号分别设置为 score 和 member,系统扫描第一个元素判断是否超时,具体如下图所示:

方法二: 使用 redis 的 Keyspace Notifications,中文翻译就是键空间机制,就是利用该机制可以在 key 失效之后,提供一个回调,实际上是 redis 会给客户端发送一个消息。是需要 redis 版本 2.8 以上。
优缺点:
- 优点:
- 由于使用 redis 作为消息通道,消息都存储在 redis 中。如果发送程序或者任务处理程序挂了,重启之后,还有重新处理数据的可能性。
- 做集群扩展相当方便
- 时间准确度高
- 缺点:需要额外进行 redis 维护
调度器、任务和触发器
三者关系:调度器负责调度各个任务,到了某个时刻或者过了一定时间,触发器触动了,特定任务便启动执行。

- scheduler 是一个计划调度器容器(总部),容器里面可以盛放众多的 JobDetail 和 trigger,当容器启动后,里面的每个 JobDetail 都会根据 trigger 按部就班自动去执行。
- JobDetail 是一个可执行的工作,它本身是有状态的。
- Trigger 代表什么时候去调。
- 当JobDetail 和 Trigger 在 scheduler 容器上注册后,形成了装配好的作业 (JobDetail 和 Trigger 所组成的一对儿),就可以伴随容器启动而调度执行了。
- scheduler 是个容器,容器中有一个线程池,用来并行调度执行每个作业,这样可以提高容器效率。