定时任务基础知识

    • 按固定周期定时执行
    • 延迟一定时间后执行
    • 指定某个时刻执行

    JDK 提供了三种常用的定时器实现方式

    • Timer:可实现固定周期的任务,以及延迟任务。
      • 缺点:单线程模式,某个 Task 执行时间过长会影响其他任务的调度。Timer 的任务调度是基于系统绝对时间,系统时间不正确则会出现未知问题。TimerTask 执行出现异常,Timer 并不会捕获,会导致线程终止,其他任务永不执行。
    • DelayedQueue:延迟获取对象的阻塞队列,内部采用优先队列 ProrityQueue 存储对象。
      • 实现重试机制:接口调用失败或请求超时后可以将当前请求对象放入 DelayQueue,通过一个异步线程 take() 取出对象然后继续进行重试。如果请求失败,继续放回 DelayQueue。为了限制重试的频率,可以设置重试的最大次数以及采用指数退避算法设置对象的 deadline,如2S、4S、8S…
    • ScheduledThreadPoolExecutor:

    时间轮算法思想来源于钟表,分成多个 slot 槽位,
    image.png
    image.png
    最大优势:

    • 任务的新增和取消都是 1. 时间轮 - 图3 时间复杂度
    • 只需要一个线程就可以驱动时间轮进行工作

    Netty 的 HashedWheelTimer

    image.png
    Netty 的时间轮使用固定的时间间隔 tickDuration 推进时间,关于时间轮的执行流程会分为两个大步骤:

    1. 任务添加。通过调用 io.netty.util.HashedWheelTimer#newTimeout 方法添加定时任务,而这个任务将会被 io.netty.util.HashedWheelTimer.HashedWheelTimeout 对象包装后添加到 MPSC 队列中。
    2. 任务处理。Netty 会单独启用一个新的工作线程处理定时任务。处理步骤简单描述如下:
      1. 计算下次 tick 的截止时间。并使线程休眠到下一个 tick 执行时间。
      2. 获取当前 tick 在 HashedWheelBucket 数组中对应的下标。
      3. 移除被取消的任务。
      4. MPSC 队列中取出任务(最多取出 1000 个任务)加入到对应的 slot 中。
      5. 执行到期的任务。
      6. tick++,时针走到下一个 tick。

    工作线程任务处理核心方法还是在 do...while 循环中,但是当时间轮退出后,对时间轮中的已添加到 slot 但未处理的任务需要加入未处理任务列表,方便 stop() 方法返回。

    Netty 的时间轮实现优缺点:

    • 时间简洁高效,源码通俗易懂。时间轮的设计思想大致能体现。
    • 但是由于使用固定间隔 tickDuration 推进时间,在时间粒度跨度大的场景下会造成空推的情况,比如只有两个任务,A 在 1S 后执行,B 在 6 小时后执行,工作线程启动,执行完 1S 后, 还会继续以 tickDuration 间隔时间不断轮询,即便此时没有任务需要被执行。在一定程序上造成性能损耗。

    Kafka 内部也有时间轮,它是 Netty 的进阶版,可以让时间轮的使用场景更加广泛。它的解决思路如下:

    • 同 Netty 一样,也是使用环形数组存储定时任务,每个 slot 代表一个 Bucket,每个 Bucket 保存定时任务列表 TimerTaskList,TimerTaskList 同样采用双向链表的结构实现,链表上的每个结点代表真正需要被处理的任务 TimerTaskEntry。
    • Kafka 使用 JDK 提供的 DelayQueue 解决 Netty 的空推问题。具体思路是:
      • DelayQueue 保存时间轮中的每个 Bucket,根据 Bucket 的到期时间进行排序。这是一个小顶堆实现,Bucket 到期时间最短的被放在堆顶。
      • Kafka 使用一个线程读取 DelayQueue 中的任务列表,如果时间未到,则 DelayQueue 会一直处于阻塞状态,这一步可以解决空推问题。
      • Kafka 引入层级时间轮解决任务时间跨度大的难题。当任务的截止时间超出当前所在的时间轮表示范围时,就会尝试将任务添加到上一层时间轮中,类似时钟的时、分、秒转动规则。