Kafka TimingWheel 时间轮源码鉴赏

241 阅读6分钟

TimingWheel

在Kafka中应用了大量的延迟操作,但在Kafka中并没用使用JDK自带的Timer或是DelayQueue用于延迟操作,而是使用自己开发的DelayedOperationPurgatory组件用于管理延迟操作,Kafka这类分布式框架有大量延迟操作并且对性能要求及其高,而java.util.Timer与java.util.concurrent.DelayQueue的插入和删除时间复杂度都为对数阶O(log n)并不能满足Kafka性能要求,所以Kafka实现了基于时间轮的定时任务组件,该时间轮定时任务实现的插入与删除(开始定时器与暂停定时器)的时间复杂度都为常数阶O(1)

时间轮数据结构

时间轮名词解释:

时间格: 环形结构中用于存放延迟任务的区块;
指针(CurrentTime): 指向当前操作的时间格,代表当前时间
格数(ticksPerWheel): 为时间轮中时间格的个数
间隔(tickDuration): 每个时间格之间的间隔
总间隔(interval): 当前时间轮总间隔,也就是等于ticksPerWheel*tickDuration

TimingWheel并非简单的环形时间轮,而是多层级时间轮,每个时间轮由多个时间格组成,每个时间格为一个时间间隔,底层的时间格跨度较小,然后随着延迟任务延迟时间的长短逐层变大;如上图,底下的时间轮每个时间格为1ms,整个时间轮为10ms,而上面一层的时间轮中时间格为10ms,整个时间轮为100ms;
时间轮添加上级时间轮的规则为: 当前currentTime为上级时间轮的startMs,当前interval为上级时间轮的tickDuration,每层ticksPerWheel相同;简单点说就是上层时间轮跨度为当前的M倍,时间格为当前的N倍;

kafka中时间轮代码实现

SystemTimer: 时间轮的承载,或者说一个触发器,调用其内部的advanceClock(long timeout) 方法能够触发时间轮转动,并执行任务。

TimingWheel: 时间轮的实现类,内部使用数组buckets承载时间轮,在存放数组时根据取余方式获取存放位置。tickMs为上述的tickDurationwheelSize为时间轮格数,interval为当前时间轮总间隔。

TimeTaskListbuckets数组对象用于存放延迟任务,一个TimerTaskList就代表一个时间格,一个时间格中能保存的任务到期时间只可在[t~t+10ms]区间(t为时间格到期时间,10ms时间格间格),每个时间格有个过期时间,时间格过期后时间格中的任务将向前移动存入前面时间格,即bucket中;

TimerTaskEntry:TimeTaskList的节点表征

TimerTask:执行的任务

添加任务

SystemTimer持有第一层时间轮,调用add(TimeTask timetask)添加任务

public void add(TimerTask timerTask) {
    readLock.lock();
    try {
        addTimerTaskEntry(new TimerTaskEntry(timerTask, timerTask.delayMs + Time.SYSTEM.hiResClockMs()));
    } finally {
        readLock.unlock();
    }
}

private void addTimerTaskEntry(TimerTaskEntry timerTaskEntry) {
    if (!timingWheel.add(timerTaskEntry)) {
        // 添加失败有两种情况: 1. 任务超时,该执行了,2任务被取消
        if (!timerTaskEntry.cancelled()) {
            // 任务超时,交给任务执行线程池处理
            taskExecutor.submit(timerTaskEntry.timerTask);
        }
    }
}

在调用TimingWheel添加任务时,有四种情况:

  1. 任务被取消 -> 不添加 -> 交给线程池执行
  2. 任务超期 -> 不添加 -> 交给线程池执行
  3. 任务符合当前时间轮时间范围 -> 添加到对应的bucket -> bucket首次添加或者变更需要添加到延迟队列
  4. 任务超出当前时间轮时间范围 -> 新建下层时间轮 -> 使用下层时间轮添加

值得注意的一点,kafka的时间轮使用了DelayQueue,DelayQueue本省就可以作用一个延迟任务、定时任务的实现,为啥kafka会使用呢?这里先卖个关子。

public boolean add(TimerTaskEntry timerTaskEntry) {
    long expiration = timerTaskEntry.expirationMs;

    if (timerTaskEntry.cancelled()) {
        // Cancelled
        return false;
    } else if (expiration < currentTimeMs + tickMs) {
        // Already expired
        return false;
    } else if (expiration < currentTimeMs + interval) {
        // Put in its own bucket
        long virtualId = expiration / tickMs;
        int bucketId = (int) (virtualId % (long) wheelSize);
        TimerTaskList bucket = buckets[bucketId];
        bucket.add(timerTaskEntry);

        // Set the bucket expiration time
        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);
        }

        return true;
    } else {
        // Out of the interval. Put it into the parent timer
        if (overflowWheel == null) addOverflowWheel();
        return overflowWheel.add(timerTaskEntry);
    }
}

推进(转动)时间轮

调用SystemTimer的advanceClock(long timeoutMs)推动时间轮进行转动,但是注意这里可以指定延迟队列获取的超时时间,因此调用该方法也不一定能够推动时间轮,可以通过返回值判断是否推进了时间(执行任务),总体的逻辑在代码片段中注释了。

public boolean advanceClock(long timeoutMs) throws InterruptedException {
    // 获取最近一个超时的bucket
    TimerTaskList bucket = delayQueue.poll(timeoutMs, TimeUnit.MILLISECONDS);
    if (bucket != null) {
        writeLock.lock(); // advanceClock方法是线程安全的
        try {
            while (bucket != null) {
                // 推进时间轮
                timingWheel.advanceClock(bucket.getExpiration());
                // 2种情况:1.投递到上层时间轮 2. 执行任务
                bucket.flush(this::addTimerTaskEntry);
                // 执行下一个超时时间格
                bucket = delayQueue.poll();
            }
        } finally {
            writeLock.unlock();
        }
        return true;
    } else {
        return false;
    }
}

// 添加或取消或执行任务
private void addTimerTaskEntry(TimerTaskEntry timerTaskEntry) {
    if (!timingWheel.add(timerTaskEntry)) {
        // Already expired or cancelled
        if (!timerTaskEntry.cancelled()) {
            taskExecutor.submit(timerTaskEntry.timerTask);
        }
    }
}
public void advanceClock(long timeMs) {
    if (timeMs >= currentTimeMs + tickMs) { // 需要推进时间轮
        // 更新时间轮起始时钟到当前执行到的时间
        currentTimeMs = timeMs - (timeMs % tickMs);

        // Try to advance the clock of the overflow wheel if present
        // 更新下层时间轮起始时钟到当前执行到的时间
        if (overflowWheel != null) overflowWheel.advanceClock(currentTimeMs);
    }
}

Demo

一个简单的demo如下,新建了一个时间格间隔为50ms,大小为20的时间轮。使用一个定时线程不断触发时间轮推进。启动了5个线程不断地塞入超时时间为0 - 10000ms的额任务。

public class TimingWheelTest {
    public static void main(String[] args) throws InterruptedException {
        try (SystemTimer timer = new SystemTimer("test", 50, 20, Time.SYSTEM.hiResClockMs())) {
            ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1);
            scheduledExecutorService.scheduleAtFixedRate(() -> {
                try {
                    timer.advanceClock(300);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }, 0, 20, TimeUnit.MILLISECONDS);
            Thread thread = null;
            for (int i = 0; i < 5; i++) {
                thread = new Thread(() -> {
                    Random random = new Random();
                    while (true) {
                        try {
                            Thread.sleep(500);
                        } catch (InterruptedException e) {
                            throw new RuntimeException(e);
                        }
                        int timeout = random.nextInt(10000);
                        long current = Time.SYSTEM.hiResClockMs();
                        timer.add(new TimerTask(timeout) {
                            @Override
                            public void run() {
                                System.out.println(String.format("[%s] task executed, expect time: %d, actual time: %d, timeout: %d 误差: %d",
                                        Thread.currentThread().getName(),
                                        current + timeout, Time.SYSTEM.hiResClockMs(),
                                        timeout,
                                        Math.abs(current + timeout - Time.SYSTEM.hiResClockMs())
                                        )
                                );
                            }
                        });
                    }
                });
                thread.start();
            }
            thread.join();
        }
    }
}

DelayQueue

Kafka的实现比较有意思的一点是时间轮中使用了DelayQueue,回顾一下前沿,kafka本身就是为了避免DelayQ的O(n)插入与移除,才实现了实现轮,为什么这里会采用延迟队列呢? 试想一下,如果不适用DelayQueue,我们需要遍历所有bucket判断是否超期,在遍历的过程中会有大量的空判断,带来不必要的性能损耗。使用DelayQueue后,很好地解决了上面的现象,对于队列头部的获取时间复杂度也为O(1)。 此外,此处DelayQueue的大小也不与任务数量相关,而是与bucket相关,假设时间轮格数大小为20,存在10层,那么DelayQueue理论上最大值也就为10 * 20 = 200,在这个数量级下操作近似于O(1),在合理的配置下,10层早就足以涵盖业务需求的延迟时间。 Kafka中的定时器真可谓“知人善用”,用TimingWheel做最擅长的任务添加和删除操作,而用DelayQueue做最擅长的时间推进工作,两者相辅相成。