大家好,这是一个为了梦想而保持学习的博客。这个专题会记录我对于 KAFKA 的学习和实战经验,希望对大家有所帮助,目录形式依旧为问答的方式,相当于是模拟面试。

一、kafka 服务端大概有哪些延时任务?

首先,我们需要了解一下 kafka 中大概有哪些需要延时的任务,该怎么查看呢?
很简单,kafka 的设计都是基于接口的,那么我们只需要找到延时任务的顶层接口,然后看一下该接口有哪些实现类就知道有哪些延时任务了。
顶层抽象类接口是:DelayedOperation
对应的子类:

kafka 有延迟队列吗 kafka延迟队列如何实现_时间复杂度

DelayedHeartbeat:就是用于做消费者心跳超时检测的;
DelayedProduce:就是做生产者设置 ack=-1 时需要等待所有副本确认写入成功的;
DelayedFetch:就是在消费的时候该分区没有数据,需要去做延时等待;
DelayedJoin:就是去做消费者加组的时候,在 JOIN 阶段需要延时等待。


 

二、kafka 里面的延时任务是如何实现的呢?

这个答案已经在标题中就已经回答了,就是时间轮。
那么时间轮在 kafka 中是如何实现的呢?我们大概来看下整个时间轮的运行图,图源:

kafka 有延迟队列吗 kafka延迟队列如何实现_kafka 有延迟队列吗_02


从上面的图我们可以大概知道,kafka 中的时间轮本体是一个 20 长度数组,不过内部持有上层数组的一个引用,数组中每个元素都是一个 List,存放处于这个时间段的所有任务。

最后将这些有任务的 List 引用,放入 DelayQueue 来实现时间的流动,每次从 DelayQueue 中取出到期的 List 进行对应的操作。

翻译一下:

就是原本把所有延时任务都一股脑全部放入 DelayQueue 中,实在是太多了,由于 DelayQueue 底层数据结构是小顶堆,插入和删除的时间复杂度都是 O (nlog (n)),

n 代表的具体任务的数量,当 n 值非常大时,对应的性能就很差,不能满足一个高性能中间件的要求。于是就想了个办法减小 n 的个数,

就是把原来的一个个延时任务,通过时间区间来封装成一个 List,把 List 作为一个基本单位存入到 DelayQueue 中,那么这一样一来,就能把插入和删除的时间复杂度

从 O (nlog (n)) 降低到接近 O (1)[这里为什么是近似 O (1) 呢?你可以理解为时间轮是一个类 hash 表的结构],除此之外,最重要的就是大大减小了 DelayQueue 中元素的个数 n,

因为一层时间轮就 20 个 List,10 层也就才 200 个,所以对于这么小数量的元素个数,DelayQueue 是完全能 hold 的住的。

总结一下:

其实时间轮的设计思想就是批处理的思想,把一批任务根据时间区间封装成一个 List,最后把 List 放到 DelayQueue 中去实现轮转的效果。

优化点主要是两个,一个是插入 / 删除的时间复杂度由 O (nlog (n)) 降低到了近似 O (1),第二个是大大减小了 DelayQueue 元素的个数。

了解设计思想,我们再看看实现原理:
 

1、核心函数:加入 Task 到时间轮中
分为三步:

  1. 如果任务已经超期就返回 false
  2. 如果任务在自己的时间跨度内,就计算应该放入哪个桶中(在哪个时间区间);如果桶没在 DelayQueue 中则加入到 DelayQueue 中去。
  3. 如果任务的超时时间超过了自己的时间跨度,就往上层时间传,直到找到一个满足时间跨度的时间轮。
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
    // 设置超时时间,如果该桶已经设置了超时时间则说明已经存在于DelayQueue中了
    // 如果不存在超时时间,则需要将当前桶加入DelayQueue中
    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)
  }
}

 

2、时间轮如何推进?
每一个 DelayedOperationPurgatory,都有一个线程 expirationReaper,去负责推进时间轮,如果当前没有 task 到期就挂起 200ms 等待。
如果有 task 到期,就取出对应的桶,然后将桶中的数据全都执行 reinsert,也就是从最底层的时间轮重新执行一遍 add 操作。

/**
 * A background reaper to expire delayed operations that have timed out
 */
private class ExpiredOperationReaper extends ShutdownableThread(
  "ExpirationReaper-%d-%s".format(brokerId, purgatoryName),
  false) {

  override def doWork() {
    advanceClock(200L)
  }
}
def advanceClock(timeoutMs: Long): Boolean = {
  // 从延时队列中取出到期的桶
  var bucket = delayQueue.poll(timeoutMs, TimeUnit.MILLISECONDS)
  if (bucket != null) {
    writeLock.lock()
    try {
      // 一次性把到期的全部取出来
      while (bucket != null) {
        // 时间轮的时间推进
        timingWheel.advanceClock(bucket.getExpiration())
        // 把桶中的所有数据都拿去执行reinsert函数
        // 本质就是去执行addTimerTaskEntry(timerTaskEntry)
        bucket.flush(reinsert)
        bucket = delayQueue.poll()
      }
    } finally {
      writeLock.unlock()
    }
    true
  } else {
    false
  }
}

3、到期的任务如何执行?
其实就是接着上面的源码,当任务到期之后,reinsert 函数会返回 false,代表已经超期 / 被取消了,每个 DelayedOperationPurgatory 又有一个单线程的 taskExecutor,
超期的任务就提交到线程池中去执行即可。

private[this] val reinsert = (timerTaskEntry: TimerTaskEntry) => addTimerTaskEntry(timerTaskEntry)
private def addTimerTaskEntry(timerTaskEntry: TimerTaskEntry): Unit = {
  // 如果时间轮添加返回false则说明超期/被取消了,直接提交到自己的单线程线程池中去执行该task
  if (!timingWheel.add(timerTaskEntry)) {
    // Already expired or cancelled
    if (!timerTaskEntry.cancelled)
      taskExecutor.submit(timerTaskEntry.timerTask)
  }
}

 

4、整个流程的运行图
整个流程概括下来,就是业务代码想 TimingWheel 执行 add,提交任务;
TimingWheel 找到合适的时间轮后插入对应的桶中,并将桶放入 DelayQueue 中;
DelayedOperationPurgatory 组件中存在收割线程,去不停从 DelayQueue 中 poll 对应到期的 task;
最后 task 重新执行 reinsert,如果超期了就提交到 taskExecutor 中去执行对应的业务 handler 逻辑。
 

kafka 有延迟队列吗 kafka延迟队列如何实现_kafka_03


 

三、相比于时间轮,为什么不采用 DelayQueue 来实现延时任务呢?

这个答案在第二小节的时候其实已经给出的,在这里进行一个总结:
1、DelayQueue 底层数据结构是小顶堆,插入和删除的时间复杂度都是 O (nlog (n)),因此面对大量的延时操作时,该结构无法满足 kafka 高性能的要求。
2、时间轮采用批处理的思想将任务按照区间进行封装,形成一类类似 hash 表的结构,让插入 / 删除的时间复杂度降低为 O (1),并且大大减小了 DelayQueue 元素的个数。

另外补充一点,kafka 中每一种延时场景都会创建单独的时间轮,一个时间轮里只存放一种类型的延时任务,因为不同 Task 在超期 / 完成的时候需要执行的逻辑是不一样的,
需要一一对应去执行。举个栗子,心跳延时场景有自己的 heartbeatPurgatory,生产延时有自己的 delayedProducePurgatory,以此类推。


 

四、延时案例分析 —— 消费者心跳的维护

1、HEARTBEAT 请求的处理
从源码中我们可以知道,心跳的维护和会话的超时,kafka 的实现非常巧妙。
通常情况下,心跳 3s 发一次,session 超时时间是 10s;
kafka 就在收到 HEARTBEAT 请求之后,就先创建一个 DelayedHeartbeat 延时任务,超时时间就是对应的 session.timeout 值即 10s;
如果在 10s 内又收到了对应 consumer 的 HEARTBEAT 请求,就将上次提交的延时任务完成;
如果在 10s 内没有收到对应 consumer 的 HEARTBEAT 请求,则任务 consumer 出问题了,就去执行对应的超期逻辑。

private def completeAndScheduleNextHeartbeatExpiration(group: GroupMetadata, member: MemberMetadata) {
  // complete current heartbeat expectation
  member.latestHeartbeat = time.milliseconds()
  val memberKey = MemberKey(member.groupId, member.memberId)
  // 完成上次的延时任务
  heartbeatPurgatory.checkAndComplete(memberKey)

  // reschedule the next heartbeat expiration deadline
  // 服务端能拿到这个session.timeout,然后根据这个时间生成一个延时任务,
  // 例如30s,如果这么长时间么有收到心跳请求,则认为消费者出了问题,就踢掉以后执行rebalance。
  val newHeartbeatDeadline = member.latestHeartbeat + member.sessionTimeoutMs
  val delayedHeartbeat = new DelayedHeartbeat(this, group, member, newHeartbeatDeadline, member.sessionTimeoutMs)
  heartbeatPurgatory.tryCompleteElseWatch(delayedHeartbeat, Seq(memberKey))
}
private[group] class DelayedHeartbeat(coordinator: GroupCoordinator,
                                      group: GroupMetadata,
                                      member: MemberMetadata,
                                      heartbeatDeadline: Long,
                                      sessionTimeout: Long)
  extends DelayedOperation(sessionTimeout, Some(group.lock)) {

  override def tryComplete(): Boolean = coordinator.tryCompleteHeartbeat(group, member, heartbeatDeadline, forceComplete _)
  override def onExpiration() = coordinator.onExpireHeartbeat(group, member, heartbeatDeadline)
  override def onComplete() = coordinator.onCompleteHeartbeat()
}

 

2、DelayedHeartbeat 任务超期后的逻辑
这一块也很简单,就是去执行 coordinator.onExpireHeartbeat 函数,
具体逻辑就是打印一个标识日志:Member  xxx has failed,这个日志为什么要单独讲呢?因为这个是我们排查消费者问题的时候的核心日志;
我们在看 server.log 的时候,如果查到某个消费组的消费者出现这个日志,那么我们就能肯定这个消费组的这个消费者是因为会话超时的原因被剔除了;
从而我们就可以继续往下分析这个消费者掉线的原因是因为消费者进程挂了?或者是客户端机器负载太高而心跳线程是守护线程优先级比较低拿不到 CPU 资源?
等等一系列定位线索。这个日志主要是用于定位消费者出问题,以及消费组 rebalance 原因的,是非常重要的一个标识日志!

讲完日志,我们就可以看到后续就是去开启 rebalance,因为消费者个数变了,需要重新去进行分区分配,已经故障转移。

def onExpireHeartbeat(group: GroupMetadata, member: MemberMetadata, heartbeatDeadline: Long) {
  group.inLock {
    if (!shouldKeepMemberAlive(member, heartbeatDeadline)) {
      // 标识日志
      info(s"Member ${member.memberId} in group ${group.groupId} has failed, removing it from the group")
      removeMemberAndUpdateGroup(group, member)
    }
  }
}
private def removeMemberAndUpdateGroup(group: GroupMetadata, member: MemberMetadata) {
  group.remove(member.memberId)
  group.currentState match {
    case Dead | Empty =>
    case Stable | CompletingRebalance => maybePrepareRebalance(group)
    case PreparingRebalance => joinPurgatory.checkAndComplete(GroupKey(group.groupId))
  }
}