50 万个定时器,四种实现差了 115 倍
每个 TCP 连接有超时定时器,每个 RPC 请求有超时定时器,每个心跳有定时器。一个中等规模的服务,同时存活几十万个定时器是常态。
四种实现,插入 50 万个(到期时间随机分布在 60 秒内):
markdown
实现 插入耗时 ns/个 相对最小堆
-----------------------------------------------------
分层时间轮 (3 层) 126 ms 252 0.79x
最小堆 (heapq) 160 ms 320 1.00x
单层时间轮 471 ms 942 2.95x
有序列表 (插入排序) 18419 ms 36838 115.24x
然后把时钟推进到 60 秒,让全部定时器到期:
markdown
实现 推进耗时 相对最小堆
--------------------------------------------
有序列表 101 ms 0.06x
分层时间轮 211 ms 0.12x
单层时间轮 278 ms 0.16x
最小堆 1729 ms 1.00x
两张表的排名几乎是反过来的。
各自的强项和软肋
最小堆:插入 O(log n),取最早 O(log n)。
插入 160 ms 很好看,但推进时要做 50 万次 pop,每次都是 O(log n) 的下沉调整 ------ 1729 ms,四种里最慢。
它的另一个问题是取消。标准的堆没法高效删除中间元素,所以实践中都用「懒删除」:先记一个墓碑,等它浮到堆顶时再丢掉。
有序列表 :插入要找位置 + 搬数据,O(n)。50 万次插入 18.4 秒,完全不可用。
但它的推进是四种里最快的(101 ms)------ 因为到期的元素就在数组头部,一次切片全删掉,连续内存操作。
单层时间轮:一个环形数组,每个槽挂一条链表。插入是取模 + 追加,O(1)。
但我的轮子只有 65536 个槽(约 65 秒),到期时间超出一圈的要记一个「还要转几圈」的计数 ------ 每转一圈都要把整圈元素检查一遍,这就是它插入 471 ms 的来源(大量元素带 rounds 标记)。
分层时间轮:毫秒轮 1000 格、秒轮 60 格、分钟轮 60 格。
远期定时器先放在粗粒度的轮子上,时间临近时降级 到细粒度轮子。插入 126 ms(比最小堆还快,因为是纯 O(1) 追加),推进 211 ms。
两头都好,这就是为什么工业界的高并发定时器几乎都是它。
真实场景:95% 的定时器会被取消
超时定时器的生命周期是这样的:设置一个 30 秒超时 → 请求 50 毫秒就回来了 → 取消它。
绝大多数定时器根本等不到到期。
scss
50 万个定时器,95% 在到期前被取消
最小堆 + 懒删除 2172 ms
时间轮 + O(1) 删除 768 ms 快 2.83 倍
差距的来源是取消的代价:
- 堆的懒删除 :取消是 O(1)(往集合里塞个 id),但那 47.5 万个死掉的定时器依然躺在堆里 。它们继续参与每一次
pop的 O(log n) 调整,直到浮上堆顶才被清掉。你为已经取消的东西付了全程的维护费。 - 时间轮 :每个定时器用一个双向链表节点,句柄直接指向它。取消 = 从链表里摘掉,真正的 O(1) ,而且立刻释放。
选型的关键问题不是「有多少定时器」,是「它们是到期的多还是被取消的多」。
时间轮的代价
时间轮不是免费的午餐:
① 精度由槽的粒度决定。 1 毫秒一格就只能做到毫秒精度,想要微秒精度要么把格数乘 1000,要么接受误差。
② 它要 tick。 时钟每走一格就要检查一次,哪怕一个定时器都没有。我测了空转成本:
yaml
轮子大小 空推进 60 秒耗时
1024 5 ms
8192 5 ms
65536 5 ms
5 毫秒走完 6 万个 tick,成本和轮子大小、定时器数量都无关 ------ 这正是时间轮「O(1) 均摊」的体现。
但在真实系统里,问题不是这 5 毫秒的 CPU,而是每个 tick 都要唤醒一个线程。一个闲置的服务每毫秒醒一次,对功耗和调度器都不友好。
Kafka 的解法很漂亮:时间轮 + DelayQueue 。轮子负责 O(1) 的增删,而 DelayQueue 里只放非空的槽------线程直接睡到下一个有任务的槽,中间的空槽一次性跳过。既保留了 O(1),又消除了空转。
③ 内存正比于槽数,而不是定时器数。空轮子也要占着那片数组。
工业界怎么选
| 系统 | 实现 | 为什么 |
|---|---|---|
| Linux 内核 | 分层时间轮(级联) | 海量定时器,绝大多数被取消 |
| Netty | HashedWheelTimer |
同上,连接级超时 |
| Kafka | 分层时间轮 + DelayQueue | 延迟任务多,且要消除空转 |
| Go runtime | 每个 P 一个四叉最小堆 | 定时器数量适中,和调度器深度集成 |
| Nginx | 红黑树 | 定时器不多,但要精确有序 |
没有绝对最优,取决于三个问题:
- 定时器数量级是几千还是几百万?
- 取消的比例高不高?
- 精度要求是毫秒还是微秒?
几千个定时器、要求精确 ------ 用堆或红黑树,简单可靠。
几十万个超时定时器、95% 会被取消 ------ 时间轮,没有悬念。
最后
定时器是那种「看起来是个小组件」的东西------无非是「到点了叫我一声」。
但当它的数量级到了百万,它就变成了整个系统里最高频的数据结构操作:每个连接建立时插一个,每个包到达时取消一个再插一个。
50 万和 115 倍,就是这么来的。