概述
这篇博文的起源在于阿里的公众号里面有一篇文章讲菜鸟的同学在造一个关于时间轮定时器的文章,然后在网上搜索资料发现其实在好多开源的软件里面已经有了,最后选择了kafka里面的定时器实现来加深自己的理解。这个概念有点绕,我也尽量把核心的点讲解清楚,博文末尾的两旁参考文献其实是讲解的比较清楚的,我应该会盗用里面的图来实现来帮助自己把核心点讲清楚。
在理解kafka的时间轮定时器的概念的时候,我们需要提前了解下java里面的DelayQueue的概念,因为在kafka的时间轮定时器其实是基于DelayQueue来实现的。
最后我希望一定要好好看博文末尾的参考文献,将这篇博文+参考文献就可以把时间轮理解的很清楚。
定时器选型
传统方案
对于实现一个定时任务,一般的做法是将定时任务写入数据库,通过一个线程定时查询出将要到期的任务,再执行任务相关逻辑。该方案的优点是实现简单,尤其适合单机或者业务量比较小的场景来。但是缺点也很明显:在分布式且业务量较大的场景中会引入很多复杂性。首先,需要设计一套合理的分库分表逻辑,以及集群任务负载逻辑。其次,即使做到这些,也会由于某些场景定时任务时间集中在某个时间点,导致集群单节点压力过大。再次,需要合理的预估容量,否则后续线性存储扩容将会非常复杂。
我们的物流处罚其实就是采用类似的机制去实现扫描的,但是后来因为分库等原因最后还是借用了rocketMq来实现的,与其说借用了rocketMq还不如说借用了rocketMq内部的定时器的实现,在开源的4.x的rocketMq版本中其实本质上还是用定时任务加队列的方式来发现任务是否过期。
传统的方法一个弊端就在于一般情况下我们按照过期粒度,譬如1分钟、10分钟、1小时、24时小时等粒度组装Timer+队列,然后同时有n个线程扫描各自的队列,然后发现其中过期的进行处理,在大量扫描过程中其实很多任务可能还是没有过期的,也就是说白白进行了扫描。那么时间轮在这方面是不是有了优化呢。
时间轮方案
时间轮方案将现实生活中的时钟概念引入到软件设计中,主要思路是定义一个时钟周期(比如时钟的12小时)和步长(比如时钟的一秒走一次),当指针每走一步的时候,会获取当前时钟刻度上挂载的任务并执行,整体结构如图1。
从上图可以看到,对于时间的计算是交给一个类似时钟的组件来做,而任务是通过一个指针或者引用去关联某个刻度上到期的定时任务,这样就能够将定时任务的存储和时间进行解耦,时钟组件难度不大,以何种方式存储这些任务数据,是时间轮方案的关键。
我理解时间轮的好处在于如果时间轮的指针指到了对应的格子,那么该格子指向的队列里面的任务就都是过期的,可以减少很多不必要的无意义的扫描,至于为什么后面可以看分析。
kafka的时间轮
说明
kafka的内部Timer其实是自己实现的一个定时器(其实就是一个时间轮),对外提供两个接口,一个接口是由外部调用添加任务add(TimerTask),一个接口是由外部驱动时间轮轮转(advanceClock),当发现任务过期以后则提交专门的任务线程去执行。时间轮内部的真正细节是下面这个图。
说明
其实Timer内部是有一个个TimingWheel来实现时间轮的,为什么会有多个时间轮呢,其实参考我们的时钟就能理解,我们的时钟有秒针(60s)、分针(60m)、时针(60h)。每走一圈代表的时间含义也不相同,所以就会存在多个时间轮了。
但是我们看到了上面有一个DelayedQueue这个java集合对象,其实它里面保存了所有的延迟任务,因为DelayedQueue本身内部实现是一个有序的堆,我姑且这么认为,所以每次通过DelayedQueue去获取队首数据就是快要过期的数据。
在进入kafka时间轮源码分析之前,我们需要提前知道的几个概念:子时间轮,父时间轮,添加任务,消费任务等。
kafka时间轮流转
kafka时间轮的流转其实按照我们上面分析其实分为两个核心步骤,步骤一是任务添加过程,步骤二是执行过期任务。
任务添加过程
我们用数组模拟时间轮(数组的每个元素是一个列表头,添加任务就是往列表头后面挂任务而已),数组的大小代表时间的格子数,添加过程中我们会通过 过期时间/时间轮格子代表时间 % 时间轮格子总数 算出的格子位置,然后通过挂链的方法添加到时间轮格子当中。
在这个过程中我们需要注意的是任务首先需要判断当前时间轮是否放的下,判断放得下的标准就是时间轮当前时间 + 一圈时间轮时间是否大于任务过期时间,如果大于就代表放的下,如果小于就代表无法放置那么就需要往上一层时间轮放置。
所有时间轮格子其实是放置在一个DelayQueue当中的。
整个逻辑过程的核心在于hash找时间轮格子的过程,具体可以看下面的源码。
任务消费过程
每隔200ms去DelayQueue中以200ms的超时去获取任务(这个过后在末尾的参考文章讲解的很详细),如果获取到说明刚好有一堆超时任务需要处理,那么我们就将所有的任务直接投递到过期任务处理的线程池当中。
然后将时间轮的格子往前挪一步,挪一步的意思代表时间往前走了一步,然后我们更新当前时间轮的时间,这个时间哪里来的呢,时间就是刚刚我们处理的任务的过期时间。其实这个操作本质上是更新时间轮的当前时间,譬如原理时间是10:00,然后我们处理完一个到期待执行的任务后时间变成了10.40,这个10.40的时间就是代表了过期时间。
kafka时间轮源码
说明:
1、kafka时间轮的核心组成部分包括tickMs(时间格代表时间)、wheelSize(时间轮格子的数量)、startMs(时间轮开始时间)、taskCounter(任务个数)、delayQueue(延迟队列)。
2、我们每次通过从delayQueue中获取过期任务,如果能够获取到过期任务说明时间轮往前进一格。
说明:
1、前面提到过我们在添加任务失败就开始执行任务,那么添加任务失败实际代表的是任务已经到期了,对于添加任务其实是分几种情况进行解释的。
2、如果 任务的过期时间 < 当前时间+单个时间格时间,那么我们任务该任务需要立刻执行。
3、如果 当前时间+单个时间格时间 <= 任务过期时间 < 当前时间+整个时间轮时间,那么我们首先通过 任务过期时间/时间格时间 代表应该落在具体的哪个格子,但是因为时间轮一直在转动,所以我们需要通过hash来确认应该放在时间轮的哪个位置,最后我们需要设置最新的过期时间并把任务加入到delayedQueue当中,设置的过期时间是通过时间格代表的时间进行的归一。
4、这里有个疑问就是通过hash的方法计算落在具体的哪个时间格里面,会不会出现覆盖的情况呢,假设我们的时间轮有20个格子,那么20%20=0,40%20=0,60%20=0,岂不是还是会存在落在同一个格子里面但是过期时间不一样的情况嘛,其实不会的,为什么呢,因为我们在前面前置了时间,也就是说基于当前时间我只能放置一个时间轮周期的任务,超过一个时间轮周期的任务我们就会放置大父亲时间轮当中。
说明:
1、其实父时间轮本质上其时间格代表的时间是子时间轮一周代表的时间而已。
说明:
1、其实时间轮我们是用数组也就是buckets来实现的,也就是说一个数组代表时间轮。
2、时间轮中每个格子用来保存任务的数据结构是TimeTaskList的数据结构,其实就是一个双向链表,然后每次在往某个时间轮格子里面放置任务也就是timerTaskEntry的时候就会挂置到TimeTaskList的这个对象当中去。
3、所以说我们放置到DelayedQueue当中其实是TimeTaskList对象,这个对象包含了同一过期时间的所有任务而已。减少了DelayedQueue的大小。
说明:
其实TimerTaskList对象就是一个双向列表而已。
参考文献
Kafka源码深度解析-序列13 -Server核心组件之2(续)- TimingWheel本质与DelayedOperationPurgatory核心结构