Kafka的TimingWheel(时间轮)算法

Kafka中存在大量的延迟操作,比如延迟生产、延迟拉取和延迟删除等,Kafka并没有使用JDK自带的Timer或DelayQueue来实现延时的功能,而是基于时间轮算法自定义实现了一个用于延迟功能的定时器(SystemTimer)。

JDK中的Timer和DelayQueue单个任务的插入和删除的平均时间复杂度为O(logN),并不能满足Kafka的高性能要求,而基于时间轮可以将任务的插入和删除操作的时间复杂度降为O(1)。

下图即为Kafka的时间轮结构:

在这里插入图片描述

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

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

下面我们通过Kafka的源代码来具体讲解一下时间轮算法。

任务添加

def add(timerTaskEntry: TimerTaskEntry): Boolean = {
	val expiration = timerTaskEntry.expirationMs

	if (timerTaskEntry.cancelled) {
	  // Cancelled
	  false
	} else if (expiration < currentTime + tickMs) {
	  // Already expired
	  false
	} else if (expiration < currentTime + interval) {
	  // Put in its own bucket
	  val virtualId = expiration / tickMs
	  val bucket = buckets((virtualId % wheelSize.toLong).toInt)
	  bucket.add(timerTaskEntry)

	  // Set the bucket expiration time
	  if (bucket.setExpiration(virtualId * tickMs)) {
		// The bucket needs to be enqueued because it was an expired bucket
		// We only need to enqueue the bucket when its expiration time has changed, i.e. the wheel has advanced
		// and the previous buckets gets reused; further calls to set the expiration within the same wheel cycle
		// will pass in the same value and hence return false, thus the bucket with the same expiration will not
		// be enqueued multiple times.
		queue.offer(bucket)
	  }
	  true
	} else {
	  // Out of the interval. Put it into the parent timer
	  if (overflowWheel == null) addOverflowWheel()
	  overflowWheel.add(timerTaskEntry)
	}
}

以222任务为例,讲解一下任务添加到时间轮的过程:

SystemTimer的addTimerTaskEntry方法调用的是TimeingWheel的add方法,若任务添加失败,则证明当前任务已到期,直接将该任务交给工作线程来执行;

TimeingWheel的add方法首先获取任务的过期时间expiration,这里为222;下面走到判断逻辑:

  • 若expiration < currentTime + tick,证明当前任务已到期,则直接返回fasle,将该任务交给工作线程来执行;

假设SystemTimer的创建时间为0,则SystemTimer创建的TimeingWheel的currentTime也为0,由于222 > 0+1,所以不符合第1个判断,进入第2个判断。

  • 若expiration < currentTime + interval,证明当前层次的时间轮就可以容纳该任务,将任务放入该时间轮的对应槽;

由于222 > 0+10,所以不符合第2个判断,进入第3个判断。

  • 若expiration >= currentTime + interval,证明该层次的时间轮不可以容纳该任务,需要往上尝试上一层时间轮;

获取到上一层时间轮后,直接在上一层时间轮上继续执行add方法。

第2层时间轮的tick=10,interval=100,由于222 > 0+100,所以还是进入到第3个判断,继续获取上一层时间轮。

第3层时间轮的tick=100,interval=1000,由于222 < 0+1000,所以进入到第2个判断,执行任务的添加过程。

下面接着看任务的添加过程:

(1) 首先是计算槽位;

val virtualId = expiration / tickMs
val bucket = buckets((virtualId % wheelSize.toLong).toInt)

virtualId = 222 / 100 = 2
bucket = 2 % 10 = 2

即第2个槽位,对应[200-300]的范围

(2) 获取到该槽位上的任务链表,并将任务添加到链表里;

bucket.add(timerTaskEntry)

(3) 若该链表是首次添加任务,则需要设置链表的过期时间expiration,并将该链表添加到SystemTimer的DelayQueue中。

// Set the bucket expiration time
if (bucket.setExpiration(virtualId * tickMs)) {
	queue.offer(bucket)
}

过期时间为2*100=200

可以看一下timerTaskList.setExpiration方法:

def setExpiration(expirationMs: Long): Boolean = {
	expiration.getAndSet(expirationMs) != expirationMs
}

可以发现,链表的过期时间若与之前设置的相同,则直接返回False,避免重复将链表添加到Timer的DelayQueue中。

在这里插入图片描述

时间轮推动

接下来,我们看一下如何推动时间轮,假设我们创建了1个SystemTimer,并添加了过期时间为9、88、222、520、521、522等6个定时任务,分别编号①到⑥。

任务添加后的时间轮示意图如下:

在这里插入图片描述

SystemTimer构造器如下:

@threadsafe
class SystemTimer(executorName: String,
                  tickMs: Long = 1,
                  wheelSize: Int = 20,
                  startMs: Long = Time.SYSTEM.hiResClockMs) extends Timer {

  // timeout 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
  )

SystemTimer是依靠DelayQueue来进行时间轮推进的。

def advanceClock(timeoutMs: Long): Boolean = {
	// 获取DelayQueue首元素(最快过期的任务槽)
	var bucket = delayQueue.poll(timeoutMs, TimeUnit.MILLISECONDS)
	if (bucket != null) {
	  writeLock.lock()
	  try {
		// 若任务槽不为null,则不断循环(while保证了时间轮的不断推进)
		while (bucket != null) {
		  // 级联更新各层级的时间轮currentTime为时间槽的过期时间
		  timingWheel.advanceClock(bucket.getExpiration)
		  // 删除该槽,并将时间槽中的任务重新添加到时间轮
		  bucket.flush(addTimerTaskEntry)
		  // 继续获取DelayQueue首元素(最快过期的任务槽)
		  bucket = delayQueue.poll()
		}
	  } finally {
		writeLock.unlock()
	  }
	  true
	} else {
	  false
	}
}
  • 任务1所在槽为DelayQueue首元素,其过期时间为9,然后,各级时间轮的currentTime更新为9;
  • 对任务1所在槽中的各元素执行flush操作;
// Remove all task entries and apply the supplied function to each of them
def flush(f: TimerTaskEntry => Unit): Unit = {
	synchronized {
	  var head = root.next
	  while (head ne root) {
		// 删除槽中的各元素
		remove(head)
		// 执行传入的function
		f(head)
		head = root.next
	  }
	  // 将原有时间槽的过期时间设置为-1
	  expiration.set(-1L)
	}
}

flush传入的函数为SystemTimer的addTimerTaskEntry:

private def addTimerTaskEntry(timerTaskEntry: TimerTaskEntry): Unit = {
	// 尝试往时间轮中添加任务TimerTaskEntry
	if (!timingWheel.add(timerTaskEntry)) {
	  // Already expired or cancelled
	  // 若添加失败,则证明该任务被取消或者已经过期
	  if (!timerTaskEntry.cancelled)
	    // 过期任务,直接提交给工作线程执行
		taskExecutor.submit(timerTaskEntry.timerTask)
	}
}

任务1重新添加到时间轮,此时:

currentTime=9
expiration=9
tick=1
interval=10

expiration < currentTime + tick,证明当前任务已到期,则直接返回fasle,将该任务交给工作线程来执行
  • 继续执行delayQueue.poll()方法,此时返回任务②所在的槽,其过期时间为80,然后,各级时间轮的currentTime更新为80;

  • 任务②重新添加到时间轮,此时:

currentTime=80
expiration=88
tick=1
interval=10

expiration < currentTime + interval,证明当前层次的时间轮就可以容纳该任务,将任务放入该时间轮的对应槽;

virtualId = expiration / tickMs = 88 / 1 = 88
bucket = virtualId % wheelSize = 88 % 10 = 8
即第8个槽位,将槽位的过期时间设置为88,并添加到延迟队列delayQueue中
  • 继续执行delayQueue.poll()方法,此时返回任务②所在的槽,其过期时间为88,然后,各级时间轮的currentTime更新为88;

  • 任务②重新添加到时间轮,此时:

currentTime=88
expiration=88
tick=1
interval=10

expiration < currentTime + tick,证明当前任务已到期,则直接返回fasle,将该任务交给工作线程来执行

其他任务以此类推。

重点理解2点:

(1) currentTime是如何演进的;
(2) 任务是如何从时间大轮向小轮降级的。

任务槽

SystemTimer是依靠DelayQueue来进行时间轮推进的,而DelayQueue中的元素则为时间轮中的槽TimerTaskList。

添加到延迟队列的元素必须实现Delayed接口的getDelay和compareTo方法:

def getDelay(unit: TimeUnit): Long = {
	unit.convert(max(getExpiration - Time.SYSTEM.hiResClockMs, 0), TimeUnit.MILLISECONDS)
}

def compareTo(d: Delayed): Int = {
	val other = d.asInstanceOf[TimerTaskList]
	java.lang.Long.compare(getExpiration, other.getExpiration)
}

当任务槽的delay<=0时,该任务槽会被从延迟队列中poll出来,然后遍历槽中的元素,依次执行重新添加到时间轮的操作;

将时间槽中的任务重新添加到时间轮时,会发生任务槽降级或者任务直接提交给工作线程执行。

每次重新添加槽,均是从最小的时间轮开始尝试的:

比如任务③,其初始槽位在第3层时间轮的第2个槽位,当其被取出重新添加到时间轮时,首先从第1层时间轮尝试:

currentTime=200
expiration=222
tick=1
interval=10

expiration >= currentTime + interval,证明该层次的时间轮不可以容纳该任务,需要往上尝试上一层时间轮

接着尝试第2层时间轮:

currentTime=200
expiration=222
tick=10
interval=100

expiration < currentTime + interval,证明当前层次的时间轮就可以容纳该任务,将任务放入该时间轮的对应槽;

virtualId = 222 / 10 = 22
bucket = 22 % 10 = 2

即第2个槽位,并设置该槽位的过期时间为virtualId * tickMs = 22*10 = 220

可以发现,任务③从第3层时间轮的第2个时间槽(过期时间为200)降级到第2层时间轮的第2个时间槽(过期时间为220)。

依次推导,下次降级会从第2层时间轮的第2个时间槽(过期时间为220)降级到第1层时间轮的第2个时间槽(过期时间为222)。

接着再次降级,此时,已没有更低精度的时间轮了,expiration < currentTime + tick,表明当前任务已到期,将该任务交给工作线程来执行。

综上,随着时间轮的不断推进,任务会被反复重新添加到时间轮,其槽位会不断降级,且过期时间的精度也会逐步提高,直至精度到达最小时间轮的精度,表明任务真正到期,提交执行。

分析到这里可以发现,Kafka中的TimingWheel专门用来执行插入和删除TimerTaskEntry的操作,而DelayQueue专门负责时间推进的任务。试想一下,DelayQueue中的第一个超时任务列表的expiration为200ms,第二个超时任务为840ms,这里获取DelayQueue 的队头只需要O(1)的时间复杂度(获取之后DelayQueue内部才会再次切换出新的队头)。如果采用每毫秒定时推进,那么获取第一个超时的任务列表时执行的200次推进中有199次属于“空推进”,而获取第二个超时任务时又需要执行639次“空推进”,这样会无故空耗机器的性能资源,这里采用DelayQueue来辅助以少量空间换时间,从而做到了“精准推进”。Kafka中的定时器真可谓“知人善用”,用TimjngWheel做最擅长的任务添加和删除操作,而用DelayQueue做最擅长的时间推进工作,两者相辅相成。

参考文献:

[1] https://github.com/apache/kafka/tree/trunk/core/src/main/scala/kafka/utils/timer
[2] 《深入理解Kafka:核心设计与实践原理》,作者:朱忠华,出版社:电子工业出版社

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值