50 万个定时器,四种实现差了 115 倍

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 红黑树 定时器不多,但要精确有序

没有绝对最优,取决于三个问题:

  1. 定时器数量级是几千还是几百万?
  2. 取消的比例高不高?
  3. 精度要求是毫秒还是微秒?

几千个定时器、要求精确 ------ 用堆或红黑树,简单可靠。

几十万个超时定时器、95% 会被取消 ------ 时间轮,没有悬念。

最后

定时器是那种「看起来是个小组件」的东西------无非是「到点了叫我一声」。

但当它的数量级到了百万,它就变成了整个系统里最高频的数据结构操作:每个连接建立时插一个,每个包到达时取消一个再插一个。

50 万和 115 倍,就是这么来的。

相关推荐
用户54817845909501 小时前
CDN 实战:如何用 4 步让网站提速 10 倍
后端
MaxHua1 小时前
达梦 DTS 迁移 MySQL 报错 - 3236:此列列表已索引 解决方案
数据库·后端
琳达爱分享1 小时前
从单机令牌桶到集群限流:一次配额超发的复盘
后端·算法
leo在掘金1 小时前
亚马逊开源TorchNeuron:改1行代码跑上Trainium
后端·架构
青岛中阳消防1 小时前
分布式光纤火灾探测器安装常见问题、光缆敷设与光纤熔接解析
后端
弈栈录1 小时前
向量化、召回与重排序服务的实现
后端·面试·架构
智驭未来掌门人1 小时前
雷达架构梳理:那些容易混淆的边界与分层
后端
琳达爱分享1 小时前
四种限流算法实测对比:固定窗口、滑动窗口、漏桶、令牌桶(附 Redis+Lua 实现)
后端·架构