定时任务

2019/03/28 定时任务

定时任务

业务背景

在稍微复杂点业务系统中,不可避免会碰到做定时任务的需求,比如淘宝的交易超时自动关闭订单、超时自动确认收货等等。对于一些定时作业比较多的系统,通常都会搭建专门的调度平台来管理,通过创建定时器来周期性执行任务。

实现方式

  • 数据库轮询
  • DelayQueue
  • Timer 与 TimerTask
  • 时间轮 (kafka)
  • RabbitMQ
  • Quartz
  • Redis缓存

数据库轮询

通常是在小型项目中使用,即通过一个线程定时的去扫描数据库,通过订单时间来判断是否有超时的订单,然后进行 update 或 delete 等操作。

  • 优点:实现简单,支持集群操作
  • 缺点:
    • 对服务器内存消耗大
    • 存在延迟,比如你每隔 3 分钟扫描一次,那最坏的延迟时间就是 3 分钟
    • 假设你的订单有几千万条,每隔几分钟这样扫描一次,数据库损耗极大

DelayQueue

利用 JDK 自带的 DelayQueue 来实现,这是一个无界阻塞队列,该队列只有在延迟期满的时候才能从中获取元素,放入 DelayQueue 中的对象,是必须实现 Delayed 接口的。 DelayedQueue 实现工作流程如下图所示:

image

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

Timer 与 TimerTask

image

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

image

时间轮 (kafka)

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

image

时间轮算法可以类比于时钟,如上图箭头(指针)按某一个方向按固定频率轮动,每一次跳动称为一个 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 可以不同。

image

  • 优点: 高效,可以利用 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,系统扫描第一个元素判断是否超时,具体如下图所示:

image

方法二: 使用 redis 的 Keyspace Notifications,中文翻译就是键空间机制,就是利用该机制可以在 key 失效之后,提供一个回调,实际上是 redis 会给客户端发送一个消息。是需要 redis 版本 2.8 以上。

优缺点:

  • 优点:
    • 由于使用 redis 作为消息通道,消息都存储在 redis 中。如果发送程序或者任务处理程序挂了,重启之后,还有重新处理数据的可能性。
    • 做集群扩展相当方便
    • 时间准确度高
  • 缺点:需要额外进行 redis 维护

调度器、任务和触发器

三者关系:调度器负责调度各个任务,到了某个时刻或者过了一定时间,触发器触动了,特定任务便启动执行。

image

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

Search

    Table of Contents