概述
Kafka中有很多延时操作,如耗时的网络请求(如Produce 时等待 ISR 副本复制成功)会被封装成 DelayOperation 进行延迟处理操作,防止阻塞 Kafka请求处理线程。Kafka 没有使用 JDK 自带的 Timer 和 DelayQueue 实现。时间复杂度上这两者插入和删除操作都是 O(logn)
,不满足性能要求。
JDK Timer 和 DelayQueue 底层都是个优先队列,即采用 minHeap 的数据结构,最快需要执行的任务排在队列第一个,不同的是 Timer 中有个线程去拉取任务执行,DelayQueue是个容器,需要配合其他线程工作。ScheduledThreadPoolExecutor是JDK提供定时线程池,也是 DelayQueue + 池化线程的一个实现。
Kafka 基于时间轮实现延时操作,时间轮算法的插入删除操作的时间复杂度都是O(1)
,满足性能要求。除Kafka外,像Netty、ZooKeepr、Dubbo、Caffeine、Akka等开源项目都有使用到时间轮的实现。
时间轮
TimingWheel,简单来说就是一个环形队列(底层一般基于数组实现),队列中的每一个元素(时间格)都可以存放一个定时任务列表(TimerTaskList)。时间轮中的每个时间格代表时间轮的基本时间跨度或时间精度,假如一秒走一个时间格的话,则这个时间轮的精度就是 1 秒(即3s和3.9s会在同一个时间格中)。TimerTaskList是一个环形的双向链表,链表中的每一项表示的都是定时任务项(TimerTaskEntry),其中封装真正的定时任务(TimerTask)。
下图是一个有 12 个时间格的时间轮,转完一圈需要 12 s。当需要新建一个 3s 后执行的定时任务,只需要将定时任务放在下标为 3 的时间格中即可。
当需要创建一个15s后执行的定时任务怎么办呢?此时可引入一叫做 圈数/轮数 的概念,即这个任务还是放在下标为 3 的时间格中, 不过它的圈数为2。除增加圈数这种方法之外,还有种 多层次时间轮,Kafka采用的就是这种方案。
实现
Netty
Kafka
几个参数:
- tickMs:时间跨度
- wheelSize:时间轮中 bucket 的个数
- startMs:开始时间
- interval:时间轮的整体时间跨度 = tickMs * wheelSize
- currentTime:tickMs的整数倍,代表时间轮当前所处的时间。currentTime可以将整个时间轮划分为到期部分和未到期部分,currentTime当前指向的时间格也属于到期部分,表示刚好到期,需要处理此时间格所对应的TimerTaskList中的所有任务
整个时间轮的总体跨度是不变的,随着指针currentTime的不断推进,当前时间轮所能处理的时间段也在不断后移,总体时间范围在currentTime和currentTime+interval之间。
Kafka采用多层次时间轮来支持大跨度的定时任务,参考手表。
上图时间轮,第 1 层的时间精度为1 ,第2 层的时间精度为 20 ,第 3 层的时间精度为 400。假如需要添加一个 350s 后执行的任务 A 的话(当前时间是 0s),这个任务会被放在第 2 层(第二层的时间跨度为 20*20=400>350)的第350/20=17 个时间格子。
当第一层转17 圈之后,时间过去340s ,第 2 层的指针此时来到第 17 个时间格子。此时第 2 层第 17 个格子的任务会被移动到第 1 层。
任务 A 当前是 10s 之后执行,因此它会被移动到第 1 层的第 10 个时间格子。
在层与层之间的移动,叫做时间轮的升降级。时间轮比较适合任务数量比较多的定时任务场景,它的任务写入和执行的时间复杂度都是O(1)
。
随着时间推进,也会有一个时间轮降级的操作,原本延时较长的任务会从高一层时间轮重新提交到时间轮中,然后会被放在合适的低层次的时间轮当中等待处理;在Kafka中时间轮之间如何关联呢,如何展现这种高一层的时间轮关系?
一个内部对象的指针,指向自己高一层的时间轮对象。
如何推进时间轮的前进,让时间轮的时间往前走?
Netty 中的时间轮是通过工作线程按照固定的时间间隔 tickDuration 推进的,如果长时间没有到期任务,这种方案会带来空推进的问题,造成一定性能损耗;
Kafka 则是通过 DelayQueue 来推进,是一种空间换时间的思想;DelayQueue 中保存着所有的 TimerTaskList 对象,根据时间来排序,这样延时越小的任务排在越前面。外部通过一个ExpiredOperationReaper线程从 DelayQueue 中获取超时的任务列表 TimerTaskList,然后根据 TimerTaskList 的过期时间来精确推进时间轮的时间,这样就不会存在空推进的问题。
Kafka采用权衡的策略,把DelayQueue 用在合适地方。DelayQueue 只存放 TimerTaskList,并不是所有的 TimerTask,数量并不多,相比空推进带来的影响是利大于弊的。
总结
- Kafka 使用时间轮来实现延时队列,因为其底层是任务的添加和删除是基于链表实现的,时间复杂度为
O(1)
,满足高性能的要求; - 对于时间跨度大的延时任务,Kafka 引入了层级时间轮,能更好控制时间粒度,可以应对更加复杂的定时任务处理场景;
- 对于如何实现时间轮的推进和避免空推进影响性能,Kafka 采用空间换时间的思想,通过 DelayQueue 来推进时间轮,算是一个经典的 trade off。
参考
Kafka时间轮算法设计HashedWheelTimer使用及源码分析一个开源的时间轮算法介绍