ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

时间轮定时器实战:单轮与多轮Java实现及避坑指南

时间轮定时器实战:单轮与多轮Java实现及避坑指南

简介:这是一份面向Java后端开发者的时间轮定时器学习资源,聚焦单轮与多轮两种时间轮结构的实现原理与代码落地,适合希望深入理解高效定时任务调度的中高级开发者。资源包共33个文件,以11个java源码、13个class编译文件、8个xml配置及1个iml工程文件为主,涵盖时间轮定义、任务插入与移除、轮询机制等核心模块,压缩包约41KB,结构紧凑便于直接导入IDE阅读调试。已有1976人学习下载。通过研读源码,读者可掌握槽位、指针、任务容器等关键概念,理解单轮处理短间隔任务、多轮通过跳跃调度长延迟任务的差异,并借鉴其心跳检测、超时处理、过期数据清理等典型应用场景的排错思路,为分布式系统与消息队列中的定时调度设计提供参考。

1. 时间轮定时器:为什么单轮扛不住十万任务,多轮却能

做过长连接网关或订单超时中心的同学大概率遇到过这个场景:系统里同时挂着几十万个定时任务,用ScheduledExecutorService或者PriorityQueue堆起来的定时器,插入和取消都是 O(log n),任务一多,调度线程的 CPU 就飙到单核跑满,延迟毛刺一波接一波。这时候时间轮(Timing Wheel)就是那个能把 O(log n) 压到 O(1) 的结构。它的核心思路很朴素:把时间切成一个个槽位(slot),任务按到期时间散列到对应槽里,指针每秒走一格,走到哪格就执行哪格的任务。单轮时间轮适合跨度小、精度高的场景,比如 1 秒到 1 分钟的心跳检测;而多轮时间轮(分层时间轮)通过多个不同粒度的轮子级联,把跨度拉到小时甚至天级别,同时保持 O(1) 的插入和删除。这篇文章不讲论文,只讲怎么用 Java 把这两种轮子写出来、参数怎么调、哪里会翻车。

2. 单轮时间轮:从槽位数组到最小可跑实现

2.1 为什么是数组加链表,而不是优先队列

单轮时间轮的本质是一个固定长度的环形数组,每个元素是一个槽位,槽位里挂一条任务链表。假设轮子有 60 个槽,每格代表 1 秒,那么整个轮子覆盖的时间跨度就是 60 秒。一个任务延迟 30 秒,就放到(currentIndex + 30) % 60这个槽里;延迟 90 秒,放到(currentIndex + 90) % 60,但需要额外记录它绕了几圈。

和优先队列相比,时间轮的插入是 O(1)——直接算下标、挂链表,没有堆调整。删除也是 O(1)——如果任务节点持有前后指针,直接从链表摘除。代价是内存换时间:60 个槽的数组本身很小,但每个槽里的链表节点有对象开销。对于十万级任务,这个开销完全可接受。

选型上,如果任务跨度不超过轮子覆盖范围,且精度要求是秒级或百毫秒级,单轮就够了。常见做法是把 tickDuration 设为 100ms 或 1s,槽位数设为跨度除以 tickDuration。

2.2 用 Java 写一个单轮时间轮的最小版本

下面是一个能跑的单轮实现,核心是addTask和advanceClock两个方法。

import java.util.*; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; public class SingleWheelTimer { private final int tickMs; // 每格毫秒数 private final int wheelSize; // 槽位数 private final long startTime; // 启动时刻 private final List<LinkedList<TimerTask>> slots; private final AtomicInteger currentIndex = new AtomicInteger(0); private final ExecutorService executor; static class TimerTask { long delayMs; // 剩余延迟 Runnable job; TimerTask prev, next; TimerTask(long delayMs, Runnable job) { this.delayMs = delayMs; this.job = job; } } public SingleWheelTimer(int tickMs, int wheelSize, ExecutorService executor) { this.tickMs = tickMs; this.wheelSize = wheelSize; this.startTime = System.currentTimeMillis(); this.executor = executor; this.slots = new ArrayList<>(wheelSize); for (int i = 0; i < wheelSize; i++) { slots.add(new LinkedList<>()); } } public void addTask(long delayMs, Runnable job) { if (delayMs < 0) delayMs = 0; // 计算需要走的格数 int ticks = (int) (delayMs / tickMs); int index = (currentIndex.get() + ticks) % wheelSize; TimerTask task = new TimerTask(delayMs, job); synchronized (slots.get(index)) { slots.get(index).add(task); } } // 由外部定时器每 tickMs 调用一次 public void advanceClock() { int idx = currentIndex.getAndUpdate(i -> (i + 1) % wheelSize); LinkedList<TimerTask> slot = slots.get(idx); List<TimerTask> ready = new ArrayList<>(); synchronized (slot) { Iterator<TimerTask> it = slot.iterator(); while (it.hasNext()) { TimerTask t = it.next(); t.delayMs -= tickMs; if (t.delayMs <= 0) { ready.add(t); it.remove(); } } } for (TimerTask t : ready) { executor.submit(t.job); } } }

逻辑说明:addTask把延迟换算成格数,取模得到槽位下标,直接挂到链表尾部。advanceClock每次把当前指针前移一格,遍历该槽所有任务,把剩余延迟减去一个 tick,到期的任务丢给线程池执行,没到期的留在槽里等下一圈。

参数说明:tickMs决定精度,设成 100 意味着任务实际触发时间最多比预期晚 100ms。wheelSize决定覆盖跨度,tickMs * wheelSize就是最大可表示延迟。如果任务延迟超过这个值,上面的代码会算错下标——这是单轮时间轮最典型的翻车点,后面避坑章节会展开。

2.3 线程模型:谁在推进指针,谁在执行任务

时间轮本身不产生线程。推进指针的advanceClock通常由一个ScheduledExecutorService以tickMs为周期调用,这个线程只做指针移动和任务摘取,不做业务逻辑。摘出来的任务丢给独立的业务线程池执行,避免慢任务阻塞指针推进。

我一般会把推进线程设为单线程,业务线程池按任务类型分池。比如心跳检测一个池、订单超时一个池,互不干扰。如果所有任务混在一个池里,一个慢 SQL 就能把整个时间轮的触发延迟拖垮。

注意:advanceClock里对槽位链表的遍历和addTask的写入必须加锁,否则并发插入时链表会断。锁的粒度是单个槽位,不是整个轮子,这样不同槽位的操作互不阻塞。

3. 多轮时间轮:分层级联怎么把跨度拉到天级别

3.1 分层时间轮的核心思想:秒轮、分轮、时轮各管一段

单轮时间轮要覆盖 24 小时,如果 tickMs 是 1 秒,就需要 86400 个槽,内存浪费且遍历慢。多轮时间轮的做法是拆成多个轮子:秒轮 60 格、分轮 60 格、时轮 24 格。秒轮走完一圈(60 秒),分轮前进一格;分轮走完一圈(60 分钟),时轮前进一格。

任务插入时,先看延迟是否在秒轮覆盖范围内(小于 60 秒),是就放秒轮;否则看是否在分轮范围内(小于 60 分钟),放分轮;再否则放时轮。当秒轮指针走到某格,除了执行到期任务,还要把分轮里对应格的任务“降级”重新散列到秒轮——因为它们的到期时间已经进入秒轮可表示的区间了。

这种级联降级是多轮时间轮的精髓,也是实现上最容易出 bug 的地方。降级时机的判断、剩余延迟的重新计算、跨轮边界的处理,每一步都要仔细。

3.2 Java 实现:三个轮子加一个降级队列

下面是一个简化但可运行的多轮实现,用三个SingleWheelTimer级联。

public class HierarchicalWheelTimer { private final SingleWheelTimer secondWheel; // 60格,每格1秒 private final SingleWheelTimer minuteWheel; // 60格,每格1分钟 private final SingleWheelTimer hourWheel; // 24格,每格1小时 private final ExecutorService executor; public HierarchicalWheelTimer(ExecutorService executor) { this.executor = executor; this.secondWheel = new SingleWheelTimer(1000, 60, executor); this.minuteWheel = new SingleWheelTimer(60_000, 60, executor); this.hourWheel = new SingleWheelTimer(3_600_000, 24, executor); } public void addTask(long delayMs, Runnable job) { if (delayMs < 60_000) { secondWheel.addTask(delayMs, job); } else if (delayMs < 3_600_000) { minuteWheel.addTask(delayMs, job); } else { hourWheel.addTask(delayMs, job); } } // 每秒调用一次 public void tickSecond() { secondWheel.advanceClock(); // 秒轮每走60格,分轮走一格 if (secondWheel.getCurrentIndex() == 0) { minuteWheel.advanceClock(); // 分轮降级:把分轮当前格的任务重新散列到秒轮 minuteWheel.drainCurrentSlot(task -> { secondWheel.addTask(task.delayMs, task.job); }); } // 分轮每走60格,时轮走一格 if (minuteWheel.getCurrentIndex() == 0 && secondWheel.getCurrentIndex() == 0) { hourWheel.advanceClock(); hourWheel.drainCurrentSlot(task -> { minuteWheel.addTask(task.delayMs, task.job); }); } } }

逻辑说明:addTask按延迟大小选择轮子。tickSecond每秒推进秒轮,秒轮转满一圈时推进分轮,并把分轮当前槽的任务降级到秒轮重新散列。同理分轮转满一圈推进时轮并降级。

参数说明:每个轮子的tickMs和wheelSize决定了该层的精度和跨度。秒轮精度 1 秒、跨度 60 秒;分轮精度 1 分钟、跨度 60 分钟;时轮精度 1 小时、跨度 24 小时。整体覆盖 24 小时,插入和删除仍是 O(1)。

这里有个关键细节:降级时任务的delayMs需要重新计算。上面的简化版直接把原delayMs传进去,实际实现中应该用任务的绝对到期时间减去当前时间,否则降级后延迟会偏大。这是多轮时间轮最常见的精度问题。

3.3 降级时机的判断:别让任务在轮子里多绕一圈

降级时机的判断直接决定任务会不会被延迟执行。假设一个任务延迟 90 秒,插入时放在分轮的第 1 格(因为 90 秒 = 1 分 30 秒)。分轮指针走到第 1 格需要 1 分钟,此时任务剩余 30 秒,降级到秒轮的第 30 格,再等 30 秒执行。总延迟 90 秒,正确。

但如果降级时没有重新计算剩余时间,而是把原始 90 秒直接塞进秒轮,秒轮只有 60 格,90 秒会绕一圈变成 30 秒后执行——任务提前了 60 秒触发。这就是典型的“多绕一圈”bug。

正确做法是每个任务记录绝对到期时间expireTime,降级时用expireTime - System.currentTimeMillis()算出真实剩余延迟,再插入下一层轮子。这样无论经过多少次降级,精度都不会累积误差。

注意:多轮时间轮的降级操作发生在指针推进的同一个线程里,如果降级任务量很大,会阻塞指针推进。常见做法是把降级操作批量处理,或者把降级队列交给独立线程消费。

4. 避坑与排查:时间轮上线后最容易翻车的五个点

4.1 任务延迟超过轮子跨度,下标算错导致任务永不执行

现象:设置一个 2 小时后的任务,单轮时间轮跨度只有 60 秒,任务插入后一直不触发。

原因:addTask里ticks = delayMs / tickMs得到 7200,取模 60 后下标是 0,任务被放到了当前指针的位置,但delayMs仍然是 7200000。指针走到该槽时,delayMs -= tickMs只减了 1000,任务远未到期,留在槽里等下一圈。但下一圈指针再走到这里时,delayMs还是很大,永远减不到 0。

解决:插入前判断delayMs是否超过tickMs * wheelSize,超过就拒绝或抛异常,或者改用多轮时间轮。如果必须用单轮,需要给任务加圈数计数,每绕一圈减一轮跨度。

4.2 槽位链表并发插入断裂,任务丢失

现象:压测时偶发任务不触发,日志里没有任何异常。

原因:addTask和advanceClock同时操作同一个槽位的LinkedList,没有加锁或锁粒度不对。LinkedList不是线程安全的,并发add和remove会导致节点指针错乱,任务从链表里“消失”。

解决:对槽位的读写加synchronized,锁对象用槽位本身。或者改用ConcurrentLinkedQueue,但要注意advanceClock遍历时需要处理弱一致性迭代器的问题。我一般用synchronized加LinkedList,简单可靠。

4.3 业务任务阻塞推进线程,整个轮子停摆

现象:某个任务执行了一个耗时 10 秒的 HTTP 调用,之后所有定时任务都延迟了 10 秒。

原因:任务直接在advanceClock的调用线程里执行,没有丢给业务线程池。推进线程被阻塞,指针停走,后续所有槽位的任务都堆积。

解决:advanceClock只负责摘取到期任务,摘出来后立即executor.submit丢给业务线程池。推进线程永远只做指针移动和链表操作,不做任何业务逻辑。

4.4 多轮降级时剩余时间计算错误,任务提前或延后触发

现象:设置 2 小时后的任务,实际 1 小时后就触发了。

原因:降级时没有用绝对到期时间重新计算剩余延迟,而是直接传递了原始延迟或错误的剩余值。时轮降级到分轮时,如果剩余时间算错,任务会在错误的槽位等待。

解决:每个任务持有expireTime(绝对毫秒时间戳),降级时用expireTime - now计算剩余延迟,再插入下一层。所有轮子的推进都基于同一个时钟源。

4.5 时间轮空转消耗 CPU,槽位遍历成本被低估

现象:任务很少,但 CPU 占用率一直有 5% 到 10% 的底噪。

原因:advanceClock每秒遍历一个槽位的链表,即使槽是空的也要加锁、判断、释放。如果tickMs设得很小(比如 10ms),每秒遍历 100 次,空转开销就很明显。

解决:tickMs不要设得过小,通常 100ms 到 1s 足够。如果精度要求高,可以用“延迟队列 + 时间轮”的混合方案:时间轮负责粗粒度调度,到期任务丢进DelayQueue做精确定时。另外,空槽位可以跳过加锁,用volatile标记槽位是否为空来减少锁竞争。

5. 进阶技巧:用绝对到期时间加惰性删除,把取消做到 O(1)

时间轮最容易被忽视的操作是取消任务。如果任务节点没有持有链表前后指针,取消时需要遍历槽位链表找到它,复杂度退化成 O(n)。我一般会在任务对象里存prev和next指针,取消时直接从链表摘除,O(1) 完成。

但还有一种更省事的做法:惰性删除。取消时不真正从链表移除,只给任务打一个cancelled标记。advanceClock遍历到该任务时,检查标记,已取消就跳过并移除。这样取消操作是 O(1) 的标记写入,代价是取消的任务会占着槽位直到指针走到它。对于取消率不高的场景,这个方案足够用;如果取消率很高,还是老老实实维护双向链表。

另一个进阶点是时间轮的持久化。内存时间轮重启后任务全丢,对于订单超时这类不能丢的场景,需要在插入时把任务写到数据库或 Redis,重启后从存储里恢复。恢复时用绝对到期时间重新散列到轮子里,已经过期的任务立即执行或标记补偿。我一般会在任务表里加一个status字段,时间轮触发时先更新状态再执行,用数据库的唯一约束防止重复触发。

验证时间轮是否正确,最直接的方法是写一个对拍测试:用ScheduledExecutorService和你的时间轮同时调度同一批任务,记录每个任务的实际触发时间,比较两者的偏差。偏差应该在tickMs以内。如果偏差超过一个 tick,说明降级或剩余时间计算有问题。

最后说一个血泪教训:时间轮的tickMs和wheelSize一旦上线就不要轻易改。改tickMs意味着所有已插入任务的槽位下标全部失效,改wheelSize意味着取模基数变了。如果非要改,只能停机重建轮子,把未到期任务重新散列。我现在的习惯是启动时就把这两个参数写进配置中心,变更走审批,避免半夜被叫起来处理定时任务集体漂移。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进