This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

View File

@@ -0,0 +1,406 @@
<audio id="audio" title="19 | TimingWheel探究Kafka定时器背后的高效时间轮算法" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2b/03/2b009cd5a52215c17768554f58c8af03.mp3"></audio>
你好我是胡夕。今天我们开始学习Kafka延时请求的代码实现。
延时请求Delayed Operation也称延迟请求是指因未满足条件而暂时无法被处理的Kafka请求。举个例子配置了acks=all的生产者发送的请求可能一时无法完成因为Kafka必须确保ISR中的所有副本都要成功响应这次写入。因此通常情况下这些请求没法被立即处理。只有满足了条件或发生了超时Kafka才会把该请求标记为完成状态。这就是所谓的延时请求。
今天,我们的重点是弄明白请求被延时处理的机制——分层时间轮算法。
时间轮的应用范围非常广。很多操作系统的定时任务调度如Crontab以及通信框架如Netty等都利用了时间轮的思想。几乎所有的时间任务调度系统都是基于时间轮算法的。Kafka应用基于时间轮算法管理延迟请求的代码简洁精炼而且和业务逻辑代码完全解耦你可以从0到1地照搬到你自己的项目工程中。
## 时间轮简介
在开始介绍时间轮之前我想先请你思考这样一个问题“如果是你你会怎么实现Kafka中的延时请求呢
针对这个问题我的第一反应是使用Java的DelayQueue。毕竟这个类是Java天然提供的延时队列非常适合建模延时对象处理。实际上Kafka的第一版延时请求就是使用DelayQueue做的。
但是DelayQueue有一个弊端它插入和删除队列元素的时间复杂度是O(logN)。对于Kafka这种非常容易积攒几十万个延时请求的场景来说该数据结构的性能是瓶颈。当然这一版的设计还有其他弊端比如它在清除已过期的延迟请求方面不够高效可能会出现内存溢出的情形。后来社区改造了延时请求的实现机制采用了基于时间轮的方案。
时间轮有简单时间轮Simple Timing Wheel和分层时间轮Hierarchical Timing Wheel两类。两者各有利弊也都有各自的使用场景。Kafka采用的是分层时间轮这是我们重点学习的内容。
关于分层时间轮有很多严谨的科学论文。不过大多数的论文读起来晦涩难懂而且偏理论研究。然而我们并非是要完整系统地学习这套机制我们关心的是如何将其应用于实践当中。要做到这一点结合着源码来学习就是一个不错的途径。你需要关注在代码层面Kafka是如何实现多层时间轮的。
“时间轮”的概念稍微有点抽象,我用一个生活中的例子,来帮助你建立一些初始印象。
想想我们生活中的手表。手表由时针、分针和秒针组成,它们各自有独立的刻度,但又彼此相关:秒针转动一圈,分针会向前推进一格;分针转动一圈,时针会向前推进一格。这就是典型的分层时间轮。
和手表不太一样的是Kafka自己有专门的术语。在Kafka中手表中的“一格”叫“一个桶Bucket而“推进”对应于Kafka中的“滴答”也就是tick。后面你在阅读源码的时候会频繁地看到Bucket、tick字眼你可以把它们理解成手表刻度盘面上的“一格”和“向前推进”的意思。
除此之外每个Bucket下也不是白板一块它实际上是一个双向循环链表Doubly Linked Cyclic List里面保存了一组延时请求。
我先用一张图帮你理解下双向循环链表。
<img src="https://static001.geekbang.org/resource/image/fd/ed/fdcdc45c1c6adc87192e6101be7793ed.png" alt="">
图中的每个节点都有一个next和prev指针分别指向下一个元素和上一个元素。Root是链表的头部节点不包含任何实际数据。它的next指针指向链表的第一个元素而prev指针指向最后一个元素。
由于是双向链表结构因此代码能够利用next和prev两个指针快速地定位元素因此在Bucket下插入和删除一个元素的时间复杂度是O(1)。当然,双向链表要求同时保存两个指针数据,在节省时间的同时消耗了更多的空间。在算法领域,这是典型的用空间去换时间的优化思想。
## 源码层级关系
在Kafka中具体是怎么应用分层时间轮实现请求队列的呢
<img src="https://static001.geekbang.org/resource/image/0d/f6/0d6eddb652a975f10563b594c77fd1f6.png" alt="">
图中的时间轮共有两个层级分别是Level 0和Level 1。每个时间轮有8个Bucket每个Bucket下是一个双向循环链表用来保存延迟请求。
在Kafka源码中时间轮对应utils.timer包下的TimingWheel类每个Bucket下的链表对应TimerTaskList类链表元素对应TimerTaskEntry类而每个链表元素里面保存的延时任务对应TimerTask。
在这些类中TimerTaskEntry与TimerTask是1对1的关系TimerTaskList下包含多个TimerTaskEntryTimingWheel包含多个TimerTaskList。
我画了一张UML图帮助你理解这些类之间的对应关系
<img src="https://static001.geekbang.org/resource/image/2b/17/2b127feffa2475ca14b0c3ae5ca47817.png" alt="">
## 时间轮各个类源码定义
掌握了这些基础知识,下面我就结合这些源码,来解释下延迟请求是如何被这套分层时间轮管理的。根据调用关系,我采用自底向上的方法给出它们的定义。
### TimerTask类
首先是TimerTask类。该类位于utils.timer包下的TimerTask.scala文件中。它的代码只有几十行非常容易理解。
```
trait TimerTask extends Runnable {
val delayMs: Long // 通常是request.timeout.ms参数值
// 每个TimerTask实例关联一个TimerTaskEntry
// 就是说每个定时任务需要知道它在哪个Bucket链表下的哪个链表元素上
private[this] var timerTaskEntry: TimerTaskEntry = null
// 取消定时任务原理就是将关联的timerTaskEntry置空
def cancel(): Unit = {
synchronized {
if (timerTaskEntry != null) timerTaskEntry.remove()
timerTaskEntry = null
}
}
// 关联timerTaskEntry原理是给timerTaskEntry字段赋值
private[timer] def setTimerTaskEntry(entry: TimerTaskEntry)
: Unit = {
synchronized {
if (timerTaskEntry != null &amp;&amp; timerTaskEntry != entry)
timerTaskEntry.remove()
timerTaskEntry = entry
}
}
// 获取关联的timerTaskEntry实例
private[timer] def getTimerTaskEntry(): TimerTaskEntry = {
timerTaskEntry
}
}
```
从代码可知TimerTask是一个Scala接口Trait。每个TimerTask都有一个delayMs字段表示这个定时任务的超时时间。通常来说这就是客户端参数request.timeout.ms的值。这个类还绑定了一个timerTaskEntry字段因为每个定时任务都要知道它存放在哪个Bucket链表下的哪个链表元素上。
既然绑定了这个字段就要提供相应的Setter和Getter方法。Getter方法仅仅是返回这个字段而已Setter方法要稍微复杂一些。在给timerTaskEntry赋值之前它必须要先考虑这个定时任务是否已经绑定了其他的timerTaskEntry如果是的话就必须先取消绑定。另外Setter的整个方法体必须由monitor锁保护起来以保证线程安全性。
这个类还有个cancel方法用于取消定时任务。原理也很简单就是将关联的timerTaskEntry置空。也就是说把定时任务从链表上摘除。
总之TimerTask建模的是Kafka中的定时任务。接下来我们来看TimerTaskEntry是如何承载这个定时任务的以及如何在链表中实现双向关联。
### TimerTaskEntry类
如前所述TimerTaskEntry表征的是Bucket链表下的一个元素。它的主要代码如下
```
private[timer] class TimerTaskEntry(val timerTask: TimerTask, val expirationMs: Long) extends Ordered[TimerTaskEntry] {
@volatile
var list: TimerTaskList = null // 绑定的Bucket链表实例
var next: TimerTaskEntry = null // next指针
var prev: TimerTaskEntry = null // prev指针
// 关联给定的定时任务
if (timerTask != null) timerTask.setTimerTaskEntry(this)
// 关联定时任务是否已经被取消了
def cancelled: Boolean = {
timerTask.getTimerTaskEntry != this
}
// 从Bucket链表中移除自己
def remove(): Unit = {
var currentList = list
while (currentList != null) {
currentList.remove(this)
currentList = list
}
}
......
}
```
该类定义了TimerTask类字段用来指定定时任务同时还封装了一个过期时间戳字段这个字段值定义了定时任务的过期时间。
举个例子假设有个PRODUCE请求在当前时间1点钟被发送到Broker超时时间是30秒那么该请求必须在1点30秒之前完成否则将被视为超时。这里的1点30秒就是expirationMs值。
除了TimerTask类字段该类还定义了3个字段list、next和prev。它们分别对应于Bucket链表实例以及自身的next、prev指针。注意list字段是volatile型的这是因为Kafka的延时请求可能会被其他线程从一个链表搬移到另一个链表中因此**为了保证必要的内存可见性**代码声明list为volatile。
该类的方法代码都很直观你可以看下我写的代码注释。这里我重点解释一下remove方法的实现原理。
remove的逻辑是将TimerTask自身从双向链表中移除掉因此代码调用了TimerTaskList的remove方法来做这件事。那这里就有一个问题“怎么算真正移除掉呢”其实这是根据“TimerTaskEntry的list是否为空”来判断的。一旦置空了该字段那么这个TimerTaskEntry实例就变成了“孤儿”不再属于任何一个链表了。从这个角度来看置空就相当于移除的效果。
需要注意的是置空这个动作是在TimerTaskList的remove中完成的而这个方法可能会被其他线程同时调用因此上段代码使用了while循环的方式来确保TimerTaskEntry的list字段确实被置空了。这样Kafka才能安全地认为此链表元素被成功移除。
### TimerTaskList类
说完了TimerTask和TimerTaskEntry就轮到链表类TimerTaskList上场了。我们先看它的定义
```
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)
......
}
```
TimerTaskList实现了刚刚那张图所展示的双向循环链表。它定义了一个Root节点同时还定义了两个字段
- taskCounter用于标识当前这个链表中的总定时任务数
- expiration表示这个链表所在Bucket的过期时间戳。
就像我前面说的每个Bucket对应于手表表盘上的一格。它有起始时间和结束时间因而也就有时间间隔的概念即“结束时间-起始时间=时间间隔”。同一层的Bucket的时间间隔都是一样的。只有当前时间越过了Bucket的起始时间这个Bucket才算是过期。而这里的起始时间就是代码中expiration字段的值。
除了定义的字段之外TimerTaskList类还定义一些重要的方法比如expiration的Getter和Setter方法、add、remove和flush方法。
我们先看expiration的Getter和Setter方法。
```
// Setter方法
def setExpiration(expirationMs: Long): Boolean = {
expiration.getAndSet(expirationMs) != expirationMs
}
// Getter方法
def getExpiration(): Long = {
expiration.get()
}
```
我重点解释下Setter方法。代码使用了AtomicLong的CAS方法getAndSet原子性地设置了过期时间戳之后将新过期时间戳和旧值进行比较看看是否不同然后返回结果。
这里为什么要比较新旧值是否不同呢这是因为目前Kafka使用一个DelayQueue统一管理所有的Bucket也就是TimerTaskList对象。随着时钟不断向前推进原有Bucket会不断地过期然后失效。当这些Bucket失效后源码会重用这些Bucket。重用的方式就是重新设置Bucket的过期时间并把它们加回到DelayQueue中。这里进行比较的目的就是用来判断这个Bucket是否要被插入到DelayQueue。
此外TimerTaskList类还提供了add和remove方法分别实现将给定定时任务插入到链表、从链表中移除定时任务的逻辑。这两个方法的主体代码基本上就是我们在数据结构课上学过的链表元素插入和删除操作所以这里我就不具体展开讲了。你可以将这些代码和数据结构书中的代码比对下看看它们是不是长得很像。
```
// add方法
def add(timerTaskEntry: TimerTaskEntry): Unit = {
var done = false
while (!done) {
// 在添加之前尝试移除该定时任务,保证该任务没有在其他链表中
timerTaskEntry.remove()
synchronized {
timerTaskEntry.synchronized {
if (timerTaskEntry.list == null) {
val tail = root.prev
timerTaskEntry.next = root
timerTaskEntry.prev = tail
timerTaskEntry.list = this
// 把timerTaskEntry添加到链表末尾
tail.next = timerTaskEntry
root.prev = timerTaskEntry
taskCounter.incrementAndGet()
done = true
}
}
}
}
}
// remove方法
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()
}
}
}
}
```
最后我们看看flush方法。它的代码如下
```
def flush(f: (TimerTaskEntry)=&gt;Unit): Unit = {
synchronized {
// 找到链表第一个元素
var head = root.next
// 开始遍历链表
while (head ne root) {
// 移除遍历到的链表元素
remove(head)
// 执行传入参数f的逻辑
f(head)
head = root.next
}
// 清空过期时间设置
expiration.set(-1L)
}
}
```
基本上flush方法是清空链表中的所有元素并对每个元素执行指定的逻辑。该方法用于将高层次时间轮Bucket上的定时任务重新插入回低层次的Bucket中。具体为什么要这么做下节课我会给出答案现在你只需要知道它的大致作用就可以了。
### TimingWheel类
最后我们再来看下TimingWheel类的代码。先看定义
```
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) { _ =&gt; new TimerTaskList(taskCounter) }
private[this] var currentTime = startMs - (startMs % tickMs)
@volatile private[this] var overflowWheel: TimingWheel = null
......
}
```
每个TimingWheel对象都定义了9个字段。这9个字段都非常重要每个字段都是分层时间轮的重要属性。因此我来逐一介绍下。
- tickMs滴答一次的时长类似于手表的例子中向前推进一格的时间。对于秒针而言tickMs就是1秒。同理分针是1分时针是1小时。在Kafka中第1层时间轮的tickMs被固定为1毫秒也就是说向前推进一格Bucket的时长是1毫秒。
- wheelSize每一层时间轮上的Bucket数量。第1层的Bucket数量是20。
- startMs时间轮对象被创建时的起始时间戳。
- taskCounter这一层时间轮上的总定时任务数。
- queue将所有Bucket按照过期时间排序的延迟队列。随着时间不断向前推进Kafka需要依靠这个队列获取那些已过期的Bucket并清除它们。
- interval这层时间轮总时长等于滴答时长乘以wheelSize。以第1层为例interval就是20毫秒。由于下一层时间轮的滴答时长就是上一层的总时长因此第2层的滴答时长就是20毫秒总时长是400毫秒以此类推。
- buckets时间轮下的所有Bucket对象也就是所有TimerTaskList对象。
- currentTime当前时间戳只是源码对它进行了一些微调整将它设置成小于当前时间的最大滴答时长的整数倍。举个例子假设滴答时长是20毫秒当前时间戳是123毫秒那么currentTime会被调整为120毫秒。
- overflowWheelKafka是按需创建上层时间轮的。这也就是说当有新的定时任务到达时会尝试将其放入第1层时间轮。如果第1层的interval无法容纳定时任务的超时时间就现场创建并配置好第2层时间轮并再次尝试放入如果依然无法容纳那么就再创建和配置第3层时间轮以此类推直到找到适合容纳该定时任务的第N层时间轮。
由于每层时间轮的长度都是倍增的,因此,代码并不需要创建太多层的时间轮,就足以容纳绝大部分的延时请求了。
举个例子目前Clients端默认的请求超时时间是30秒按照现在代码中的wheelSize=20进行倍增只需要4层时间轮就能容纳160秒以内的所有延时请求了。
说完了类声明我们再来学习下TimingWheel中定义的3个方法addOverflowWheel、add和advanceClock。就像我前面说的TimingWheel类字段overflowWheel的创建是按需的。每当需要一个新的上层时间轮时代码就会调用addOverflowWheel方法。我们看下它的代码
```
private[this] def addOverflowWheel(): Unit = {
synchronized {
// 只有之前没有创建上层时间轮方法才会继续
if (overflowWheel == null) {
// 创建新的TimingWheel实例
// 滴答时长tickMs等于下层时间轮总时长
// 每层的轮子数都是相同的
overflowWheel = new TimingWheel(
tickMs = interval,
wheelSize = wheelSize,
startMs = currentTime,
taskCounter = taskCounter,
queue
)
}
}
}
```
这个方法就是创建一个新的TimingWheel实例也就是创建上层时间轮。所用的滴答时长等于下层时间轮总时长而每层的轮子数都是相同的。创建完成之后代码将新创建的实例赋值给overflowWheel字段。至此方法结束。
下面我们再来学习下add和advanceClock方法。首先是add方法代码及其注释如下
```
def add(timerTaskEntry: TimerTaskEntry): Boolean = {
// 获取定时任务的过期时间戳
val expiration = timerTaskEntry.expirationMs
// 如果该任务已然被取消了,则无需添加,直接返回
if (timerTaskEntry.cancelled) {
false
// 如果该任务超时时间已过期
} else if (expiration &lt; currentTime + tickMs) {
false
// 如果该任务超时时间在本层时间轮覆盖时间范围内
} else if (expiration &lt; currentTime + interval) {
val virtualId = expiration / tickMs
// 计算要被放入到哪个Bucket中
val bucket = buckets((virtualId % wheelSize.toLong).toInt)
// 添加到Bucket中
bucket.add(timerTaskEntry)
// 设置Bucket过期时间
// 如果该时间变更过说明Bucket是新建或被重用将其加回到DelayQueue
if (bucket.setExpiration(virtualId * tickMs)) {
queue.offer(bucket)
}
true
// 本层时间轮无法容纳该任务,交由上层时间轮处理
} else {
// 按需创建上层时间轮
if (overflowWheel == null) addOverflowWheel()
// 加入到上层时间轮中
overflowWheel.add(timerTaskEntry)
}
}
```
我结合一张图来解释下这个add方法要做的事情
<img src="https://static001.geekbang.org/resource/image/a3/3e/a3f8774eeeb06d0d0394b69f4b106b3e.jpg" alt="">
方法的**第1步**是获取定时任务的过期时间戳。所谓过期时间戳,就是这个定时任务过期时的时点。
**第2步**是看定时任务是否已被取消。如果已经被取消,则无需加入到时间轮中。如果没有被取消,就接着看这个定时任务是否已经过期。如果过期了,自然也不用加入到时间轮中。如果没有过期,就看这个定时任务的过期时间是否能够被涵盖在本层时间轮的时间范围内。如果可以,则进入到下一步。
**第3步**首先计算目标Bucket序号也就是这个定时任务需要被保存在哪个TimerTaskList中。我举个实际的例子来说明一下如何计算目标Bucket。
前面说过了第1层的时间轮有20个Bucket每个滴答时长是1毫秒。那么第2层时间轮的滴答时长应该就是20毫秒总时长是400毫秒。第2层第1个Bucket的时间范围应该是[2040)第2个Bucket的时间范围是[4060依次类推。假设现在有个延时请求的超时时间戳是237那么它就应该被插入到第11个Bucket中。
在确定了目标Bucket序号之后代码会将该定时任务添加到这个Bucket下同时更新这个Bucket的过期时间戳。在刚刚的那个例子中第11号Bucket的起始时间就应该是小于237的最大的20的倍数即220。
**第4步**如果这个Bucket是首次插入定时任务那么还同时要将这个Bucket加入到DelayQueue中方便Kafka轻松地获取那些已过期Bucket并删除它们。如果定时任务的过期时间无法被涵盖在本层时间轮中那么就按需创建上一层时间戳然后在上一层时间轮上完整地执行刚刚所说的所有逻辑。
说完了add方法我们看下advanceClock方法。顾名思义它就是向前驱动时钟的方法。代码如下
```
def advanceClock(timeMs: Long): Unit = {
// 向前驱动到的时点要超过Bucket的时间范围才是有意义的推进否则什么都不做
// 更新当前时间currentTime到下一个Bucket的起始时点
if (timeMs &gt;= currentTime + tickMs) {
currentTime = timeMs - (timeMs % tickMs)
// 同时尝试为上一层时间轮做向前推进动作
if (overflowWheel != null) overflowWheel.advanceClock(currentTime)
}
}
```
参数timeMs表示要把时钟向前推动到这个时点。向前驱动到的时点必须要超过Bucket的时间范围才是有意义的推进否则什么都不做毕竟它还在Bucket时间范围内。
相反一旦超过了Bucket覆盖的时间范围代码就会更新当前时间currentTime到下一个Bucket的起始时点同时递归地为上一层时间轮做向前推进动作。推进时钟的动作是由Kafka后台专属的Reaper线程发起的。
今天我反复提到了删除过期Bucket这个操作是由这个Reaper线程执行的。下节课我们会提到这个Reaper线程。
## 总结
今天我简要介绍了时间轮机制并结合代码重点讲解了分层时间轮在Kafka中的代码实现。Kafka正是利用这套分层时间轮机制实现了对于延迟请求的处理。在源码层级上Kafka定义了4个类来构建整套分层时间轮体系。
- TimerTask类建模Kafka延时请求。它是一个Runnable类Kafka使用一个单独线程异步添加延时请求到时间轮。
- TimerTaskEntry类建模时间轮Bucket下延时请求链表的元素类型封装了TimerTask对象和定时任务的过期时间戳信息。
- TimerTaskList类建模时间轮Bucket下的延时请求双向循环链表提供O(1)时间复杂度的请求插入和删除。
- TimingWheel类建模时间轮类型统一管理下辖的所有Bucket以及定时任务。
<img src="https://static001.geekbang.org/resource/image/ae/8d/ae956dfc9f494be6c50440c347f5fc8d.jpg" alt="">
在下一讲中我们将继续学习Kafka延时请求以及管理它们的DelayedOperation家族的源码。只有了解了DelayedOperation及其具体实现子类的代码我们才能完整地了解当请求不能被及时处理时Kafka是如何应对的。
在分布式系统中如何优雅而高效地延迟处理任务是摆在设计者面前的难题之一。我建议你好好学习下这套实现机制在Kafka中的应用代码活学活用将其彻底私有化加入到你的工具箱中。
## 课后讨论
TimingWheel类中的overflowWheel变量为什么是volatile型的
欢迎你在留言区畅所欲言,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,464 @@
<audio id="audio" title="20 | DelayedOperationBroker是怎么延时处理请求的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/56/88/56df95a03706fa0f29adbc928256a888.mp3"></audio>
你好,我是胡夕。
上节课我们学习了分层时间轮在Kafka中的实现。既然是分层时间轮那就说明源码中构造的时间轮是有多个层次的。每一层所表示的总时长等于该层Bucket数乘以每个Bucket涵盖的时间范围。另外该总时长自动成为下一层单个Bucket所覆盖的时间范围。
举个例子目前Kafka第1层的时间轮固定时长是20毫秒interval即有20个BucketwheelSize每个Bucket涵盖1毫秒tickMs的时间范围。第2层的总时长是400毫秒同样有20个Bucket每个Bucket 20毫秒。依次类推那么第3层的时间轮时长就是8秒因为这一层单个Bucket的时长是400毫秒共有20个Bucket。
基于这种设计每个延迟请求需要根据自己的超时时间来决定它要被保存于哪一层时间轮上。我们假设在t=0时创建了第1层的时间轮那么该层第1个Bucket保存的延迟请求就是介于[01之间第2个Bucket保存的是介于[12)之间的请求。现在如果有两个延迟请求超时时刻分别在18.5毫秒和123毫秒那么第1个请求就应该被保存在第1层的第19个Bucket序号从1开始而第2个请求则应该被保存在第2层时间轮的第6个Bucket中。
这基本上就是Kafka中分层时间轮的实现原理。Kafka不断向前推动各个层级的时间轮的时钟按照时间轮的滴答时长陆续接触到Bucket下的各个延迟任务从而实现了对请求的延迟处理。
但是如果你仔细查看的话就会发现到目前为止这套分层时间轮代码和Kafka概念并无直接的关联比如分层时间轮里并不涉及主题、分区、副本这样的概念也没有和Controller、副本管理器等Kafka组件进行直接交互。但实际上延迟处理请求是Kafka的重要功能之一。你可能会问到底是Kafka的哪部分源码负责创建和维护这套分层时间轮并将它集成到整体框架中去的呢答案就是接下来要介绍的两个类Timer和SystemTimer。
## Timer接口及SystemTimer
这两个类的源码位于utils.timer包下的Timer.scala文件。其中**Timer接口定义了管理延迟操作的方法而SystemTimer是实现延迟操作的关键代码**。后续在学习延迟请求类DelayedOperation时我们就会发现调用分层时间轮上的各类操作都是通过SystemTimer类完成的。
### Timer接口
接下来我们就看下它们的源码。首先是Time接口类代码如下
```
trait Timer {
// 将给定的定时任务插入到时间轮上,等待后续延迟执行
def add(timerTask: TimerTask): Unit
// 向前推进时钟,执行已达过期时间的延迟任务
def advanceClock(timeoutMs: Long): Boolean
// 获取时间轮上总的定时任务数
def size: Int
// 关闭定时器
def shutdown(): Unit
}
```
该Timer接口定义了4个方法。
- add方法将给定的定时任务插入到时间轮上等待后续延迟执行。
- advanceClock方法向前推进时钟执行已达过期时间的延迟任务。
- size方法获取当前总定时任务数。
- shutdown方法关闭该定时器。
其中,最重要的两个方法是**add**和**advanceClock**,它们是**完成延迟请求处理的关键步骤**。接下来我们结合Timer实现类SystemTimer的源码重点分析这两个方法。
### SystemTimer类
SystemTimer类是Timer接口的实现类。它是一个定时器类封装了分层时间轮对象为Purgatory提供延迟请求管理功能。所谓的Purgatory就是保存延迟请求的缓冲区。也就是说它保存的是因为不满足条件而无法完成但是又没有超时的请求。
下面我们从定义和方法两个维度来学习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) =&gt; KafkaThread.nonDaemon(&quot;executor-&quot; + executorName, runnable))
// 延迟队列保存所有Bucket即所有TimerTaskList对象
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()
......
}
```
每个SystemTimer类定义了4个原生字段分别是executorName、tickMs、wheelSize和startMs。
tickMs和wheelSize是构建分层时间轮的基础你一定要重点掌握。不过上节课我已经讲过了而且我在开篇还用具体数字带你回顾了它们的用途这里就不重复了。另外两个参数不太重要你只需要知道它们的含义就行了。
- executorNamePurgatory的名字。Kafka中存在不同的Purgatory比如专门处理生产者延迟请求的Produce缓冲区、处理消费者延迟请求的Fetch缓冲区等。这里的Produce和Fetch就是executorName。
- startMs该SystemTimer定时器启动时间单位是毫秒。
除了原生字段SystemTimer类还定义了其他一些字段属性。我介绍3个比较重要的。这3个字段与时间轮都是强相关的。
1. **delayQueue字段**。它保存了该定时器下管理的所有Bucket对象。因为是DelayQueue所以只有在Bucket过期后才能从该队列中获取到。SystemTimer类的advanceClock方法正是依靠了这个特性向前驱动时钟。关于这一点一会儿我们详细说。
1. **timingWheel**。TimingWheel是实现分层时间轮的类。SystemTimer类依靠它来操作分层时间轮。
1. **taskExecutor**。它是单线程的线程池,用于异步执行提交的定时任务逻辑。
#### 方法
说完了类定义与字段我们看下SystemTimer类的方法。
该类总共定义了6个方法add、addTimerTaskEntry、reinsert、advanceClock、size和shutdown。
其中size方法计算的是给定Purgatory下的总延迟请求数shutdown方法则是关闭前面说到的线程池而addTimerTaskEntry方法则是将给定的TimerTaskEntry插入到时间轮中。如果该TimerTaskEntry表征的定时任务没有过期或被取消方法还会将已经过期的定时任务提交给线程池等待异步执行该定时任务。至于reinsert方法它会调用addTimerTaskEntry重新将定时任务插入回时间轮。
其实SystemTimer类最重要的方法是add和advanceClock方法因为**它们是真正对外提供服务的**。我们先说add方法。add方法的作用是将给定的定时任务插入到时间轮中进行管理。代码如下
```
def add(timerTask: TimerTask): Unit = {
// 获取读锁。在没有线程持有写锁的前提下,
// 多个线程能够同时向时间轮添加定时任务
readLock.lock()
try {
// 调用addTimerTaskEntry执行插入逻辑
addTimerTaskEntry(new TimerTaskEntry(timerTask, timerTask.delayMs + Time.SYSTEM.hiResClockMs))
} finally {
// 释放读锁
readLock.unlock()
}
}
```
add方法就是调用addTimerTaskEntry方法执行插入动作。以下是addTimerTaskEntry的方法代码
```
private def addTimerTaskEntry(timerTaskEntry: TimerTaskEntry): Unit = {
// 视timerTaskEntry状态决定执行什么逻辑
// 1. 未过期未取消:添加到时间轮
// 2. 已取消:什么都不做
// 3. 已过期:提交到线程池,等待执行
if (!timingWheel.add(timerTaskEntry)) {
// 定时任务未取消,说明定时任务已过期
// 否则timingWheel.add方法应该返回True
if (!timerTaskEntry.cancelled)
taskExecutor.submit(timerTaskEntry.timerTask)
}
}
```
TimingWheel的add方法会在定时任务已取消或已过期时返回False否则该方法会将定时任务添加到时间轮然后返回True。因此addTimerTaskEntry方法到底执行什么逻辑取决于给定定时任务的状态
1. 如果该任务既未取消也未过期那么addTimerTaskEntry方法将其添加到时间轮
1. 如果该任务已取消,则该方法什么都不做,直接返回;
1. 如果该任务已经过期,则提交到相应的线程池,等待后续执行。
另一个关键方法是advanceClock方法。顾名思义它的作用是**驱动时钟向前推进**。我们看下代码:
```
def advanceClock(timeoutMs: Long): Boolean = {
// 获取delayQueue中下一个已过期的Bucket
var bucket = delayQueue.poll(
timeoutMs, TimeUnit.MILLISECONDS)
if (bucket != null) {
// 获取写锁
// 一旦有线程持有写锁其他任何线程执行add或advanceClock方法时会阻塞
writeLock.lock()
try {
while (bucket != null) {
// 推动时间轮向前&quot;滚动&quot;到Bucket的过期时间点
timingWheel.advanceClock(bucket.getExpiration())
// 将该Bucket下的所有定时任务重写回到时间轮
bucket.flush(reinsert)
// 读取下一个Bucket对象
bucket = delayQueue.poll()
}
} finally {
// 释放写锁
writeLock.unlock()
}
true
} else {
false
}
}
```
由于代码逻辑比较复杂,我再画一张图来展示一下:
<img src="https://static001.geekbang.org/resource/image/31/89/310c9160f701082ceb90984a7dcfe089.jpg" alt="">
advanceClock方法要做的事情就是遍历delayQueue中的所有Bucket并将时间轮的时钟依次推进到它们的过期时间点令它们过期。然后再将这些Bucket下的所有定时任务全部重新插入回时间轮。
我用一张图来说明这个重新插入过程。
<img src="https://static001.geekbang.org/resource/image/53/ef/535e18ad9516c90ff58baae8cfc9b9ef.png" alt="">
从这张图中我们可以看到在T0时刻任务①存放在Level 0的时间轮上而任务②和③存放在Level 1的时间轮上。此时时钟推进到Level 0的第0个Bucket上以及Level 1的第0个Bucket上。
当时间来到T19时刻时钟也被推进到Level 0的第19个Bucket任务①会被执行。但是由于一层时间轮是20个Bucket因此T19时刻Level 0的时间轮尚未完整走完一圈此时Level 1的时间轮状态没有发生任何变化。
当T20时刻到达时Level 0的时间轮已经执行完成Level 1的时间轮执行了一次滴答向前推进一格。此时Kafka需要将任务②和③插入到Level 0的时间轮上位置是第20个和第21个Bucket。这个将高层时间轮上的任务插入到低层时间轮的过程是由advanceClock中的reinsert方法完成。
至于为什么要重新插入回低层次的时间轮,其实是因为,随着时钟的推进,当前时间逐渐逼近任务②和③的超时时间点。它们之间差值的缩小,足以让它们被放入到下一层的时间轮中。
总的来说SystemTimer类实现了Timer接口的方法**它封装了底层的分层时间轮,为上层调用方提供了便捷的方法来操作时间轮**。那么它的上层调用方是谁呢答案就是DelayedOperationPurgatory类。这就是我们建模Purgatory的地方。
不过在了解DelayedOperationPurgatory之前我们要先学习另一个重要的类DelayedOperation。前者是一个泛型类它的类型参数恰恰就是DelayedOperation。因此我们不可能在不了解DelayedOperation的情况下很好地掌握DelayedOperationPurgatory。
## DelayedOperation类
这个类位于server包下的DelayedOperation.scala文件中。它是所有Kafka延迟请求类的抽象父类。我们依然从定义和方法这两个维度去剖析它。
### 定义
首先来看定义。代码如下:
```
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)
......
}
```
DelayedOperation类是一个抽象类它的构造函数中只需要传入一个超时时间即可。这个超时时间通常是**客户端发出请求的超时时间**,也就是客户端参数**request.timeout.ms**的值。这个类实现了上节课学到的TimerTask接口因此作为一个建模延迟操作的类它自动继承了TimerTask接口的cancel方法支持延迟操作的取消以及TimerTaskEntry的Getter和Setter方法支持将延迟操作绑定到时间轮相应Bucket下的某个链表元素上。
除此之外DelayedOperation类额外定义了两个字段**completed**和**tryCompletePending**。
前者理解起来比较容易,它就是**表征这个延迟操作是否完成的布尔变量**。我重点解释一下tryCompletePending的作用。
这个参数是在1.1版本引入的。在此之前只有completed参数。但是这样就可能存在这样一个问题当多个线程同时检查某个延迟操作是否满足完成条件时如果其中一个线程持有了锁也就是上面的lock字段然后执行条件检查会发现不满足完成条件。而与此同时另一个线程执行检查时却发现条件满足了但是这个线程又没有拿到锁此时该延迟操作将永远不会有再次被检查的机会会导致最终超时。
加入tryCompletePending字段目的就是**确保拿到锁的线程有机会再次检查条件是否已经满足**。具体是怎么实现的呢下面讲到maybeTryComplete方法时我会再带你进行深入的分析。
关于DelayedOperation类的定义你掌握到这个程度就可以了重点是学习这些字段是如何在方法中发挥作用的。
### 方法
DelayedOperation类有7个方法。我先介绍下它们的作用这样你在读源码时就可以心中有数。
- forceComplete强制完成延迟操作不管它是否满足完成条件。每当操作满足完成条件或已经过期了就需要调用该方法完成该操作。
- isCompleted检查延迟操作是否已经完成。源码使用这个方法来决定后续如何处理该操作。比如如果操作已经完成了那么通常需要取消该操作。
- onExpiration强制完成之后执行的过期逻辑回调方法。只有真正完成操作的那个线程才有资格调用这个方法。
- onComplete完成延迟操作所需的处理逻辑。这个方法只会在forceComplete方法中被调用。
- tryComplete尝试完成延迟操作的顶层方法内部会调用forceComplete方法。
- maybeTryComplete线程安全版本的tryComplete方法。这个方法其实是社区后来才加入的不过已经慢慢地取代了tryComplete现在外部代码调用的都是这个方法了。
- run调用延迟操作超时后的过期逻辑也就是组合调用forceComplete + onExpiration。
我们说过DelayedOperation是抽象类对于不同类型的延时请求onExpiration、onComplete和tryComplete的处理逻辑也各不相同因此需要子类来实现它们。
其他方法的代码大多短小精悍你一看就能明白我就不做过多解释了。我重点说下maybeTryComplete方法。毕竟这是社区为了规避因多线程访问产生锁争用导致线程阻塞从而引发请求超时问题而做的努力。先看方法代码
```
private[server] def maybeTryComplete(): Boolean = {
var retry = false // 是否需要重试
var done = false // 延迟操作是否已完成
do {
if (lock.tryLock()) { // 尝试获取锁对象
try {
tryCompletePending.set(false)
done = tryComplete()
} finally {
lock.unlock()
}
// 运行到这里的线程持有锁其他线程只能运行else分支的代码
// 如果其他线程将maybeTryComplete设置为true那么retry=true
// 这就相当于其他线程给了本线程重试的机会
retry = tryCompletePending.get()
} else {
// 运行到这里的线程没有拿到锁
// 设置tryCompletePending=true给持有锁的线程一个重试的机会
retry = !tryCompletePending.getAndSet(true)
}
} while (!isCompleted &amp;&amp; retry)
done
}
```
为了方便你理解,我画了一张流程图说明它的逻辑:
<img src="https://static001.geekbang.org/resource/image/35/a3/35bd69c5aa46d52a358976152508daa3.jpg" alt="">
从图中可以看出,这个方法可能会被多个线程同时访问,只是不同线程会走不同的代码分支,分叉点就在**尝试获取锁的if语句**。
如果拿到锁对象就依次执行清空tryCompletePending状态、完成延迟请求、释放锁以及读取最新retry状态的动作。未拿到锁的线程就只能设置tryCompletePending状态来间接影响retry值从而给获取到锁的线程一个重试的机会。这里的重试是通过do…while循环的方式实现的。
好了DelayedOperation类我们就说到这里。除了这些公共方法你最好结合一两个具体子类的方法实现体会下具体延迟请求类是如何实现tryComplete方法的。我推荐你从DelayedProduce类的**tryComplete方法**开始。
我们之前总说acks=all的PRODUCE请求很容易成为延迟请求因为它必须等待所有的ISR副本全部同步消息之后才能完成你可以顺着这个思路研究下DelayedProduce的tryComplete方法是如何实现的。
## DelayedOperationPurgatory类
接下来我们补上延迟请求模块的最后一块“拼图”DelayedOperationPurgatory类的源码分析。
该类是实现Purgatory的地方。从代码结构上看它是一个Scala伴生对象。也就是说源码文件同时定义了DelayedOperationPurgatory Object和Class。Object中仅仅定义了apply工厂方法和一个名为Shards的字段这个字段是DelayedOperationPurgatory监控列表的数组长度信息。因此我们还是重点学习DelayedOperationPurgatory Class的源码。
前面说过DelayedOperationPurgatory类是一个泛型类它的参数类型是DelayedOperation的具体子类。因此通常情况下每一类延迟请求都对应于一个DelayedOperationPurgatory实例。这些实例一般都保存在上层的管理器中。比如与消费者组相关的心跳请求、加入组请求的Purgatory实例就保存在GroupCoordinator组件中而与生产者相关的PRODUCE请求的Purgatory实例被保存在分区对象或副本状态机中。
### 定义
至于怎么学,还是老规矩,我们先从定义开始。代码如下:
```
final class DelayedOperationPurgatory[T &lt;: DelayedOperation](
purgatoryName: String,
timeoutTimer: Timer,
brokerId: Int = 0,
purgeInterval: Int = 1000,
reaperEnabled: Boolean = true,
timerEnabled: Boolean = true) extends Logging with KafkaMetricsGroup {
......
}
```
定义中有6个字段。其中很多字段都有默认参数比如最后两个参数分别表示是否启动删除线程以及是否启用分层时间轮。现在源码中所有类型的Purgatory实例都是默认启动的因此无需特别留意它们。
purgeInterval这个参数用于控制删除线程移除Bucket中的过期延迟请求的频率在绝大部分情况下都是1秒一次。当然对于生产者、消费者以及删除消息的AdminClient而言Kafka分别定义了专属的参数允许你调整这个频率。比如生产者参数producer.purgatory.purge.interval.requests就是做这个用的。
事实上,需要传入的参数一般只有两个:**purgatoryName**和**brokerId**它们分别表示这个Purgatory的名字和Broker的序号。
而timeoutTimer就是我们前面讲过的SystemTimer实例我就不重复解释了。
### Wathcers和WatcherList
DelayedOperationPurgatory还定义了两个内置类分别是Watchers和WatcherList。
**Watchers是基于Key的一个延迟请求的监控链表**。它的主体代码如下:
```
private class Watchers(val key: Any) {
private[this] val operations =
new ConcurrentLinkedQueue[T]()
// 其他方法......
}
```
每个Watchers实例都定义了一个延迟请求链表而这里的Key可以是任何类型比如表示消费者组的字符串类型、表示主题分区的TopicPartitionOperationKey类型。你不用穷尽这里所有的Key类型你只需要了解Watchers是一个通用的延迟请求链表就行了。Kafka利用它来**监控保存其中的延迟请求的可完成状态**。
既然Watchers主要的数据结构是链表那么它的所有方法本质上就是一个链表操作。比如tryCompleteWatched方法会遍历整个链表并尝试完成其中的延迟请求。再比如cancel方法也是遍历链表再取消掉里面的延迟请求。至于watch方法则是将延迟请求加入到链表中。
说完了Watchers我们看下WatcherList类。它非常短小精悍完整代码如下
```
private class WatcherList {
// 定义一组按照Key分组的Watchers对象
val watchersByKey = new Pool[Any, Watchers](Some((key: Any) =&gt; new Watchers(key)))
val watchersLock = new ReentrantLock()
// 返回所有Watchers对象
def allWatchers = {
watchersByKey.values
}
}
```
WatcherList最重要的字段是**watchersByKey**。它是一个PoolPool就是Kafka定义的池对象它本质上就是一个ConcurrentHashMap。watchersByKey的Key可以是任何类型而Value就是Key对应类型的一组Watchers对象。
说完了DelayedOperationPurgatory类的两个内部类Watchers和WatcherList我们可以开始学习该类的两个重要方法tryCompleteElseWatch和checkAndComplete方法。
前者的作用是**检查操作是否能够完成**如果不能的话就把它加入到对应Key所在的WatcherList中。以下是方法代码
```
def tryCompleteElseWatch(operation: T, watchKeys: Seq[Any]): Boolean = {
assert(watchKeys.nonEmpty, &quot;The watch key list can't be empty&quot;)
var isCompletedByMe = operation.tryComplete()
// 如果该延迟请求是由本线程完成的直接返回true即可
if (isCompletedByMe)
return true
var watchCreated = false
// 遍历所有要监控的Key
for(key &lt;- watchKeys) {
// 再次查看请求的完成状态如果已经完成就说明是被其他线程完成的返回false
if (operation.isCompleted)
return false
// 否则将该operation加入到Key所在的WatcherList
watchForOperation(key, operation)
// 设置watchCreated标记表明该任务已经被加入到WatcherList
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
}
```
该方法的名字折射出了它要做的事情先尝试完成请求如果无法完成则把它加入到WatcherList中进行监控。具体来说tryCompleteElseWatch调用tryComplete方法尝试完成延迟请求如果返回结果是true就说明执行tryCompleteElseWatch方法的线程正常地完成了该延迟请求也就不需要再添加到WatcherList了直接返回true就行了。
否则的话代码会遍历所有要监控的Key再次查看请求的完成状态。如果已经完成就说明是被其他线程完成的返回false如果依然无法完成则将该请求加入到Key所在的WatcherList中等待后续完成。同时设置watchCreated标记表明该任务已经被加入到WatcherList以及更新Purgatory中总请求数。
待遍历完所有Key之后源码会再次尝试完成该延迟请求如果完成了就返回true否则就取消该请求然后将其加入到过期队列最后返回false。
总的来看,你要掌握这个方法要做的两个事情:
1. 先尝试完成延迟请求;
1. 如果不行就加入到WatcherList等待后面再试。
那么代码是在哪里进行重试的呢这就需要用到第2个方法checkAndComplete了。
该方法会**检查给定Key所在的WatcherList中的延迟请求是否满足完成条件**,如果是的话,则结束掉它们。我们一起看下源码:
```
def checkAndComplete(key: Any): Int = {
// 获取给定Key的WatcherList
val wl = watcherList(key)
// 获取WatcherList中Key对应的Watchers对象实例
val watchers = inLock(wl.watchersLock) { wl.watchersByKey.get(key) }
// 尝试完成满足完成条件的延迟请求并返回成功完成的请求数
val numCompleted = if (watchers == null)
0
else
watchers.tryCompleteWatched()
debug(s&quot;Request key $key unblocked $numCompleted $purgatoryName operations&quot;)
numCompleted
}
```
代码很简单就是根据给定Key获取对应的WatcherList对象以及它下面保存的Watchers对象实例然后尝试完成满足完成条件的延迟请求并返回成功完成的请求数。
可见,非常重要的步骤就是**调用Watchers的tryCompleteWatched方法去尝试完成那些已满足完成条件的延迟请求**。
## 总结
今天我们重点学习了分层时间轮的上层组件包括Timer接口及其实现类SystemTimer、DelayedOperation类以及DelayedOperationPurgatory类。你基本上可以认为它们是逐级被调用的关系即**DelayedOperation调用SystemTimer类DelayedOperationPurgatory管理DelayedOperation**。它们共同实现了Broker端对于延迟请求的处理基本思想就是**能立即完成的请求马上完成否则就放入到名为Purgatory的缓冲区中**。后续DelayedOperationPurgatory类的方法会自动地处理这些延迟请求。
我们来回顾一下重点。
- SystemTimer类Kafka定义的定时器类封装了底层分层时间轮实现了时间轮Bucket的管理以及时钟向前推进功能。它是实现延迟请求后续被自动处理的基础。
- DelayedOperation类延迟请求的高阶抽象类提供了完成请求以及请求完成和过期后的回调逻辑实现。
- DelayedOperationPurgatory类Purgatory实现类该类定义了WatcherList对象以及对WatcherList的操作方法而WatcherList是实现延迟请求后续自动处理的关键数据结构。
总的来说延迟请求模块属于Kafka的冷门组件。毕竟大部分的请求还是能够被立即处理的。了解这部分模块的最大意义在于你可以学习Kafka这个分布式系统是如何异步循环操作和管理定时任务的。这个功能是所有分布式系统都要面临的课题因此弄明白了这部分的原理和代码实现后续我们在自行设计类似的功能模块时就非常容易了。
## 课后讨论
DelayedOperationPurgatory类中定义了一个Reaper线程用于将已过期的延迟请求从数据结构中移除掉。这实际上是由DelayedOperationPurgatory的advanceClock方法完成的。它里面有这样一句
```
val purged = watcherLists.foldLeft(0) {
case (sum, watcherList) =&gt; sum + watcherList.allWatchers.map(_.purgeCompleted()).sum
}
```
你觉得这个语句是做什么用的?
欢迎在留言区写下你的思考和答案,跟我交流讨论,也欢迎你把今天的内容分享给你的朋友。