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:核心设计与实践原理》,作者:朱忠华,出版社:电子工业出版社

  • 2
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
你可以使用Spring Boot来整合Kafka时间来实现延时处理。下面是一个简单的示例: 首先,确保你已经在Spring Boot项目中添加了Kafka的依赖。可以在pom.xml文件中添加如下依赖: ```xml <dependency> <groupId>org.springframework.kafka</groupId> <artifactId>spring-kafka</artifactId> </dependency> ``` 接下来,创建一个Kafka生产者来发送延时消息。你可以使用`KafkaTemplate`来发送消息。这是一个简单的示例: ```java import org.springframework.beans.factory.annotation.Autowired; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Component; @Component public class KafkaProducer { @Autowired private KafkaTemplate<String, String> kafkaTemplate; public void sendDelayedMessage(String topic, String message, long delay) { kafkaTemplate.send(topic, message).get(delay, TimeUnit.MILLISECONDS); } } ``` 在这个示例中,`sendDelayedMessage`方法接受一个`topic`、消息内容`message`和延时时间`delay`(以毫秒为单位)。它使用`KafkaTemplate`发送消息,并设置了延时时间。 接下来,创建一个Kafka消费者来处理延时消息。你可以使用`@KafkaListener`注解来监听指定的topic并处理消息。这是一个简单的示例: ```java import org.springframework.kafka.annotation.KafkaListener; import org.springframework.stereotype.Component; @Component public class KafkaConsumer { @KafkaListener(topics = "your-topic") public void receiveMessage(String message) { // 处理收到的消息 System.out.println("Received message: " + message); } } ``` 在这个示例中,`receiveMessage`方法使用`@KafkaListener`注解来监听名为"your-topic"的Kafka主题,并在收到消息时进行处理。 最后,你可以在需要发送延时消息的地方调用`KafkaProducer`的`sendDelayedMessage`方法,将延时消息发送到Kafka中。 注意:这只是一个简单的示例,实际应用中可能需要更复杂的逻辑来处理延时消息。你可能需要使用时间算法来管理延时消息的触发和删除。你可以在项目中引入第三方库来实现时间的功能。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值