糊涂啊!这个需求居然没想到用时间轮来解决。

你好呀,我是歪歪。

上周不是发布了这篇文章嘛:《也是出息了,业务代码里面也用上算法了。》

里面聊到一个场景,A、B、C 三个平台需要调用下游系统的接口查询数据。

当时下游对该查询接口做了限流,只支持一秒最多一个请求。

其中 A 平台要求每个请求间隔 6s 或者以上。

B,C 平台可以接受一秒一次请求。

如何实现时间的最大化利用?

也就是把 A 平台中的间隔时间利用起来:

把中间用其他平台的数据塞满:

我当时灵机一动,想到一个骚操作:加权轮询负载均衡算法。

最终也算是实现了这个需求。

你知道的,在业务代码里面能用到算法还是一件很稀奇的事情,所以我写了上面那篇文章。

文章发布之后,经过读者提醒,我又打开了新思路。

找到了比我这个加权轮询负载均衡算法实现更加优雅的实现方案。

时间轮

其实当这个读者提到"一个平台一条时间轮"这句话的时候,我的思路一下就打开了。

时间轮,这个玩意我也会啊。

但是时间轮这个玩意,我们先按下不表,先分析一下这个读者的思路。

他在描述的时候,对时间轮加了引号,等我理解他的意思之后才知道,这个引号加的很巧妙。

因为他用的并不是真正的时间轮,而是借鉴了时间轮的"时间间隔"思路。

为了我画图方便,我先做一个预设:

  • A 平台的时间间隔为 5s
  • B 平台的时间间隔为 3s
  • C 平台的时间间隔为 1s

然后我们需要一个初始化的时间,假设为 10 点整,这个 10 点整你可以理解为程序跑起来的时间点。

那么整个核心思路大概是这样的。

首先,在程序刚刚启动的时候,A、B、C 3 个平台都是满足条件的。

所以理论上任何一个平台都可以。

对平台进行循环,最先循环到的是 A:

然后,关键的地方就来了。

平台被选中后,按照专属的时间间隔更新下次执行时间:

A 平台下次执行时间就变成了 10:00:05。

按照这个逻辑往下推。

前三秒就是这样的:

继续往下,到第六秒的时候,是这样的:

到这里,我必须把 10:00:05 这个时间切片单独拿出来给你看看,这个点非常关键:

你说,这个时候应该是 A 执行还是 C 执行呢,毕竟它们都满足"当前时间大于等于下次执行时间"这个判断条件。

按理来说,代码循环的时候,最新取到的肯定是 A,所以 A 先执行,没毛病。

但是实际上大概率是 C 执行。

为啥呢?

回到 A 的下次执行时间被更新时的这个时间点:

你仔细分析图中的这句话:

更新平台 A 的下次执行时间: System.currentTimeMillis()+5s

那平台 A 的下次执行时间会正正好好是 10:00:05 吗?

有 System.currentTimeMillis() 的存在,是不是 10:00:05:123 这样带着点毫秒数的时间更加合理点?

所以,当我把毫秒数带着,这样你再看,是不是大概率是 C 执行了:

那么问题又来了:为什么是大概率 C 执行呢?

在什么情况下可能会把 A 选出来执行呢?

在 GC 抖动的情况下,当前时间可能也会往前偏移一点,可能会把 A 选出来执行。

但是,A、C 谁先谁后,根本不重要,因为在当前的情况下,A、C 谁都可以执行。

整个思路就是这样的。

思路清晰了,代码不就是"呼大模型"而出了嘛:

复制代码
public class PlatformLaneScheduler {
    // 平台配置类
    static class Platform {
        final String name;
        final long interval; // 执行间隔(毫秒)
        long nextAllowedTime; // 下次允许执行时间
        
        public Platform(String name, long intervalSeconds) {
            this.name = name;
            this.interval = intervalSeconds * 1000;
            this.nextAllowedTime = System.currentTimeMillis(); // 初始可立即执行
        }
    }
    
    // 全局状态
    private final Map<String, Platform> platforms = new HashMap<>();
    private long lastExecutionTime = 0; // 上次执行时间
    private final ScheduledExecutorService scheduler;
    
    public PlatformLaneScheduler() {
        // 初始化调度器(单线程)
        scheduler = Executors.newSingleThreadScheduledExecutor();
        
        // 添加平台配置
        addPlatform("A", 5);
        addPlatform("B", 3);
        addPlatform("C", 1);
    }
    
    public void addPlatform(String name, long intervalSeconds) {
        platforms.put(name, new Platform(name, intervalSeconds));
    }
    
    public void start() {
        // 每100ms巡检一次
        scheduler.scheduleAtFixedRate(this::checkPlatforms, 0, 100, TimeUnit.MILLISECONDS);
    }
    
    public void stop() {
        scheduler.shutdown();
    }
    
    private void checkPlatforms() {
        long now = System.currentTimeMillis();
        // 检查全局限流:1秒内只能执行一次
        if (now - lastExecutionTime < 1000) {
            return;
        }
        // 查找最早到期的平台
        Platform earliestPlatform = null;
        long minNextTime = Long.MAX_VALUE;
        for (Platform platform : platforms.values()) {
            if (platform.nextAllowedTime <= now && platform.nextAllowedTime < minNextTime) {
                earliestPlatform = platform;
                minNextTime = platform.nextAllowedTime;
            }
        }
        // 执行符合条件的平台请求
        if (earliestPlatform != null) {
            executePlatformRequest(earliestPlatform, now);
        }
    }
    
    private void executePlatformRequest(Platform platform, long now) {
        // 执行请求
        System.out.printf("[%tT.%tL] %s平台执行 | 设定间隔: %ds | 实际间隔: %.3fs%n",
                now, now, platform.name, platform.interval / 1000,
                (now - platform.nextAllowedTime + platform.interval) / 1000.0);
        // 更新平台状态
        platform.nextAllowedTime = now + platform.interval;
        // 更新全局状态
        lastExecutionTime = now;
    }
    
    public static void main(String[] args) throws InterruptedException {
        PlatformLaneScheduler laneScheduler = new PlatformLaneScheduler();
        laneScheduler.start();
        // 运行60秒
        TimeUnit.SECONDS.sleep(60);
        laneScheduler.stop();
    }
}

从代码执行结果来看,第 6 秒,它确实选择了 C 平台执行:

老实说,这个解决方案,比我那个剑走偏锋的加权轮询负载均衡的方案好多了。

可以支持任意多个平台,每个平台都可以配置个性化的时间间隔。

而且这个方案的底层逻辑理解起来的成本也非常低。

但是,你看看这章的小标题,叫做"时间轮"。

上面这个方案并不是真正意义上的时间轮。

真正的时间轮

那么什么是时间轮呢?

首先时间轮最基本的结构其实就是一个数组,比如下面这个长度为 8 的数组:

怎么变成一个轮呢?

首尾相接就可以了:

假如每个元素代表一秒钟,那么这个数组一圈能表达的时间就是 8 秒,就是这样的:

注意我前面强调的是一圈,为 8 秒。

那么 2 圈就是 16 秒, 3 圈就是 24 秒,100 圈就是 800 秒。

这个能理解吧?

我再给你配个图:

虽然数组长度只有 8,但是它可以在上叠加一圈又一圈,那么能表示的数据就多了。

比如我把上面的图的前三圈改成这样画:

希望你能看明白,如果你看不明白,不要怀疑自己,肯定是垃圾作者画得不行,和你自己没关系。

记住,全文重点:与其反思自己,不如指责别人。

我画上面的图主要是要你知道这里面有一个"第几圈"的概念。

好了,我现在把前面的这个数组美化一下,从视觉上也把它变成一个轮子。

轮子怎么说?

轮子的英文是 wheel,所以我们现在有了一个叫做 wheel 的数组:

然后,把前面的数据给填进去大概是长这样的。

为了方便示意,我只填了下标为 0 和 3 的位置,其他地方也是一个意思:

那么问题就来了。假设这个时候我有一个需要在 800 秒之后执行的任务,应该是怎么样的呢?

800 mod 8 =0,说明应该挂在下标为 0 的地方:

假设又来一个 400 秒之后需要执行的任务呢?

同样的道理,继续往后追加即可:

不要误以为下标对应的链表中的圈数必须按照从小到大的顺序来,这个是没有必要的。

好,现在又来一个 403 秒后需要执行的任务,应该挂在哪儿?

403 mod 8 = 3,那么就是这样的:

我为什么要不厌其烦的给你说怎么计算,怎么挂到对应的下标中去呢?

因为我还需要引出一个东西:待分配任务的队列。

上面画 800 秒、 400 秒和 403 秒的任务的时候,我还省略了一步。

其实应该是这样的:

你看这个玩意,是不是就和我们讨论的场景很像了。

还是这一套老参数:

  • A 平台的时间间隔为 5s
  • B 平台的时间间隔为 3s
  • C 平台的时间间隔为 1s

假设我们的时间轮一圈是 8s,这个时间你可以自定义,你要是喜欢 10s 也不是不可以。

那么第一个八秒,即第一圈,应该是这样的:

  • 第 1 秒,A 平台执行。放在第 1 圈,第 1 个位置
  • 第 2 秒,B 平台执行。放在第 1 圈,第 2 个位置
  • 第 3 秒,C 平台执行。放在第 1 圈,第 3 个位置
  • 第 4 秒,C 平台执行。放在第 1 圈,第 4 个位置
  • 第 5 秒,B 平台执行。放在第 1 圈,第 5 个位置
  • 第 6 秒,C 平台执行。放在第 1 圈,第 6 个位置
  • 第 7 秒,A 平台执行。放在第 1 圈,第 7 个位置
  • 第 8 秒,C 平台执行。放在第 2 圈,第 0 个位置

第二个八秒,即第二圈,是这样的,我用不同的颜色来表示:

  • 第 9 秒,B 平台执行。放在第 2 圈,第 1 个位置
  • 第 10 秒,C 平台执行。放在第 2 圈,第 2 个位置
  • 第 11 秒,C 平台执行。放在第 2 圈,第 3 个位置
  • 第 12 秒,A 平台执行。放在第 2 圈,第 4 个位置
  • 第 13 秒,B 平台执行。放在第 2 圈,第 5 个位置
  • 第 14 秒,C 平台执行。放在第 2 圈,第 6 个位置
  • 第 15 秒,C 平台执行。放在第 2 圈,第 7 个位置
  • 第 16 秒,B 平台执行。放在第 3 圈,第 0 个位置

这玩意,才叫真正的时间轮。

以时间间隔为桶,根本不关心桶里面装的是哪个平台的数据。

反正时间一到,就把桶里面的数据往外扔就完事了。

你理解了真正的时间轮,你就知道为什么我说前面读者给出来的方案中的"时间轮"这个引号加的很巧妙。

他只是借用了时间轮中"定期检查"的思想,但从数据结构、调度机制和实现方式上,都与真正的时间轮有本质区别。

有点那种就是:我也不想解释我这个方案不是真正的时间轮,我只是联想到了真正的时间轮,借鉴了里面的一些思路。但是我又懒得给你解释这么多,我就加个引号在这里,你自己去悟。能不能悟出来,就看个人道行深浅了。

扔掉时间轮

所以,你以为到这里就结束了吗?

这篇文章下面还有一个评论。

这个评论寥寥数语,就给出了一个我认为是最佳方案的方案:

为什么不像上面说的用时间轮?

因为平台数比包数小很多,不需要时间轮这种复杂结构。

所以,还有高手?

前面我们说了,如果用真正的时间轮的话,里面放的是什么?

一圈又一圈的,放的是每个平台的数据。

但是换个视角,如果我们只关注平台呢?

把所有的平台都放到二叉堆里面去,利用二叉堆这个数据结构,帮我们实现平台维度的排序。

数据结构一换,整个解题思路又不一样了。

而这位读者的思路,和 DeepSeek 的思路是一致的。

直接给出了可运行的代码。

核心是基于 Java 的 PriorityQueue 实现:

不一样的是 DeepSeek 还给出了"锦上添花"的部分:

DeepSeek 怎么说

我还追问了 DeepSeek,让其用时间轮来解题。

它也解了:

随便给我对比了一下两个方案的优劣势:

然后给出了推荐的方案:

所以,这里也是在回答前面那篇文章中的这个留言:

从实践来看,AI 不会给出我最开始想到的"加权负载均衡算法"。

它会直接给出"基于优先级队列"这个最符合实际场景,也是最简单有效的实现方案。

写在最后

所以,你以为到这里就结束了吗?

要我说,其实前面的这些东西其实都不重要。

你看懂了,更好。看不懂,就算求了。

通过这个事情,我想要表达的还是我写文章以来一直坚持的一个观点:鼓励分享。

谁说写文章给出的方案就一定是要十全十美的?

我前面写的这篇文章,里面就带着"技术债"。

在分享上一篇文章后,通过和读者的思维碰撞,我得到了两种更有效、更优雅的解决方案。

这就是分享后的奖励。

它不只是单向输出,而是一个相互学习的过程。

别怕写得不够好。

写的不好,最坏的结果是你默默修正了认知。

而最好的结果是什么?

你收获的可能不只是两个更好的方案,而是更多人的智慧增量,以及下次能写出更"抗打"文章的底气。

甚至,进一步来说,抛开技术层面,大胆写下你的任何思考,哪怕它带着毛刺和缝隙。

因为正是这些缝隙,让光得以照进,让其他思想得以注入。

一个人类的思想与另外一个或者一群人的思想进行碰撞,才会产生智慧的花火。