Kafka 进阶学习(十一)—— 时间轮

148 阅读7分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第10天,点击查看活动详情

前言

今天是我 Kafka 学习的第十一天。

Kafka 中存在大量的延时操作,比如延时生产、延时拉取和延时删除等。其实现方式是基于时间轮实现的一个用于延时功能的定时器(SystemTimer)。相比于 JDK 中 Timer 和 DelayQueue 延时功能组件,其时间复杂度更高,为 O(1),更能满足 Kafka 的高性能要求,而 Timer 和 DelayQueue 的插入和删除操作的平均时间复杂度为 O(nlogn)。时间轮的应用并非Kafka独有,其应用场景还有很多,在 Netty、Akka、Quartz、ZooKeeper 等组件中都存在时间轮的踪影。下面让我们一起来学习一下。

时间轮概念

Kafka 中的时间轮(TimingWheel)是一个 存储定时任务的环形队列,底层采用数组实现,数组中的每个元素可以存放一个定时任务列表(TimerTaskList)。TimerTaskList 是一个环形的双向链表,链表中的每一项表示的都是定时任务项(TimerTaskEntry),其中封装了真正的定时任务(TimerTask)。

时间轮由多个 时间格 组成,其结构如下图所示。每个时间格代表当前时间轮的 基本时间跨度(tickMs)。时间轮的时间格个数是固定的,可用 wheelSize 来表示,那么整个时间轮的总体时间跨度(interval)可以通过公式 tickMs × wheelSize 计算得出。时间轮还有一个 表盘指针(currentTime),用来表示时间轮当前所处的时间,currentTime 是 tickMs 的整数倍。currentTime 可以将整个时间轮划分为 到期部分 和 未到期部分currentTime 当前指向的时间格也属于到期部分,表示刚好到期,需要处理此时间格所对应的 TimerTaskList 中的所有任务

时间轮结构.png

时间轮使用方式

下面我们来看一个具体的例子,来演示时间轮的具体使用方式。

1. 初始第一层轮盘

若时间轮的 tickMs 为 1ms 且 wheelSize 等于 20,那么可以计算得出总体时间跨度 interval 为 20ms。

初始情况下表盘指针 currentTime 指向时间格 0。

2. 添加任务

此时有一个定时为 2ms 的任务插进来会存放到时间格为 2 的 TimerTaskList 中。随着时间的不断推移,指针 currentTime 不断向前推进,过了 2ms 之后,当到达时间格 2 时,就需要将时间格 2 对应的 TimeTaskList 中的任务进行相应的到期操作。

此时若又有一个定时为 8ms 的任务插进来,则会存放到时间格 10 中,currentTime 再过 8ms 后会指向时间格 10。

如果同时有一个定时为 19ms 的任务插进来怎么办?新来的 TimerTaskEntry 会复用原来的 TimerTaskList,所以它会插入原本已经到期的时间格 1。

总之,整个时间轮的总体跨度是不变的,随着指针 currentTime 的不断推进,当前时间轮所能处理的时间段也在不断后移,总体时间范围在 currentTime 和 currentTime+interval 之间。

3. 添加超过 wheelSize 的任务

那我们再来看一看,如果此时有一个定时为 350ms 的任务该如何处理?直接扩充 wheelSize 的大小吗?其实这是不合理的,Kafka 中不乏几万甚至几十万毫秒的定时任务,这个 wheelSize 的扩充没有底线,就算将所有的定时任务的到期时间都设定一个上限,比如 100 万毫秒,那么这个 wheelSize 为 100 万毫秒的时间轮不仅占用很大的内存空间,而且也会拉低效率。Kafka 为此引入了 层级时间轮 的概念,当任务的到期时间超过了当前时间轮所表示的时间范围时,就会尝试添加到上层时间轮中。

即第一层的时间轮 tickMs=1ms、wheelSize=20、interval=20ms。第二层的时间轮的 tickMs 为第一层时间轮的 interval,即 20ms。每一层时间轮的 wheelSize 是固定的,都是 20,那么第二层的时间轮的总体时间跨度 interval 为 400ms。以此类推,这个 400ms 也是第三层的 tickMs 的大小,第三层的时间轮的总体时间跨度为 8000ms。

对于之前所说的 350ms 的定时任务,显然第一层时间轮不能满足条件,所以就升级到第二层时间轮中,最终被插入第二层时间轮中时间格 17 所对应的 TimerTaskList。

如果此时又有一个定时为 450ms 的任务,那么显然第二层时间轮也无法满足条件,所以又升级到第三层时间轮中,最终被插入第三层时间轮中时间格 1 的 TimerTaskList。

4. 时间轮降级

注意到在到期时间为 [400ms,800ms)区间内的多个任务(比如 446ms、455ms 和 473ms 的定时任务)都会被放入第三层时间轮的时间格 1,时间格 1 对应的 TimerTaskList 的超时时间为 400ms。随着时间的流逝,当此 TimerTaskList 到期之时,原本定时为 450ms 的任务还剩下 50ms 的时间,还不能执行这个任务的到期操作。这里就有一个 时间轮降级 的操作,会将这个剩余时间为 50ms 的定时任务重新提交到层级时间轮中,此时第一层时间轮的总体时间跨度不够,而第二层足够,所以该任务被放到第二层时间轮到期时间为 [40ms,60ms)的时间格中。再经历 40ms 之后,此时这个任务又被“察觉”,不过还剩余 10ms,还是不能立即执行到期操作。所以还要再有一次时间轮的降级,此任务被添加到第一层时间轮到期时间为 [10ms,11ms)的时间格中,之后再经历 10ms 后,此任务真正到期,最终执行相应的到期操作。

时间轮实现细节

Kafka 在实现时间轮时还有一些小细节,这里我们一起看下:

  1. 起始时间初始化: TimingWheel 在创建的时候以当前系统时间为第一层时间轮的起始时间(startMs),时间设置是调用 Time.SYSTEM.hiResClockMs 方法,该方法可以精确的获取毫秒级时间,不依赖于具体操作系统。
  2. 哨兵节点:TimingWheel 中的每个双向环形链表 TimerTaskList 都会有一个哨兵节点(sentinel),引入哨兵节点可以简化边界条件。哨兵节点也称为哑元节点(dummy node),它是一个附加的链表节点,该节点作为第一个节点,它的值域中并不存储任何东西,只是为了操作的方便而引入的。如果一个链表有哨兵节点,那么线性表的第一个元素应该是链表的第二个节点。
  3. 层级起始时间:除了第一层时间轮,其余高层时间轮的起始时间(startMs)都设置为创建此层时间轮时前面第一轮的 currentTime。每一层的 currentTime 都必须是 tickMs 的整数倍,如果不满足则会将 currentTime 修剪为 tickMs 的整数倍,以此与时间轮中的时间格的到期时间范围对应起来。修剪方法为:currentTime=startMs-(startMs%tickMs)。currentTime 会随着时间推移而推进,但不会改变为 tickMs 的整数倍的既定事实。若某一时刻的时间为 timeMs,那么此时时间轮的 currentTime=timeMs-(timeMs%tickMs),时间每推进一次,每个层级的时间轮的 currentTime 都会依据此公式执行推进。
  4. 时间轮引用:Kafka 中的定时器只需持有 TimingWheel 的第一层时间轮的引用,并不会直接持有其他高层的时间轮,但每一层时间轮都会有一个引用(overflowWheel)指向更高一层的应用,以此层级调用可以实现定时器间接持有各个层级时间轮的引用。

参考文档

  • 《深入理解 Kafka:核心设计与实践原理》—— 朱忠华

往期文章