You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

24 KiB

19 | TimingWheel探究Kafka定时器背后的高效时间轮算法

你好我是胡夕。今天我们开始学习Kafka延时请求的代码实现。

延时请求Delayed Operation也称延迟请求是指因未满足条件而暂时无法被处理的Kafka请求。举个例子配置了acks=all的生产者发送的请求可能一时无法完成因为Kafka必须确保ISR中的所有副本都要成功响应这次写入。因此通常情况下这些请求没法被立即处理。只有满足了条件或发生了超时Kafka才会把该请求标记为完成状态。这就是所谓的延时请求。

今天,我们的重点是弄明白请求被延时处理的机制——分层时间轮算法。

时间轮的应用范围非常广。很多操作系统的定时任务调度如Crontab以及通信框架如Netty等都利用了时间轮的思想。几乎所有的时间任务调度系统都是基于时间轮算法的。Kafka应用基于时间轮算法管理延迟请求的代码简洁精炼而且和业务逻辑代码完全解耦你可以从0到1地照搬到你自己的项目工程中。

时间轮简介

在开始介绍时间轮之前我想先请你思考这样一个问题“如果是你你会怎么实现Kafka中的延时请求呢

针对这个问题我的第一反应是使用Java的DelayQueue。毕竟这个类是Java天然提供的延时队列非常适合建模延时对象处理。实际上Kafka的第一版延时请求就是使用DelayQueue做的。

但是DelayQueue有一个弊端它插入和删除队列元素的时间复杂度是O(logN)。对于Kafka这种非常容易积攒几十万个延时请求的场景来说该数据结构的性能是瓶颈。当然这一版的设计还有其他弊端比如它在清除已过期的延迟请求方面不够高效可能会出现内存溢出的情形。后来社区改造了延时请求的实现机制采用了基于时间轮的方案。

时间轮有简单时间轮Simple Timing Wheel和分层时间轮Hierarchical Timing Wheel两类。两者各有利弊也都有各自的使用场景。Kafka采用的是分层时间轮这是我们重点学习的内容。

关于分层时间轮有很多严谨的科学论文。不过大多数的论文读起来晦涩难懂而且偏理论研究。然而我们并非是要完整系统地学习这套机制我们关心的是如何将其应用于实践当中。要做到这一点结合着源码来学习就是一个不错的途径。你需要关注在代码层面Kafka是如何实现多层时间轮的。

“时间轮”的概念稍微有点抽象,我用一个生活中的例子,来帮助你建立一些初始印象。

想想我们生活中的手表。手表由时针、分针和秒针组成,它们各自有独立的刻度,但又彼此相关:秒针转动一圈,分针会向前推进一格;分针转动一圈,时针会向前推进一格。这就是典型的分层时间轮。

和手表不太一样的是Kafka自己有专门的术语。在Kafka中手表中的“一格”叫“一个桶Bucket而“推进”对应于Kafka中的“滴答”也就是tick。后面你在阅读源码的时候会频繁地看到Bucket、tick字眼你可以把它们理解成手表刻度盘面上的“一格”和“向前推进”的意思。

除此之外每个Bucket下也不是白板一块它实际上是一个双向循环链表Doubly Linked Cyclic List里面保存了一组延时请求。

我先用一张图帮你理解下双向循环链表。

图中的每个节点都有一个next和prev指针分别指向下一个元素和上一个元素。Root是链表的头部节点不包含任何实际数据。它的next指针指向链表的第一个元素而prev指针指向最后一个元素。

由于是双向链表结构因此代码能够利用next和prev两个指针快速地定位元素因此在Bucket下插入和删除一个元素的时间复杂度是O(1)。当然,双向链表要求同时保存两个指针数据,在节省时间的同时消耗了更多的空间。在算法领域,这是典型的用空间去换时间的优化思想。

源码层级关系

在Kafka中具体是怎么应用分层时间轮实现请求队列的呢

图中的时间轮共有两个层级分别是Level 0和Level 1。每个时间轮有8个Bucket每个Bucket下是一个双向循环链表用来保存延迟请求。

在Kafka源码中时间轮对应utils.timer包下的TimingWheel类每个Bucket下的链表对应TimerTaskList类链表元素对应TimerTaskEntry类而每个链表元素里面保存的延时任务对应TimerTask。

在这些类中TimerTaskEntry与TimerTask是1对1的关系TimerTaskList下包含多个TimerTaskEntryTimingWheel包含多个TimerTaskList。

我画了一张UML图帮助你理解这些类之间的对应关系

时间轮各个类源码定义

掌握了这些基础知识,下面我就结合这些源码,来解释下延迟请求是如何被这套分层时间轮管理的。根据调用关系,我采用自底向上的方法给出它们的定义。

TimerTask类

首先是TimerTask类。该类位于utils.timer包下的TimerTask.scala文件中。它的代码只有几十行非常容易理解。

trait TimerTask extends Runnable {
  val delayMs: Long // 通常是request.timeout.ms参数值
  // 每个TimerTask实例关联一个TimerTaskEntry
  // 就是说每个定时任务需要知道它在哪个Bucket链表下的哪个链表元素上
  private[this] var timerTaskEntry: TimerTaskEntry = null
  // 取消定时任务原理就是将关联的timerTaskEntry置空
  def cancel(): Unit = {
    synchronized {
      if (timerTaskEntry != null) timerTaskEntry.remove()
      timerTaskEntry = null
    }
  }
  // 关联timerTaskEntry原理是给timerTaskEntry字段赋值
  private[timer] def setTimerTaskEntry(entry: TimerTaskEntry)
    : Unit = {
    synchronized {
      if (timerTaskEntry != null && timerTaskEntry != entry)
        timerTaskEntry.remove()
      timerTaskEntry = entry
    }
  }
  // 获取关联的timerTaskEntry实例
  private[timer] def getTimerTaskEntry(): TimerTaskEntry = {
    timerTaskEntry
  }
}


从代码可知TimerTask是一个Scala接口Trait。每个TimerTask都有一个delayMs字段表示这个定时任务的超时时间。通常来说这就是客户端参数request.timeout.ms的值。这个类还绑定了一个timerTaskEntry字段因为每个定时任务都要知道它存放在哪个Bucket链表下的哪个链表元素上。

既然绑定了这个字段就要提供相应的Setter和Getter方法。Getter方法仅仅是返回这个字段而已Setter方法要稍微复杂一些。在给timerTaskEntry赋值之前它必须要先考虑这个定时任务是否已经绑定了其他的timerTaskEntry如果是的话就必须先取消绑定。另外Setter的整个方法体必须由monitor锁保护起来以保证线程安全性。

这个类还有个cancel方法用于取消定时任务。原理也很简单就是将关联的timerTaskEntry置空。也就是说把定时任务从链表上摘除。

总之TimerTask建模的是Kafka中的定时任务。接下来我们来看TimerTaskEntry是如何承载这个定时任务的以及如何在链表中实现双向关联。

TimerTaskEntry类

如前所述TimerTaskEntry表征的是Bucket链表下的一个元素。它的主要代码如下

private[timer] class TimerTaskEntry(val timerTask: TimerTask, val expirationMs: Long) extends Ordered[TimerTaskEntry] {
  @volatile
  var list: TimerTaskList = null   // 绑定的Bucket链表实例 
  var next: TimerTaskEntry = null  // next指针
  var prev: TimerTaskEntry = null  // prev指针
  // 关联给定的定时任务
  if (timerTask != null) timerTask.setTimerTaskEntry(this)
  // 关联定时任务是否已经被取消了
  def cancelled: Boolean = {
    timerTask.getTimerTaskEntry != this
  }
  // 从Bucket链表中移除自己
  def remove(): Unit = {
    var currentList = list
    while (currentList != null) {
      currentList.remove(this)
      currentList = list
    }
  }
  ......
}

该类定义了TimerTask类字段用来指定定时任务同时还封装了一个过期时间戳字段这个字段值定义了定时任务的过期时间。

举个例子假设有个PRODUCE请求在当前时间1点钟被发送到Broker超时时间是30秒那么该请求必须在1点30秒之前完成否则将被视为超时。这里的1点30秒就是expirationMs值。

除了TimerTask类字段该类还定义了3个字段list、next和prev。它们分别对应于Bucket链表实例以及自身的next、prev指针。注意list字段是volatile型的这是因为Kafka的延时请求可能会被其他线程从一个链表搬移到另一个链表中因此为了保证必要的内存可见性代码声明list为volatile。

该类的方法代码都很直观你可以看下我写的代码注释。这里我重点解释一下remove方法的实现原理。

remove的逻辑是将TimerTask自身从双向链表中移除掉因此代码调用了TimerTaskList的remove方法来做这件事。那这里就有一个问题“怎么算真正移除掉呢”其实这是根据“TimerTaskEntry的list是否为空”来判断的。一旦置空了该字段那么这个TimerTaskEntry实例就变成了“孤儿”不再属于任何一个链表了。从这个角度来看置空就相当于移除的效果。

需要注意的是置空这个动作是在TimerTaskList的remove中完成的而这个方法可能会被其他线程同时调用因此上段代码使用了while循环的方式来确保TimerTaskEntry的list字段确实被置空了。这样Kafka才能安全地认为此链表元素被成功移除。

TimerTaskList类

说完了TimerTask和TimerTaskEntry就轮到链表类TimerTaskList上场了。我们先看它的定义

private[timer] class TimerTaskList(taskCounter: AtomicInteger) extends Delayed {
  private[this] val root = new TimerTaskEntry(null, -1)
  root.next = root
  root.prev = root
  private[this] val expiration = new AtomicLong(-1L)
  ......
}

TimerTaskList实现了刚刚那张图所展示的双向循环链表。它定义了一个Root节点同时还定义了两个字段

  • taskCounter用于标识当前这个链表中的总定时任务数
  • expiration表示这个链表所在Bucket的过期时间戳。

就像我前面说的每个Bucket对应于手表表盘上的一格。它有起始时间和结束时间因而也就有时间间隔的概念即“结束时间-起始时间=时间间隔”。同一层的Bucket的时间间隔都是一样的。只有当前时间越过了Bucket的起始时间这个Bucket才算是过期。而这里的起始时间就是代码中expiration字段的值。

除了定义的字段之外TimerTaskList类还定义一些重要的方法比如expiration的Getter和Setter方法、add、remove和flush方法。

我们先看expiration的Getter和Setter方法。

// Setter方法
def setExpiration(expirationMs: Long): Boolean = {
  expiration.getAndSet(expirationMs) != expirationMs
}

// Getter方法
def getExpiration(): Long = {
  expiration.get()
}

我重点解释下Setter方法。代码使用了AtomicLong的CAS方法getAndSet原子性地设置了过期时间戳之后将新过期时间戳和旧值进行比较看看是否不同然后返回结果。

这里为什么要比较新旧值是否不同呢这是因为目前Kafka使用一个DelayQueue统一管理所有的Bucket也就是TimerTaskList对象。随着时钟不断向前推进原有Bucket会不断地过期然后失效。当这些Bucket失效后源码会重用这些Bucket。重用的方式就是重新设置Bucket的过期时间并把它们加回到DelayQueue中。这里进行比较的目的就是用来判断这个Bucket是否要被插入到DelayQueue。

此外TimerTaskList类还提供了add和remove方法分别实现将给定定时任务插入到链表、从链表中移除定时任务的逻辑。这两个方法的主体代码基本上就是我们在数据结构课上学过的链表元素插入和删除操作所以这里我就不具体展开讲了。你可以将这些代码和数据结构书中的代码比对下看看它们是不是长得很像。

// add方法
def add(timerTaskEntry: TimerTaskEntry): Unit = {
  var done = false
  while (!done) {
    // 在添加之前尝试移除该定时任务,保证该任务没有在其他链表中
    timerTaskEntry.remove()
    synchronized {
      timerTaskEntry.synchronized {
        if (timerTaskEntry.list == null) {
          val tail = root.prev
          timerTaskEntry.next = root
          timerTaskEntry.prev = tail
          timerTaskEntry.list = this
          // 把timerTaskEntry添加到链表末尾
          tail.next = timerTaskEntry
          root.prev = timerTaskEntry
          taskCounter.incrementAndGet()
          done = true
        }
      }
    }
  }
}
// remove方法
def remove(timerTaskEntry: TimerTaskEntry): Unit = {
  synchronized {
    timerTaskEntry.synchronized {
      if (timerTaskEntry.list eq this) {
        timerTaskEntry.next.prev = timerTaskEntry.prev
        timerTaskEntry.prev.next = timerTaskEntry.next
        timerTaskEntry.next = null
        timerTaskEntry.prev = null
        timerTaskEntry.list = null
        taskCounter.decrementAndGet()
      }
    }
  }
}

最后我们看看flush方法。它的代码如下

def flush(f: (TimerTaskEntry)=>Unit): Unit = {
  synchronized {
    // 找到链表第一个元素
    var head = root.next
    // 开始遍历链表
    while (head ne root) {
      // 移除遍历到的链表元素
      remove(head)
      // 执行传入参数f的逻辑
      f(head)
      head = root.next
    }
    // 清空过期时间设置
    expiration.set(-1L)
  }
}

基本上flush方法是清空链表中的所有元素并对每个元素执行指定的逻辑。该方法用于将高层次时间轮Bucket上的定时任务重新插入回低层次的Bucket中。具体为什么要这么做下节课我会给出答案现在你只需要知道它的大致作用就可以了。

TimingWheel类

最后我们再来看下TimingWheel类的代码。先看定义

private[timer] class TimingWheel(
  tickMs: Long, wheelSize: Int, 
  startMs: Long, taskCounter: AtomicInteger, 
  queue: DelayQueue[TimerTaskList]) {
  private[this] val interval = tickMs * wheelSize
  private[this] val buckets = Array.tabulate[TimerTaskList](wheelSize) { _ => new TimerTaskList(taskCounter) }
  private[this] var currentTime = startMs - (startMs % tickMs)
  @volatile private[this] var overflowWheel: TimingWheel = null
  ......
}


每个TimingWheel对象都定义了9个字段。这9个字段都非常重要每个字段都是分层时间轮的重要属性。因此我来逐一介绍下。

  • tickMs滴答一次的时长类似于手表的例子中向前推进一格的时间。对于秒针而言tickMs就是1秒。同理分针是1分时针是1小时。在Kafka中第1层时间轮的tickMs被固定为1毫秒也就是说向前推进一格Bucket的时长是1毫秒。
  • wheelSize每一层时间轮上的Bucket数量。第1层的Bucket数量是20。
  • startMs时间轮对象被创建时的起始时间戳。
  • taskCounter这一层时间轮上的总定时任务数。
  • queue将所有Bucket按照过期时间排序的延迟队列。随着时间不断向前推进Kafka需要依靠这个队列获取那些已过期的Bucket并清除它们。
  • interval这层时间轮总时长等于滴答时长乘以wheelSize。以第1层为例interval就是20毫秒。由于下一层时间轮的滴答时长就是上一层的总时长因此第2层的滴答时长就是20毫秒总时长是400毫秒以此类推。
  • buckets时间轮下的所有Bucket对象也就是所有TimerTaskList对象。
  • currentTime当前时间戳只是源码对它进行了一些微调整将它设置成小于当前时间的最大滴答时长的整数倍。举个例子假设滴答时长是20毫秒当前时间戳是123毫秒那么currentTime会被调整为120毫秒。
  • overflowWheelKafka是按需创建上层时间轮的。这也就是说当有新的定时任务到达时会尝试将其放入第1层时间轮。如果第1层的interval无法容纳定时任务的超时时间就现场创建并配置好第2层时间轮并再次尝试放入如果依然无法容纳那么就再创建和配置第3层时间轮以此类推直到找到适合容纳该定时任务的第N层时间轮。

由于每层时间轮的长度都是倍增的,因此,代码并不需要创建太多层的时间轮,就足以容纳绝大部分的延时请求了。

举个例子目前Clients端默认的请求超时时间是30秒按照现在代码中的wheelSize=20进行倍增只需要4层时间轮就能容纳160秒以内的所有延时请求了。

说完了类声明我们再来学习下TimingWheel中定义的3个方法addOverflowWheel、add和advanceClock。就像我前面说的TimingWheel类字段overflowWheel的创建是按需的。每当需要一个新的上层时间轮时代码就会调用addOverflowWheel方法。我们看下它的代码

private[this] def addOverflowWheel(): Unit = {
  synchronized {
    // 只有之前没有创建上层时间轮方法才会继续
    if (overflowWheel == null) {
      // 创建新的TimingWheel实例
      // 滴答时长tickMs等于下层时间轮总时长
      // 每层的轮子数都是相同的
      overflowWheel = new TimingWheel(
        tickMs = interval,
        wheelSize = wheelSize,
        startMs = currentTime,
        taskCounter = taskCounter,
        queue
      )
    }
  }
}

这个方法就是创建一个新的TimingWheel实例也就是创建上层时间轮。所用的滴答时长等于下层时间轮总时长而每层的轮子数都是相同的。创建完成之后代码将新创建的实例赋值给overflowWheel字段。至此方法结束。

下面我们再来学习下add和advanceClock方法。首先是add方法代码及其注释如下

def add(timerTaskEntry: TimerTaskEntry): Boolean = {
  // 获取定时任务的过期时间戳
  val expiration = timerTaskEntry.expirationMs
  // 如果该任务已然被取消了,则无需添加,直接返回
  if (timerTaskEntry.cancelled) {
    false
  // 如果该任务超时时间已过期
  } else if (expiration < currentTime + tickMs) {
    false
  // 如果该任务超时时间在本层时间轮覆盖时间范围内
  } else if (expiration < currentTime + interval) {
    val virtualId = expiration / tickMs
    // 计算要被放入到哪个Bucket中
    val bucket = buckets((virtualId % wheelSize.toLong).toInt)
    // 添加到Bucket中
    bucket.add(timerTaskEntry)
    // 设置Bucket过期时间
    // 如果该时间变更过说明Bucket是新建或被重用将其加回到DelayQueue
    if (bucket.setExpiration(virtualId * tickMs)) {
      queue.offer(bucket)
    }
    true
  // 本层时间轮无法容纳该任务,交由上层时间轮处理
  } else {
    // 按需创建上层时间轮
    if (overflowWheel == null) addOverflowWheel()
    // 加入到上层时间轮中
    overflowWheel.add(timerTaskEntry)
  }
}

我结合一张图来解释下这个add方法要做的事情

方法的第1步是获取定时任务的过期时间戳。所谓过期时间戳,就是这个定时任务过期时的时点。

第2步是看定时任务是否已被取消。如果已经被取消,则无需加入到时间轮中。如果没有被取消,就接着看这个定时任务是否已经过期。如果过期了,自然也不用加入到时间轮中。如果没有过期,就看这个定时任务的过期时间是否能够被涵盖在本层时间轮的时间范围内。如果可以,则进入到下一步。

第3步首先计算目标Bucket序号也就是这个定时任务需要被保存在哪个TimerTaskList中。我举个实际的例子来说明一下如何计算目标Bucket。

前面说过了第1层的时间轮有20个Bucket每个滴答时长是1毫秒。那么第2层时间轮的滴答时长应该就是20毫秒总时长是400毫秒。第2层第1个Bucket的时间范围应该是[2040)第2个Bucket的时间范围是[4060依次类推。假设现在有个延时请求的超时时间戳是237那么它就应该被插入到第11个Bucket中。

在确定了目标Bucket序号之后代码会将该定时任务添加到这个Bucket下同时更新这个Bucket的过期时间戳。在刚刚的那个例子中第11号Bucket的起始时间就应该是小于237的最大的20的倍数即220。

第4步如果这个Bucket是首次插入定时任务那么还同时要将这个Bucket加入到DelayQueue中方便Kafka轻松地获取那些已过期Bucket并删除它们。如果定时任务的过期时间无法被涵盖在本层时间轮中那么就按需创建上一层时间戳然后在上一层时间轮上完整地执行刚刚所说的所有逻辑。

说完了add方法我们看下advanceClock方法。顾名思义它就是向前驱动时钟的方法。代码如下

def advanceClock(timeMs: Long): Unit = {
  // 向前驱动到的时点要超过Bucket的时间范围才是有意义的推进否则什么都不做
  // 更新当前时间currentTime到下一个Bucket的起始时点
  if (timeMs >= currentTime + tickMs) {
    currentTime = timeMs - (timeMs % tickMs)
    // 同时尝试为上一层时间轮做向前推进动作
    if (overflowWheel != null) overflowWheel.advanceClock(currentTime)
  }
}

参数timeMs表示要把时钟向前推动到这个时点。向前驱动到的时点必须要超过Bucket的时间范围才是有意义的推进否则什么都不做毕竟它还在Bucket时间范围内。

相反一旦超过了Bucket覆盖的时间范围代码就会更新当前时间currentTime到下一个Bucket的起始时点同时递归地为上一层时间轮做向前推进动作。推进时钟的动作是由Kafka后台专属的Reaper线程发起的。

今天我反复提到了删除过期Bucket这个操作是由这个Reaper线程执行的。下节课我们会提到这个Reaper线程。

总结

今天我简要介绍了时间轮机制并结合代码重点讲解了分层时间轮在Kafka中的代码实现。Kafka正是利用这套分层时间轮机制实现了对于延迟请求的处理。在源码层级上Kafka定义了4个类来构建整套分层时间轮体系。

  • TimerTask类建模Kafka延时请求。它是一个Runnable类Kafka使用一个单独线程异步添加延时请求到时间轮。
  • TimerTaskEntry类建模时间轮Bucket下延时请求链表的元素类型封装了TimerTask对象和定时任务的过期时间戳信息。
  • TimerTaskList类建模时间轮Bucket下的延时请求双向循环链表提供O(1)时间复杂度的请求插入和删除。
  • TimingWheel类建模时间轮类型统一管理下辖的所有Bucket以及定时任务。

在下一讲中我们将继续学习Kafka延时请求以及管理它们的DelayedOperation家族的源码。只有了解了DelayedOperation及其具体实现子类的代码我们才能完整地了解当请求不能被及时处理时Kafka是如何应对的。

在分布式系统中如何优雅而高效地延迟处理任务是摆在设计者面前的难题之一。我建议你好好学习下这套实现机制在Kafka中的应用代码活学活用将其彻底私有化加入到你的工具箱中。

课后讨论

TimingWheel类中的overflowWheel变量为什么是volatile型的

欢迎你在留言区畅所欲言,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。