← 首页 / 时间轮 Timing Wheel 待审核
single · hierarchical · ring buffer

时间轮 Timing Wheel

管理成千上万个定时器,朴素做法(有序链表 / 最小堆)的添加与删除是 O(n)O(n)O(logn)O(\log n)时间轮 (timing wheel) 把这两个操作压到 O(1)O(1):一个环形数组,每格 (slot) 代表一个时间刻度 tick、挂一条到期定时器的链表 (bucket),一根指针随真实时间每 tick 前进一格,只处理当前格——结构与 clock 表盘同构。

需要澄清的是:O(1)O(1) 说的是对它做添加 / 删除 / 触发这几类操作的复杂度,不是「数据结构本身」的复杂度——复杂度永远是操作的属性。时间轮借了这三个操作的 O(1)O(1) 给自己贴标签,底层机件则是环形缓冲区 (ring buffer):固定数组 + 取模回绕的指针。

下面依次看单层轮、分层轮,再对比有序链表 / 最小堆并归纳环形结构的用途。每个 demo 都能添加定时器、单步推进指针,观察 bucket 增删与代码逐行高亮。

1 · 单层时间轮:槽位、指针与 rounds

核心机件只有三样:一个环形数组,每格 (slot) 代表一个时间刻度 tick,每格挂一条 bucket(到期定时器的链表),一根指针 (pointer) 随真实时间每 tick 前进一格,到头绕回 0。添加一个延迟 delay 的定时器,只需算出落点槽位 (pointer + delay) % slots、挂进那一格——O(1)O(1),推进时指针走一格,只处理当前格里到期的定时器,不扫描其余格子。延迟超过一圈 (delayslotsdelay \ge slots) 的定时器,靠 rounds(还要绕几圈) 留在同一槽。

1.1 · delay 超过一圈:rounds 的代价

slots = 8,添加一个 delay = 20 的定时器:落点槽位 (pointer + 20) % 8,而 rounds=(201)/8=2rounds = \lfloor (20-1) / 8\rfloor = 2——指针要两次经过该槽(每次把 rounds 减一)才在第三次触发。这让单层轮能表示任意远的延迟,但代价藏在 tick 里:每次推进都要遍历当前整槽给计数减一,于是触发不再是干净的 O(1)O(1),而退化为 O(当前槽元素数)O(当前槽元素数)

这正是单层轮的关键局限。 要表示「最长 N 个 tick」的延迟,单层轮要么 rounds 退化 tick,要么把格数铺到 N 个(绝大多数永远为空)。下面的分层时间轮用「钟表的时 / 分 / 秒三个盘」化解:远期定时器先挂粗粒度高层,临近触发才 cascade 降级到细粒度低层——几百个格子覆盖极大范围,且全程 O(1)O(1) 摊还。

删除为什么也是 O(1)O(1)? 每个 slot 的 bucket 是一条链表,持有定时器节点时直接摘除即可,与定时器总数 n 无关。本页为聚焦核心,只演示添加与触发,删除的链表摘除见链表系列。

2 · 分层时间轮:cascade 与「槽位膨胀」

单层轮要表示「最长 N 个 tick」的延迟,要么靠 rounds 退化 tick、要么把格数铺到 N 个。设精度 1ms、范围 1 天,单层就得开 86,400,000 个格子,且绝大多数永远为空。分层时间轮像钟表的时 / 分 / 秒三个盘:每升一层,一格的跨度 ×slots\times slots。远期定时器先挂粗粒度的高层轮;高层指针每推进一格,就把到期那一槽的定时器 cascade(降级) 重新分发到低层——越临近触发被搬到越细的轮,最终落到底层 L0 精确触发。

本节用 slots = 43 层演示:L0 每格 ×1\times 1(覆盖 0–3)、L1 每格 ×4\times 4(覆盖 4–15)、L2 每格 ×16\times 16(覆盖 16–63)。共 4×3=12{4 \times 3 = 12} 个格子,却覆盖 43=64{4^3 = 64} 个 tick——用层数换格数,正是进位制压缩。Kafka 的 TimingWheel 即此思路。

盯住一个远期定时器(如 delay=40):它先落在 L2;当 now 走到 16 的倍数,L2 指针跨槽,把它 cascade 降级L1;now 走到 4 的倍数时再从 L1 降到 L0;最后在 now=40L0 精确触发。一个定时器一生最多被搬运 (层数−1) 次,与定时器总数无关——摊还 O(1)O(1)。真实场景里大量定时器在触发前就被取消,连这点搬运都省了。

精度与范围解耦。 底层 L0 决定精度(最小一格多大),层数决定范围(覆盖多久)。要毫秒精度、覆盖一天,4 层 × 每层 100 格 ≈ 400 个格子即可,而非 8640 万——与朴素铺满的差距,就是写「86400000」只用 8 个数字位、而非画 8640 万个点。

3 · 对比有序表 / 最小堆,与环形结构的用途

复杂度永远是操作的属性,不是数据结构本身的。管理海量定时器,核心操作是添加、删除推进 / 触发。下面把同一批定时器分别交给有序链表最小堆时间轮三种实现,实时累计它们「添加一个定时器」消耗的基本操作数(比较 + 移动 / 交换),看出时间轮的 O(1)O(1) 添加优势所在。

多添加几个(或「连加 20 个」):有序链表的累计代价随规模线性飙升,最小堆对数增长,而时间轮每次都是常数。这就是为什么高频、短延迟、且大量提前取消的场景(如每个网络连接一个超时定时器)几乎都用时间轮——Netty、Kafka、Dubbo 皆然。注意时间轮也有代价:延迟超出范围要靠分层 cascade 搬运,且精度受 tick 粒度限制(Netty 默认 1 格 100ms,不保证精确到点)。

3.1 · 同一个机件,不同用途

时间轮底层是环形缓冲区 (ring buffer):固定数组 + 取模回绕的指针。把它当调度盘(槽位按时间寻址、装到期任务)就是时间轮;把它当滑动窗口(只留最近 N 项)就是另一大类用途。两类都建立在同一机件上。

3.2 · 环形缓冲区:内存有界、覆盖最旧

有界事件 / 日志留存

只留最近 N 条事件,满了覆盖最旧。dmesg 内核环形日志、各类 ring log、晚注册订阅者的回填快照都是它。

无锁生产者-消费者队列

一写一读的固定容量队列。LMAX Disruptor 的核心就是一个 ring buffer,支撑每秒千万级消息。

流 / 音视频缓冲 · 硬件 I/O 环

音频采集、抓包 buffer、网卡 RX/TX ring、磁盘 DMA 描述符环:数据持续流入,只关心最近一段,旧的被覆盖。

滑动窗口统计

保留最近 N 个采样算移动平均 / P99 / QPS。把每格当一个「时间片计数桶」,指针走过就清空过期桶——这与时间轮在此交汇。

3.3 · 时间轮:按未来时刻分桶,批量处理到期

连接 / 会话超时管理

Netty HashedWheelTimer 管海量 TCP 连接的 idle / 读写超时;RPC 框架(Dubbo 等)同理。

延迟队列 / 重试退避

Kafka TimingWheel 实现延迟生产 / 拉取、事务超时;「失败后 3 秒、再 30 秒重试」按下次执行时刻挂入轮中。

限流 · 缓存 TTL 批量清理

滑动窗口限流器把每格当时间片计数桶;缓存项按过期时刻分桶,指针到哪桶就批量淘汰,免去逐个扫描查 TTL。

对比:最小堆做定时器 →

事件驱动模拟、OS 定时器队列也常用最小堆按触发时间排序、每次 extract-min 取下一个该发生的事件。点开看堆的单步操作。

一句话: 同一圈格子,装事件是历史缓冲、装计数是限流器、装到期任务是定时器——结构同源,语义取决于往槽位里放什么。

相关链接

  • 优先队列 / 二叉堆 @vega/playground 定时器管理的另一主流实现:按触发时间建最小堆,O(log n) 添加 / 弹出。本页对比代价。
  • 链表 Linked List @vega/playground 每个 slot 的 bucket 就是一条链表,O(1) 摘除节点正是删除定时器的实现基础。
  • Hierarchical timing wheels wikipedia.org Varghese & Lauck 1987 的原始方案:单层、rounds、分层三种变体与各自的复杂度权衡。
  • Netty HashedWheelTimer netty.io 工业级单层时间轮:默认 1 格 = 100ms,不保证精确到点、而在下一个 tick 边界附近触发,主要管连接超时。
  • Kafka 的 TimingWheel kafka.apache.org 分层时间轮 + 按需创建高层 overflow wheel,实现延迟生产 / 拉取 / 事务超时等 DelayedOperation。