kafka延时请求-分层时间轮

2,678 阅读8分钟

时间永远在向前流动,滴答滴答滴答。

在很多场景下需要用到定时任务,例如linux系统中都crontab,基本涉及到定时时间调度的底层都采用了时间轮都思想,时间轮分为简单和分层,以下记录都是分层。在kafka中,也存在延迟请求的场景,比如配置了acks=all,生产者发送一条请求,需要ISR(与主分区同步的所有副本)都返回处理成功的时候,才会标注当前请求已经完成,否则即为未满足条件而无法处理的任务。

思考以下如果自己来实现一个延迟请求,怎么处理呢?猜想是有一个数据结构根据到期时间排序存储定时任务,然后能够动态的放入和取出任务,同时有定时器进行轮询这个数据结构,判断是否取出的定时任务到期了,如果到期了处理,没有到期则继续等待下一个时期定时。如果是在多实例的情况下,这个数据结构需要单独有一个地方存储,并且需要处理多个实例只有一个实例取出任务执行。

在已有的项目中想到的就是每隔一段时间定时运行任务,当时采取的方案是任务的定时配置与任务信息都存储在数据库中,服务中起一个定时器,每隔一个时间搜索数据库中配置了定时的任务,检查是否到期,如果到期了就执行,采用乐观锁的方式防止出现竞争关系。

但是这样的问题会是什么呢?

首先,这个定时间隔是多少呢,如果同时有两个任务,一个任务的定时时间是1毫秒,一个定时任务是100天,那么设置1毫秒才能扫描到这个任务,但是中间的大量时间就浪费了;并且数据库需要分库分表,如果一开始不这么做,后续会很复杂;当定时任务数量级很大的时候,容易造成单点瓶颈。

其次,而这个存储定时任务的数据结构如果是最小堆,添加与删除任务的时间复杂度是O(nlogn),在任务数量很大的时候表现不如意,时间轮的话可以降到O(1)。

后续可以看到对于这两个问题kafka的时间轮是如何解决的。

一直在想kafka中是如何保证在分布式中实现定时任务的,kafka中一个topic而言不是每个分区副本都进行任务调度的,只有一台leader,当它出现问题的时候会将leader的角色转给其他副本进行选举出来,而时间轮存在在这个leader上,所以不存在多个实例同时定时的情况,这个是和上面项目中方案的区别所在。所以在之前的项目中方案可以进行改进,定时任务的定时信息与任务的普通信息可以分开进行存储,实例可以单独一个角色出来负责定时的管理,如果这个时候该服务挂掉了咋办,数据被复制到了多个副本中,依旧能继续工作。

时间轮介绍

时间轮就像一个手表表盘,秒针,时针,分针,60秒=1分钟,60分钟=1小时,每个针都在往前滴答推进,对应到时间轮的数据结构和各个部分关系就如下面的图。

大概介绍一下时间轮的概念,kafka的定时器只会持有第一层的时间轮TimingWhere,每一层的时间轮会持有上一层时间轮的引用overflowWheel。时间轮的每一隔叫做bucket,每个时间轮都有相同个数的bucket,bucket中是一个双向链表代表相同时间(大概)要执行的任务,双向链表的实现是采用哨兵的机制,每一个元素是TimerTaskEntry,元素中的任务是TimerTask.

之前说的DelayQueue在元素的插入和删除上性能不如TimingWheel,那为什么这里还是用到了?就要说到kafka中时间轮推进是如何进行的,对于问题一而言,如果两个任务之间相差的时间较长,定时器会进行“空等待”,kafka用delayQueue中存储TimerTaskList来进行推进,DelayQueue实质上是实现了PriorityQueue,最小堆查找min的时间复杂度是O(1),并且不是每一个TimerTaskEntry插入DelayQueue,而是双向链表是其中的元素,因此,只有当bucket之前为空并且有新的元素加入的时候才会插入DelayQueue, 均摊了。kafka会有一个线程获取DelayQueue中min的任务,然后到时间轮上进行推进,而不是1秒1秒的推进。

具体的代码见附录

Kafka中管理时间轮的东东

Kakfa中负责将时间轮管理并集成到框架中的是Timer接口和SystemTimer类

timer接口

trait Timer {
  def add(timerTask: TimerTask): Unit
  def advanceClock(timeoutMs: Long): Boolean
  def size: Int
  def shutdown(): Unit
}

SystemTimer类

Timer接口的实现类,是个定时器类,封装了时间轮对象,为Purgatory缓冲区(延时请求的存放处)提供延时请求管理功能。

executorName:Purgatory 的名字。Kafka 中存在不同的 Purgatory,比如专门处理生产者延迟请求的 Produce 缓冲区、处理消费者延迟请求的 Fetch 缓冲区等。这里的 Produce 和 Fetch 就是 executorName

startMs:该 SystemTimer 定时器启动时间,单位是毫秒

class SystemTimer(executorName: String,
                  tickMs: Long = 1,
                  wheelSize: Int = 20,
                  startMs: Long = Time.SYSTEM.hiResClockMs) extends Timer {
// 单线程线程池用于异步处理定时任务
  private[this] val taskExecutor = Executors.newFixedThreadPool(1,
    (runnable: Runnable) => KafkaThread.nonDaemon("executor-" + executorName, runnable))

  private[this] val delayQueue = new DelayQueue[TimerTaskList]()
  private[this] val taskCounter = new AtomicInteger(0)
  private[this] val timingWheel = new TimingWheel(
    tickMs = tickMs,
    wheelSize = wheelSize,
    startMs = startMs,
    taskCounter = taskCounter,
    delayQueue
  )

  private[this] val readWriteLock = new ReentrantReadWriteLock()
  private[this] val readLock = readWriteLock.readLock()
  private[this] val writeLock = readWriteLock.writeLock()
  
  // add方法调用该方法,
  private def addTimerTaskEntry(timerTaskEntry: TimerTaskEntry): Unit = {
    //未取消,未过期则添加到时间轮
    if (!timingWheel.add(timerTaskEntry)) {
      // 取消了则什么都不做
      if (!timerTaskEntry.cancelled)
        // 未取消,添加到时间轮失败,说明过期了,加入到线程池执行任务
        taskExecutor.submit(timerTaskEntry.timerTask)
    }
  }
 }
 
 // 驱动时间前进
 def advanceClock(timeoutMs: Long): Boolean = {
    var bucket = delayQueue.poll(timeoutMs, TimeUnit.MILLISECONDS)
    if (bucket != null) { // 从delayQueue中获取
      writeLock.lock()
      try {
        while (bucket != null) {
          timingWheel.advanceClock(bucket.getExpiration()) //推进到bucket过期时间
          bucket.flush(reinsert) //bucket下所有任务重新写回时间轮
          bucket = delayQueue.poll() //获取下一个bucket
        }
      } finally {
        writeLock.unlock()
      }
      true
    } else {
      false
    }
  }
  
当时间在第一层时间轮最后一格的时候,第二层时间轮上的任务有可能会被降级到第一层时间轮上,这个是由reinsert方法实现的。

到目前为止底层已经封装好了时间轮以及提供给上层较为便捷的方法操作时间轮,那么上层类如何操作的代码如下

DelayedOperation类

继承了TimerTask类,支持延时请求的取消,支持绑定到TimerTaskEntry上。

completed:该延时请求是否完成

tryCompletePending:拿到锁的线程可以再次检查是否完成

其中有七个方法,因为是abstract类,会被子类改写,forceComplete,isCompleted,onExpiration,onComplete,tryComplete,maybeTryComplete,run

主要看一下maybeTryComplete()方法,如果有线程A和线程B,线程A拿到了锁,检测出没有完成,线程B拿不到锁阻塞住了,而此时实际上已经完成了,那么会造成两个线程被阻塞,请求超时的情况,为了优化这个出现了maybeTryComplete方法。

abstract class DelayedOperation(override val delayMs: Long,
                                lockOpt: Option[Lock] = None)
  extends TimerTask with Logging {

  private val completed = new AtomicBoolean(false)
  private val tryCompletePending = new AtomicBoolean(false)
  private[server] val lock: Lock = lockOpt.getOrElse(new ReentrantLock)
  
  private[server] def maybeTryComplete(): Boolean = {
    var retry = false
    var done = false
    do {
      if (lock.tryLock()) {
        try {
          tryCompletePending.set(false)
          done = tryComplete()
        } finally {
          lock.unlock()
        }
        retry = tryCompletePending.get()
      } else {
        // 如果拿不到锁说明另一个线程已经持有锁了,那么检测是否完成的任务就交给它了,重试什么的也是它的责任了
        // 设置tryCompletePending为true,给持有锁的线程一个机会去重试
        retry = !tryCompletePending.getAndSet(true)
      }
    } while (!isCompleted && retry)
    done
  }

DelayedOperationPurgatory类

purgatoryName: purgatory的名字,存放延迟请求的缓冲区

brokerId

还有两个内置类:Watchers和WatcherList,Watchers是一个基于key的延时请求的链表,key可以是消费者组的字符串类型,表示主题分区的TopicPartitionOperationKey类型等,可以用来监控保存其中的延迟请求的可完成状态。WatcherList中有个字段是watchersByKey,是一个Pool,本质上是一个ConcurrentHashMap, key是任何类型,Value是Key对应类型的一组Watchers对象。

final class DelayedOperationPurgatory[T <: DelayedOperation](purgatoryName: String,
                                                             timeoutTimer: Timer,
                                                             brokerId: Int = 0,
                                                             purgeInterval: Int = 1000,
                                                             reaperEnabled: Boolean = true,
                                                             timerEnabled: Boolean = true)
        extends Logging with KafkaMetricsGroup {
        
def tryCompleteElseWatch(operation: T, watchKeys: Seq[Any]): Boolean = {
    assert(watchKeys.nonEmpty, "The watch key list can't be empty")
    // 如果是本线程完成的,返回true
    var isCompletedByMe = operation.tryComplete()
    if (isCompletedByMe)
      return true

    var watchCreated = false
    //遍历监控的所有key
    for(key <- watchKeys) {
      //如果是被其他线程完成的返回false
      if (operation.isCompleted)
        return false
       // 把operation加入到key所在的watchList中
      watchForOperation(key, operation)
      // 设置watchCreated标记,表明operation已经加入到watchList中
      if (!watchCreated) {
        watchCreated = true
        // 更新purgatory中的总请求数
        estimatedTotalOperations.incrementAndGet()
      }
    }
    // 再次尝试完成延时请求
    isCompletedByMe = operation.maybeTryComplete()
    if (isCompletedByMe)
      return true

    // 如果还是不能完成,加入到过期队列
    if (!operation.isCompleted) {
      if (timerEnabled)
        timeoutTimer.add(operation)
      if (operation.isCompleted) {
        operation.cancel()
      }
    }

    false
  }


尝试连起来

附:时间轮基本类源码阅读(从下往上)

TimerTask类

trait TimerTask extends Runnable { 
	val delayMs: Long // 通常是request.timeout.ms参数值 
	// 每个定时任务在哪个Bucket链表下的哪个元素上
	private[this] var timerTaskEntry: TimerTaskEntry = null
}

TimerTaskEntry类

private[timer] class TimerTaskEntry(val timerTask: TimerTask, val expirationMs: Long) extends Ordered[TimerTaskEntry] {

  @volatile
  var list: TimerTaskList = null
  var next: TimerTaskEntry = null
  var prev: TimerTaskEntry = null

  // if this timerTask is already held by an existing timer task entry,
  // setTimerTaskEntry will remove it.
  if (timerTask != null) timerTask.setTimerTaskEntry(this)

  def cancelled: Boolean = {
    timerTask.getTimerTaskEntry != this
  }

  def remove(): Unit = {
    var currentList = list
    // 用while是为了在多个线程的情况下,确保当前元素被删除了
    while (currentList != null) {
      currentList.remove(this)
      currentList = list
    }
  }
}

TimerTaskList类

taskCounter记录这个链表中有多少定时任务,expiration为bucket的起始时间

setExpiration方法使用了CAS原子方法操作,如果过期时间改变了返回true,为什么要用CAS呢,因为kafka使用了delayQueue管理TimerTaskList对象来推进时间,当bucket过期后kafka会采用重新设置过期时间的方式来重用bucket,并需要重新插入到delayQueue中,是否需要重新插入呢,就根据这个函数返回来看。

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)
  
  def setExpiration(expirationMs: Long): Boolean = {
    expiration.getAndSet(expirationMs) != expirationMs
  }
  }

TimingWheel类

tickMs:每一隔的时间,“滴答”

wheelSize: bucket大小

startMs: 时间轮对象的创建时间

taskCounter: 时间轮里面一共的定时任务数量

queue: 里面存放TimerTaskList对象,用于推进时间轮

interval: 这层时间轮的总长,例如:第一层有20个bucket,每个为1ms,这一层的总时长为20ms,那么第二层有20个bucket,每个为20ms,总时长为400ms,一次类推。

buckets: 时间轮底层是一个数组

currentTime: 当前时间戳,做了微调,变成小于当前时间的最大的tick的整数倍时常

overflowWheel: 按需创建上一层时间轮,如果第一层放不了就创建第二层,第二层不行就继续往上直到可以放下

由于每轮都是倍增的,因此不需要很多轮就可以满足要求了。

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) // rounding down to multiple of tickMs

  // overflowWheel can potentially be updated and read by two concurrent threads through add().
  // Therefore, it needs to be volatile due to the issue of Double-Checked Locking pattern with JVM
  @volatile private[this] var overflowWheel: TimingWheel = null
  
  
  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
      val bucket = buckets((virtualId % wheelSize.toLong).toInt)
      bucket.add(timerTaskEntry)

      if (bucket.setExpiration(virtualId * tickMs)) {
        queue.offer(bucket) // 如果过期时间被修改过,需要重新加入到delayQueue中
      }
      true
    } else {
      if (overflowWheel == null) addOverflowWheel() //该层处理不了,交给上层
      overflowWheel.add(timerTaskEntry)
    }
  }
  
 def advanceClock(timeMs: Long): Unit = {
    // 向前驱动到的时间点要超过bucket的时间范围才是有意义
    if (timeMs >= currentTime + tickMs) {
      currentTime = timeMs - (timeMs % tickMs)

      // 如果存在上层时间轮,更新currentTime,并递归的为向上推进做准备
      // 推进的线程由后台线程Reaper发起
      if (overflowWheel != null) overflowWheel.advanceClock(currentTime)
    }
  }