Kafka详解(七)Purgatory

DelayedOperationPurgatory是用来缓存延时请求(Delayed Request)的。所谓延时请求,就是那些一时未满足条件不能立刻处理的请求。比如设置了 acks=all 的 PRODUCE 请求,一旦设置了 acks=all,那么该请求就必须等待 ISR 中所有副本都接收了消息后才能返回,此时处理该请求的 IO 线程就必须等待其他 Broker 的写入结果。当请求不能立刻处理时,它就会暂存在 Purgatory 中。稍后一旦满足了完成条件,IO 线程会继续处理该请求,并将 Response 放入对应网络线程的响应队列中。

1. 时间轮定义

时间轮方案将现实生活中的时钟概念引入到软件设计中,主要思路是定义一个时钟周期(比如时钟的12小时)和步长(比如时钟的一秒走一次),当指针每走一步的时候,会获取当前时钟刻度上挂载的任务并执行,整体结构如下图:

从上图可以看到,对于时间的计算是交给一个类似时钟的组件来做,而任务是通过一个指针或者引用去关联某个刻度上到期的定时任务,这样就能够将定时任务的存储和时间进行解耦,时钟组件难度不大,以何种方式存储这些任务数据,是时间轮方案的关键。

时间轮的好处在于如果时间轮的指针指到了对应的格子,那么该格子指向的队列里面的任务就都是过期的,可以减少很多不必要的无意义的定时扫描。

2. Kafka的时间轮

Kafka中的时间轮类称为TimingWheel,该类结构为存储定时任务的 环形队列,内部使用数组实现,数组是用于存放TimerTaskList对象,TimerTaskList环形双向链表,链表项TimerTaskEntry封装了定时任务TimerTask,TimerTaskList与TimerTaskEntry中均有超时时间字段,TimerTask中delayMs字段用于记录任务延迟时间;该三个类为Kafka时间轮实现的核心。

  • TimingWheel:表示一个时间轮,通常会有多层时间轮也就存在多个TimingWheel对象;
  • TimerTaskList:为数组对象用于存放延迟任务,一个TimerTaskList就代表一个时间格,一个时间格中能保存的任务到期时间只可在[t~t+10ms]区间(t为时间格到期时间,10ms时间格间格),每个时间格有个过期时间,时间格过期后时间格中的任务将向前移动存入前面时间格中;
  • TimerTask:表示延迟任务;
  • SystemTimer:kafka实现的定时器,内部封装了TimningWheel用于执行、管理定时任务;

下面通过一个示例来介绍kafka时间轮的工作过程:

  1. 时间轮初始化:初始时间轮中的格数、间隔、指针的初始化时间,创建时间格所对应的buckets数组,计算总间隔interval;
  2. 添加延迟任务:判断该任务是否已被取消、是否已经过期如已过期则把任务放入线程池中执行、根据时间轮总间隔与当前时间判断任务是否可存入当前层级时间轮否则添加上层时间轮并再次尝试往时间轮中添加该任务;
  3. 时间轮降级:有一个定时任务再300ms后将执行,现层级时间轮每层有10个时间格,顶层时间轮的时间格间隔为1ms,整个时间轮为10ms,无法存下该任务。这时创建第二层时间轮,时间格间隔为10ms,整个时间轮为100ms,还是无法存该任务。接着创建第三层时间轮,时间格间隔为100ms,整个时间轮为1000ms,此时任务存入第三层时间轮的第三个时间格中;过了段时间,TimerTaskList到期(时间格)可该任务还有90ms,还无法执行。此时将再次把定时任务添加到时间轮中,顶层时间轮还是无法满足存入条件,往第二层时间轮添加,这时定时任务存入第二层时间轮第九个时间格当中;任务在时间轮中如此反复,直到任务过期时将放入线程池中执行;

3. DelayedOperationPurgatory核心结构

下图可以看出,DelayedProducePurgatory有2个核心部件,1个是一个Watchers的map,1个是Timer。对应的DelayedProduce有2个角色:一个是DelayedOperation,同时它也是一个TimerTask。

每当处理一个ProduceRequest,就会生成一个DelayedProduce对象,被加入到一个Watchers中,同时它也会作为一个TimerTask,加入到Timer中。

最后这个DelayedProduce可能被接下来的Fetch满足,也可能在Timer中超时,给客户端返回超时错误。如果是前者,那就需要调用TimerTask.cancel,把该任务从Timer中删除。

这里写图片描述

3.1 Timer的使用方式

这里写图片描述

Kafka的Timer使用方式如上图所示:

  • 调用者(也就是DelayedOperationPurgatory)不断调用timer.add函数加入新的Task;另一方面,不是Timer内部有线程驱动,而是有一个外部线程ExpiredOperationReaper,不断调用timer.advanceClock函数,来驱动整个Timer。
  • 同时,当某一个TimerTask到期之后,不是由Timer直接执行此TimerTask。而是交由一个executor,来执行所有过期的TimerTask。之所以这么做,是因为不能让TimerTask的执行阻塞Timer本身的进度。

总结一下:这里有2个外部线程,一个驱动Timer,一个executor,专门用来执行过期的Task。这2个线程,都是DelayedOperationPurgatory的内部变量。

3.2 Timer内部结构

下面先看一下Timer的内部结构:

这里写图片描述

  • Timer是最外层类,表示一个定时器。其内部有一个TimingWheel对象,TimingWheel是有层次结构的,每个TimingWheel可能有parent TimingWheel(这个原理就类似我们生活中的水表,不同表盘有不同刻度)。
  • TimingWheel是一个时间刻度盘,每个刻度上有一个TimerTask的双向链表,称之为一个bucket。同1个bucket里面的所有Task,其过期时间相等。因此,每1个bucket有一个过期时间的字段。
  • 所有TimingWheel共用了一个DelayedQueue,这个DelayedQueue存储了所有的bucket,而不是所有的TimerTask。

3.3 Timer的3大核心功能

对于一个Timer来说,有3大功能:

  1. 添加:把一个TimerTask加入Timer
  2. 过期:时间到了,执行所有那些过期的TimerTask
  3. 取消:时间未到,取消TimerTask。把TimerTask删除

3.3.1 添加

//Timer的对外接口add
class Timer(taskExecutor: ExecutorService, tickMs: Long = 1, wheelSize: Int = 20, startMs: Long = System.currentTimeMillis) {
  ...
  def add(timerTask: TimerTask): Unit = {
    readLock.lock()
    try {
      addTimerTaskEntry(new TimerTaskEntry(timerTask))  //把TimerTask包装成一个TimerTaskEntry节点,添加进去
    } finally {
      readLock.unlock()
    }
  }

  private def addTimerTaskEntry(timerTaskEntry: TimerTaskEntry): Unit = {
    if (!timingWheel.add(timerTaskEntry)) {
      // Already expired or cancelled
      if (!timerTaskEntry.cancelled) //关键点:如果该TimerTask不能被加入timingWheel,说明其已经过期了。同时该任务又没有被取消,则直接执行此任务
        taskExecutor.submit(timerTaskEntry.timerTask)
    }
  }

//关键的TimingWheel的add函数
  def add(timerTaskEntry: TimerTaskEntry): Boolean = {
    val expiration = timerTaskEntry.timerTask.expirationMs

    if (timerTaskEntry.cancelled) {
      // 如果该任务已经被取消,则不加入timingWheel
      false
    } else if (expiration < currentTime + tickMs) {
      //如果该Task的过期时间已经小于当前时间 + 基本的tick单位(1ms),说明此任务已经过期了,不用再加入timingWheel
      false
    } else if (expiration < currentTime + interval) {
      // 如果过期时间 < 当前时间 + interval,则说明当前的刻度盘可以表达此过期时间。这里的interval就是当前刻度盘所能表达的最大时间范围:tickMs * wheelSize

      //这里tickMs设置的是1ms,所以virtualId = expiration
      val virtualId = expiration / tickMs

      //关键的hash函数:根据过期时间,计算出bucket的位置
      val bucket = buckets((virtualId % wheelSize.toLong).toInt) 

      //把该Task加入bucket
      bucket.add(timerTaskEntry)

      //同一个bucket,所有task的expiration是相等的。因此,expiration相等的task,会hash到同1个bucket,然后此函数只第1次调用会成功
      if (bucket.setExpiration(virtualId * tickMs)) {
        queue.offer(bucket) //该桶只会被加入delayedQueue1次
      }
      true
    } else {
      //过期时间超出了currentTime + interval,说明该过期时间超出了当前刻度盘所能表达的最大范围,则调用其parent刻度盘,来试图加入此Task
      if (overflowWheel == null) addOverflowWheel()
      overflowWheel.add(timerTaskEntry)
    }
  }

3.3.2 过期

正如上面的图所示,外部线程每隔200ms调用1次advanceClock,从而驱动时钟不断运转。在驱动过程中,发现过期的Task,放入executors执行。

  private class ExpiredOperationReaper extends ShutdownableThread(
    "ExpirationReaper-%d".format(brokerId),
    false) {

    override def doWork() {
      //不断循环,每200ms调用1次advanceClock
      timeoutTimer.advanceClock(200L)  
      ...
    }
}


  def advanceClock(timeoutMs: Long): Boolean = {
    //关键点:这里判断一个Task是否过期,其实还是用delayedQueue来判断的。而不是TimingWheel本事
    //过期的bucket会从队列的首部出对
    var bucket = delayQueue.poll(timeoutMs, TimeUnit.MILLISECONDS)
    if (bucket != null) {
      writeLock.lock()
      try {
        while (bucket != null) {
          //把timingWheel的进度,调整到队列首部的bucket的过期时间,也就是当前时间
          timingWheel.advanceClock(bucket.getExpiration())

          //清空bucket,执行bucket中每个Task的过期函数(执行方式就是把所有这些过期的Task,放入executors)
          bucket.flush(reinsert)

          //再次从队列首部拿下1个过期的bucket。如果没有,直接返回null。该函数不会阻塞
          bucket = delayQueue.poll()
        }
      } finally {
        writeLock.unlock()
      }
      true
    } else {
      false
    }
  }

//TimingWheel
  def advanceClock(timeMs: Long): Unit = {
    if (timeMs >= currentTime + tickMs) {
      //更新currentTime(把timeMs取整,赋给currentTime)
      currentTime = timeMs - (timeMs % tickMs)

      //更新parent timingWheel的currentTime
      if (overflowWheel != null) overflowWheel.advanceClock(currentTime)
    }
  }

3.3.3 取消

Task的取消,并不是在Timer里面实现的。而是TimerTask自身,定义了一个cancel函数。所谓cancel,就是自己把自己用TimerTaskEntryList这个双向链表中删除。

trait TimerTask extends Runnable {

  val expirationMs: Long // timestamp in millisecond

  private[this] var timerTaskEntry: TimerTaskEntry = null

  def cancel(): Unit = {
    synchronized {
      if (timerTaskEntry != null) timerTaskEntry.remove()
      timerTaskEntry = null
    }
  }

  def remove(): Unit = {
    var currentList = list
    while (currentList != null) {
      currentList.remove(this)  //从链表中,把自己删掉
      currentList = list
    }
  }

 //remove函数。因为是双向链表,所以删除不需要遍历链表。删除复杂度是O(1)
  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()
        }
      }
    }
  }

3.4 TimingWheel本质

3.4.1 DelayedQueue

从上面代码中可以看到,添加/取消的时间复杂度都是O(1)。并且在上面的代码中,大家可以看出,TimingWheel.advanceClock()函数里面其实什么都没做,就只是更新了一下所有刻度盘的currentTime。真正的判断哪个Task过期的逻辑,其实是用DelayedQueue来判断的,而不是通过TimingWheel判断的。

那TimingWheel在此处到底起了一个什么作用呢?

前面讲过,expiration相等的TimerTask,会组成一个双向链表,称之为一个bucket。DelayedQueue的每个节点,放入的就是一个bucket,而不是单个的TimerTask。过期的判断,就是通过DelayedQueue来实现的。

这里写图片描述

3.4.2 计算bucket

但这里有个问题,当我要把某个TimerTask加入这个DelayedQueue时,我怎么计算出所在的bucket呢??

答案就是TimingWheel。这里TimingWheel本质上就是充当了一个hash函数,通过Task的expiration time,hash出所在的bucket。如下图所示:

跟水表一样,刻度盘有多级,每个刻度上对应一个bucket。根据expiration计算所属的bucket的代码,就在上面的add函数里面。

这里写图片描述

3.4.3 刻度盘的层次

每个刻度盘都有个变量,记录currentTime。所有刻度盘的currentTime基本是相等的(会根据自己的tickMs取整)。advanceClock函数,就是更新这个currentTime。

在这里,不同的刻度盘单位其实都是ms。只是不同的刻度盘上,1格所代表的时间长度是不一样的。这里有个关系:

parent 刻度盘的1格表示的时间长度 = child刻度盘的整个表盘所表示的时间范围

在代码中,即:

  private[this] def addOverflowWheel(): Unit = {
    synchronized {
      if (overflowWheel == null) {
        overflowWheel = new TimingWheel(
          tickMs = interval,  //parent刻度盘的刻度tickMs = child刻度盘的整个表盘范围 interval(tickMs * wheelSize)
          wheelSize = wheelSize,
          startMs = currentTime,
          taskCounter = taskCounter,
          queue
        )
      }
    }
  }

因此,从底往上: tickMs = 1ms, wheelSize = 20格

第1层刻度盘能表达的expiration的范围就是[currentTime, currentTime + tickMs*wheelSize]; //每1格1ms,总共20ms范围

第2层刻度盘能表达的expiration的范围就是[currentTime, currentTime + tickMs*wheelSize*wheelSize]; //每1格20ms,总共400ms范围

第3层刻度盘能表达的expiration的范围就是[currentTime, currentTime + tickMs*wheelSize*wheelSize*WheelSize]; //每1格400ms,总共8000ms范围

这里有个细节:

  private[this] val reinsert = (timerTaskEntry: TimerTaskEntry) => addTimerTaskEntry(timerTaskEntry)

  //advanceClock函数
  bucket.flush(reinsert)

严格来讲,同1个bucket里面装的并不是expiration精确相等的Task,而是在当前刻度下,经过取整,落在同1个bucket里面的所有Task。

最底层的精度是1ms,往上1层精度是20ms,意味着相差在20ms以内的所有Task都会落到同1个bucket里面,以此类推。。。

所以当从上层的TimingWheel的bucket中取出来的Task,要重新从最低层加入TimingWheel体系,只有最底层都加不进去,才说明真正的过期!!

发布了8 篇原创文章 · 获赞 0 · 访问量 7278

猜你喜欢

转载自blog.csdn.net/fedorafrog/article/details/103991698