简单说说Kafka中的时间轮算法

原创
2018/10/25 17:26
阅读数 11.1W

零、时间轮定义

简单说说时间轮吧,它是一个高效的延时队列,或者说定时器。实际上现在网上对于时间轮算法的解释很多,定义也很全,这里引用一下朱小厮博客里出现的定义:

参考下图,Kafka中的时间轮(TimingWheel)是一个存储定时任务的环形队列,底层采用数组实现,数组中的每个元素可以存放一个定时任务列表(TimerTaskList)。TimerTaskList是一个环形的双向链表,链表中的每一项表示的都是定时任务项(TimerTaskEntry),其中封装了真正的定时任务TimerTask。

时间轮

如果你理解了上面的定义,那么就不必往下看了。但如果你第一次看到和我一样懵比,并且有不少疑问,那么这篇博文将带你进一步了解时间轮,甚至理解时间轮算法。

如果有兴趣,可以去看看其他的定时器 你真的了解延时队列吗。博主认为,时间轮定时器最大的优点:

    1. 是任务的添加与移除,都是O(1)级的复杂度;
    1. 不会占用大量的资源;
    1. 只需要有一个线程去推进时间轮就可以工作了。

我们将对时间轮做层层推进的解析:

一、为什么使用环形队列

假设我们现在有一个很大的数组,专门用于存放延时任务。它的精度达到了毫秒级!那么我们的延迟任务实际上需要将定时的那个时间简单转换为毫秒即可,然后将定时任务存入其中:

比如说当前的时间是2018/10/24 19:43:45,那么就将任务存入Task[1540381425000],value则是定时任务的内容。


private Task[很长] tasks;

public List<Task> getTaskList(long timestamp) {
	return task.get(timestamp)
}

// 假装这里真的能一毫秒一个循环
public void run(){
	while (true){
		getTaskList(System.currentTimeMillis()).后台执行()
		Thread.sleep(1);
	}
}

假如这个数组长度达到了亿亿级,我们确实可以这么干。 那如果将精度缩减到秒级呢?我们也需要一个百亿级长度的数组。

先不说内存够不够,显然你的定时器要这么大的内存显然很浪费。

当然如果我们自己写一个map,并保证它不存在hash冲突问题,那也是完全可行的。(我不确定我的想法是否正确,如果错误,请指出)


/* 一个精度为秒级的延时任务管理类 */
private Map<Long, Task> taskMap;

public List<Task> getTaskList(long timestamp) {
	return taskMap.get(timestamp - timestamp % 1000)
}

// 新增一个任务
public void addTask(long timestamp, Task task) {
	List<Task> taskList = getTaskList(timestamp - timestamp % 1000);
		if (taskList == null){
			taskList = new ArrayList();
		}
	taskList.add(task);
}

// 假装这里真的能一秒一个循环
public void run(){
	while (true){
		getTaskList(System.currentTimeMillis()).后台执行()
		Thread.sleep(1000);
	}
}

其实时间轮就是一个不存在hash冲突的数据结构

抛开其他疑问,我们看看手腕上的手表(如果没有去找个钟表,或者想象一个),是不是无论当前是什么时间,总能用我们的表盘去表示它(忽略精度)

就拿秒表来说,它总是落在 0 - 59 秒,每走一圈,又会重新开始。

用伪代码模拟一下我们这个秒表:

private Bucket[60] buckets;// 表示60秒

public void addTask(long timestamp, Task task) {
	Bucket bucket = buckets[timestamp / 1000 % 60];
	bucket.add(task);
}

public Bucket getBucket(long timestamp) {
	return buckets[timestamp / 1000 % 60];
}

// 假装这里真的能一秒一个循环
public void run(){
	while (true){
		getBucket(System.currentTimeMillis()).后台执行()
		Thread.sleep(1000);
	}
}

这样,我们的时间总能落在0 - 59任意一个bucket上,就如同我们的秒钟总是落在0 - 59刻度上一样,这便是时间轮的环形队列

二、表示的时间有限

但是细心的小伙伴也会发现这么一个问题:如果只能表示60秒内的定时任务应该怎么存储与取出,那是不是太有局限性了?如果想要加入一小时后的延迟任务,该怎么办?

其实还是可以看一看钟表,对于只有三个指针的表(一般的表)来说,最大能表示12个小时,超过了12小时这个范围,时间就会产生歧义。如果我们加多几个指针呢?比如说我们有秒针,分针,时针,上下午针,天针,月针,年针...... 那不就能表示很长很长的一段时间了?而且,它并不需要占用很大的内存。

比如说秒针我们可以用一个长度为60的数组来表示,分针也同样可以用一个长度为60的数组来表示,时针可以用一个长度为24的数组来表示。那么表示一天内的所有时间,只需要三个数组即可。


动手来做吧,我们将这个数据结构称作时间轮,tickMs表示一个刻度,比如说上面说的一秒。wheelSize表示一圈有多少个刻度,即上面说的60。interval表示一圈能表示多少时间,即 tickMs * wheelSize = 60秒。

overflowWheel表示上一层的时间轮,比如说,对于秒钟来说,overflowWheel就表示分钟,以此类推。

public class TimeWheel {

    /** 一个时间槽的时间 */
    private long tickMs;

    /** 时间轮大小 */
    private int wheelSize;

    /** 时间跨度 */
    private long interval;

    /** 槽 */
    private Bucket[] buckets;

    /** 时间轮指针 */
    private long currentTimestamp;

    /** 上层时间轮 */
    private volatile TimeWheel overflowWheel;

    public TimeWheel(long tickMs, int wheelSize, long currentTimestamp) {
        this.currentTimestamp = currentTimestamp;
        this.tickMs = tickMs;
        this.wheelSize = wheelSize;
        this.interval = tickMs * wheelSize;
        this.buckets = new Bucket[wheelSize];
        this.currentTimestamp = currentTimestamp - (currentTimestamp % tickMs);

        for (int i = 0; i < wheelSize; i++) {
            buckets[i] = new Bucket();
        }
    }
}

将任务添加到时间轮中十分简单,对于每个时间轮来说,比如说秒级时间轮,和分级时间轮,都有它自己的过期槽。也就是delayMs < tickMs的时候。

添加延时任务的时候一共就这几种情况:

####一、时间到期

  • 1)比如说有一个任务要在 16:29:07 执行,从秒级时间轮中来看,当我们的当前时间走到16:29:06的时候,则表示这个任务已经过期了。因为它的delayMs = 1000ms,小于了我们的秒级时间轮的tickMs(1000ms)。
    1. 比如说有一个任务要在 16:41:25 执行,从分级时间轮中来看,当我们的当前时间走到 16:41的时候(分级时间轮没有秒针!它的最小精度是分钟(一定要理解这一点)),则表示这个任务已经到期,因为它的delayMs = 25000ms,小于了我们的分级时间轮的tickMs(60000ms)。

二、时间未到期,且delayMs小于interval。

对于秒级时间轮来说,就是延迟时间小于60s,那么肯定能找到一个秒钟槽扔进去。

三、时间未到期,且delayMs大于interval。

对于妙级时间轮来说,就是延迟时间大于等于60s,这时候就需要借助上层时间轮的力量了,很简单的代码实现,就是拿到上层时间轮,然后类似递归一样,把它扔进去。


比如说一个有一个延时为一年后的定时任务,就会在这个递归中不断创建更上层的时间轮,直到找到满足delayMs小于interval的那个时间轮。

这里为了不把代码写的那么复杂,我们每一层时间轮的刻度都一样,也就是秒级时间轮表示60秒,上面则表示60分钟,再上面则表示60小时,再上层则表示60个60小时,再上层则表示60个60个60小时 = 216000小时。

也就是如果将最底层时间轮的tickMs(精度)设置为1000ms。wheelSize设置为60。那么只需要5层时间轮,可表示的时间跨度已经长达24年(216000小时)

    /**
     * 添加任务到某个时间轮
     */
    public boolean addTask(TimedTask timedTask) {
        long expireTimestamp = timedTask.getExpireTimestamp();
        long delayMs = expireTimestamp - currentTimestamp;
        if (delayMs < tickMs) {// 到期了
            return false;
        } else {

            // 扔进当前时间轮的某个槽中,只有时间【大于某个槽】,才会放进去
            if (delayMs < interval) {
                int bucketIndex = (int) (((delayMs + currentTimestamp) / tickMs) % wheelSize);

                Bucket bucket = buckets[bucketIndex];
                bucket.addTask(timedTask);
            } else {
			// 当maybeInThisBucket大于等于wheelSize时,需要将它扔到上一层的时间轮
                TimeWheel timeWheel = getOverflowWheel();
                timeWheel.addTask(timedTask);
            }
        }
        return true;
    }


   /**
     * 获取或创建一个上层时间轮
     */
	private TimeWheel getOverflowWheel() {
        if (overflowWheel == null) {
            synchronized (this) {
                if (overflowWheel == null) {
                    overflowWheel = new TimeWheel(interval, wheelSize, currentTimestamp, delayQueue);
                }
            }
        }
        return overflowWheel;
    }

当然我们的时间轮还需要一个指针的推进机制,总不能让时间永远停留在当前吧?推进的时候,同时类似递归,去推进一下上一层的时间轮。

注意:要强调一点的是,我们这个时间轮更像是电子表,它不存在时间的中间状态,也就是精度这个概念一定要理解好。比如说,对于秒级时间轮来说,它的精度只能保证到1秒,小于1秒的,都会当成是已到期

对于分级时间轮来说,它的精度只能保证到1分,小于1分的,都会当成是已到期

    /**
     * 尝试推进一下指针
     */
    public void advanceClock(long timestamp) {
        if (timestamp >= currentTimestamp + tickMs) {
            currentTimestamp = timestamp - (timestamp % tickMs);

            if (overflowWheel != null) {
                this.getOverflowWheel()
                    .advanceClock(timestamp);
            }
        }
    }

三、对于高层时间轮来说,精度越来越不准,会不会有影响?

上面说到,分级时间轮,精度只有分钟级,总不能延迟1秒的定时任务和延迟59秒的定时任务同时执行吧?

有这个疑问的同学很好!实际上很好解决,只需再入时间轮即可。比如说,对于分钟级时间轮来说,delayMs为1秒和delayMs为59秒的都已经过期,我们将其取出,再扔进底层的时间轮不就可以了?

1秒的会被扔到秒级时间轮的下一个执行槽中,而59秒的会被扔到秒级时间轮的后59个时间槽中。

细心的同学会发现,我们的添加任务方法,返回的是一个bool

public boolean addTask(TimedTask timedTask)

再倒回去好好看看,添加到最底层时间轮失败的(我们只能直接操作最底层的时间轮,不能直接操作上层的时间轮),是不是会直接返回flase?对于再入失败的任务,我们直接执行即可。


    /**
     * 将任务添加到时间轮
     */
    public void addOrSubmitTask(TimedTask timedTask) {
        if (!timeWheel.addTask(timedTask)) {
            taskExecutor.submit(timedTask.getTask());
        }
    }
	

四、如何知道一个任务已经过期?

记得我们将任务存储在槽中嘛?比如说秒级时间轮中,有60个槽,那么一共有60个槽。如果时间轮共有两层,也仅仅只有120个槽。我们只需将槽扔进一个delayedQueue之中即可。

我们轮询地从delayedQueue取出已经过期的槽即可。(前面的所有代码,为了简单说明,并没有引入这个DelayQueue的概念,所以不用去上面翻了,并没有。博主觉得...已经看到这里了,应该很明白这个DelayQueue的意义了。

其实简单来说,实际上定时任务单单使用DelayQueue来实现,也是可以的,但是一旦任务的数量多了起来,达到了百万级,千万级,针对这个delayQueue的增删,将非常的慢。

** 一、面向槽的delayQueue**

而对于时间轮来说,它只需要往delayQueue里面扔各种槽即可,比如我们的定时任务长短不一,最长的跨度到了24年,这个delayQueue也仅仅只有300个元素。

** 二、处理过期的槽**

而这个槽到期后,也就是被我们从delayQueue中poll出来后,我们只需要将槽中的所有任务循环一次,重新加到新的槽中(添加失败则直接执行)即可。

   /**
     * 推进一下时间轮的指针,并且将delayQueue中的任务取出来再重新扔进去
     */
    public void advanceClock(long timeout) {
        try {
            Bucket bucket = delayQueue.poll(timeout, TimeUnit.MILLISECONDS);
            if (bucket != null) {
                timeWheel.advanceClock(bucket.getExpire());
                bucket.flush(this::addTask);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

完整的时间轮GitHub,其实就是半抄半自己撸的Kafka时间轮简化版 Timer#main 中模拟了六百万个简单的延时任务,执行的效率很高 ~

展开阅读全文
打赏
11
112 收藏
分享
加载中
精度越来越不准,这里就看不懂了:
上面说到,分级时间轮,精度只有分钟级,总不能延迟1秒的定时任务和延迟59秒的定时任务同时执行吧?

有这个疑问的同学很好!实际上很好解决,只需再入时间轮即可。比如说,对于分钟级时间轮来说,delayMs为1秒和delayMs为59秒的都已经过期,我们将其取出,再扔进底层的时间轮不就可以了

假如有1m1s和1m59s两个任务,现在时间是1m的话,那么两个都没到期,如果跳到2m,那么两个都过期了,还是同时执行而且延时性很高?高阶和低阶时间轮怎么运行的没看懂。
2019/12/27 15:12
回复
举报
delayQueue里面的顺序结构应该是什么样的
2019/12/27 15:21
回复
举报
是每次推进时间,都会把当前时间对应的最小时间轮槽内的所有任务取出来,过期就执行,没过期就重新添加,这样做的吧
2019/12/27 16:00
回复
举报
Anur博主
是的,没过期就重新添加,上层的时间轮过期后,重新添加自然就到下层时间轮里面去了。比如最上层是以一小时为最小槽,那么当它不足一小时,就会过期,就被“执行”,执行的逻辑都是重新扔进时间轮,只有扔不进去的才会被worker线程真正执行(也就是最底层的时间轮)
2019/12/27 16:59
回复
举报
2019/09/26 10:25
回复
举报
博主,我理解不了currentTimestamp是什么?是一个时间戳,还是用来时间轮的环形队列的下标索引?我看代码中取的是当前时间戳,那该如何理解它的作用?我理解的时间轮是一个环形队列,一个数组,这个指针就是数组索引啊? 如果任务延时大于当前时间轮的周期interval,就创建上层时间轮,可是为什么要把currentTimestamp作为上层时间轮的当前时间?难道所有时间轮的当前时间都一样?还有这和kafka中startMs是一个东西吗? Timer中的advanceClock方法也感觉有些懵逼,从delayQueue中获取过期任务还好说,但是后面的timeWheel.advanceClock(bucket.getExpire())的作用又是什么,感觉是把每一层时间轮的currentTimestamp都同步了下,但为什么delayQueue中有过期任务才同步?没任务上层时间轮的currentTimestamp就不变了?
2019/09/26 10:29
回复
举报
Anur博主
首先,如果把所有的时间(比如从2019-2020 一年的时间,精确到毫秒)放到一个大表盘上,每一个刻度是否都能表示一个具体的毫秒? currentTimestamp 就是一个“毫秒针”,指的当前的时间,每一毫秒都能跳动一格,并把存在这一格的定时任务都拿出来跑,这就是简单的时间轮 advanceClock 就是推进 “毫秒针” 的那个动作 先不讨论上层时间轮 delayQueue是判断槽是否过期的,按照上面的假设,每毫秒都是一个槽,那槽是否到期(其实不应该用过期),是不是直接 delayQueue.poll 即可 按照目前阶段的设计,我们完全不需要时间轮,只需要一个 delayQueue,以毫秒为精度往里面塞任务即可。但是这样,假如每毫秒都有一个任务,那这一年需要3.1536*10^10个槽,delayQueue的长度不用说了,链表 O(n) 的删改查会很慢 所以我们将精度降低,变成每秒一个槽,那么只需要31536000个槽即可 但是这依旧很大 怎么办?如果不想再降低精度,就引入上层时间轮,比如上层时间轮精度为一天,这样就只要 3600000 + 365个槽 先不谈论最下层的时间轮,就说第二层,到了今天12点,365个槽中就会有一个槽到期,里面所欲任务就会被取出来,塞进下层时间轮 ext:没任务槽里面不需要去同步时间,那个时间是用来入队列和出队用的 ext:所有的槽、时间轮的currentTimestamp当然指的当前时间
2019/10/09 11:17
回复
举报
Anur博主
该评论暂时无法显示,详情咨询 QQ 群:912889742

引用来自“六点小巷”的评论

我是刚学kafka,看的懂了些,kafka 消息是否 删除 看 过期时间 和消息是否到达一定数量, 不太清楚时间轮算法 是不是做个的

引用来自“Anur”的评论

时间轮不是做这个的~ 是做那些延时任务或者定时任务的,比如说心跳包,比如说producer发送一个消息到broker,需要ack这种。
比如说ack,如果没到过期时间就达到ack条件了,就直接从时间轮中移除这个任务,返回ack,如果说到了过期时间依然没有达到ack条件(比如说你设置了必须等副本也同步完成才算消息发送成功,那么到了过期时间,就会给producer发送一条发送失败:同步超时之类的回复)
懂了 谢谢
2018/11/23 17:48
回复
举报
Anur博主

引用来自“六点小巷”的评论

我是刚学kafka,看的懂了些,kafka 消息是否 删除 看 过期时间 和消息是否到达一定数量, 不太清楚时间轮算法 是不是做个的
时间轮不是做这个的~ 是做那些延时任务或者定时任务的,比如说心跳包,比如说producer发送一个消息到broker,需要ack这种。
比如说ack,如果没到过期时间就达到ack条件了,就直接从时间轮中移除这个任务,返回ack,如果说到了过期时间依然没有达到ack条件(比如说你设置了必须等副本也同步完成才算消息发送成功,那么到了过期时间,就会给producer发送一条发送失败:同步超时之类的回复)
2018/11/14 19:56
回复
举报
多说一句 你的直觉是对的 LinkedList<Node> 每一次轮训 本质上都在遍历一个链表 我之所以选择数据库储存Tasks也正是看中了数据库的Hash散列速度 用Map做确实比用链表快O(N)倍 而且不需要遍历
2018/10/28 01:28
回复
举报
我也写过一个100%自己设计TCD时间片轮训算法 : 楼主的实现与iOS和.NET中的SpinLock很像 这种"环形设计"普遍存在一个硬伤: 在轮训速度远远大于Interval的时候你的FailTaskPtr指针会频繁起锁 任务达到一定量级 上层调用者就会崩溃 。不如将时间轴拉成一条直线,然后根据花色对扑克牌进行分组(枚举出任务类型) 然后辅以SenderID 进行T轴上的垂直穿插,Internval设定为大于168*2毫秒 就足够应对90%的App需求了。游戏级别的 我没做过,但应该也没啥问题。
2018/10/28 01:25
回复
举报
多层的话数据结构会很复杂,其实只需要一层。比如200秒后的延时任务。用200 / 60 = 3, 200 %60 = 20可以得到在轮询3遍后的索引为19的位置(下标从0开始)bucket。在对应的列表中加入该延时任务,该任务有一个标志位表示当前剩余几次轮询后该执行它。每次时间轮轮询的到索引为19的bucket时,取出该bucket的任务列表进行遍历,标志位为0表示该任务需要执行了,执行完后删除该节点;不为0将该值减1,对节点不做处理。循环往复。
2018/10/27 11:21
回复
举报
我是刚学kafka,看的懂了些,kafka 消息是否 删除 看 过期时间 和消息是否到达一定数量, 不太清楚时间轮算法 是不是做个的
2018/10/26 15:58
回复
举报
Anur博主

引用来自“xiaoshiyue”的评论

蟹蟹!
2018/10/25 17:56
回复
举报
2018/10/25 17:45
回复
举报
更多评论
打赏
16 评论
112 收藏
11
分享
返回顶部
顶部