HashedWheelTimer 默认配置无法支撑百万级任务,因其专为中低频、短延时场景设计;需按业务延时分布合理设置 tickDuration 和 ticksPerWheel,并避免注册即取消、阻塞执行等误用。

HashedWheelTimer 为什么扛不住百万级任务
直接用 Netty 的 HashedWheelTimer 默认配置撑百万任务,大概率会在 5–10 万任务量时就开始掉链子——不是功能失效,而是精度漂移、延迟堆积、甚至 Timeout 对象泄漏。根本原因不在算法本身,而在参数失配和使用方式错位。
常见错误现象包括:
- 添加 10 万任务后,第 99999 个任务实际延迟比设定值多出 200ms+(非线性误差累积)
-
timeout.cancel()后内存不释放,TimerTaskEntry仍挂在某个TimerTaskList链表里 - 高并发注册任务时出现
java.lang.IllegalStateException: Timer is already stopped
核心问题在于:Netty 的 HashedWheelTimer 是为「中低频、短延时、强实时」场景设计的(比如连接空闲检测),不是开箱即用的百万级延迟队列。
tickDuration 和 ticksPerWheel 怎么设才不翻车
这两个参数决定时间轮的分辨率和总跨度,必须按业务延时分布反推,不能拍脑袋填。例如你 95% 的任务延时在 1s–300s 之间,那 tickDuration=50(毫秒)、ticksPerWheel=4096 就比默认的 tickDuration=100、ticksPerWheel=512 更合理。
关键判断逻辑:
-
tickDuration应 ≤ 你最敏感延时任务允许的最大误差(如支付超时容忍 ±100ms,则设为 50 或 100) -
ticksPerWheel要 ≥ 常见最大延时 ÷tickDuration,再向上取 2 的幂(Netty 内部用位运算做模运算,非 2 的幂会降级为取余,慢一个数量级) - 总跨度
interval = tickDuration × ticksPerWheel必须覆盖你 80% 以上任务的延时;超出部分会被降级到上层轮或拒绝,不是自动扩容
错误示例:new HashedWheelTimer(…, 1, TimeUnit.SECONDS, 60) 看似像钟表,但 1 秒一格 + 60 格 → 只能精确调度 60 秒内任务,超时任务直接丢弃或阻塞主线程。
取消任务时为什么 TimerTaskEntry 还在链表里
这是 Netty 时间轮最隐蔽的坑:timeout.cancel() 只是标记取消,并不立即从链表中物理移除。真正清理发生在下一次 tick 到该槽位时,由工作线程执行 TimerTaskList.expireTimeouts()。如果任务刚注册就取消,而指针还没转到对应槽位,它就一直挂着。
后果很实在:
- 大量短命任务(如秒杀预占锁后立刻释放)会导致
TimerTaskList链表持续膨胀 -
TimerTask引用未断,GC 不掉关联对象(如ChannelHandlerContext),引发内存泄漏
正确做法:
- 业务侧避免「注册即取消」模式;高频创建/取消场景改用状态机 + volatile 标志位控制逻辑执行
- 必须支持即时取消时,继承
TimerTask并在run()开头加if (cancelled) return;双重检查 - 监控
HashedWheelTimer.pendingTimeouts()值,持续 > 1000 说明取消积压严重,需调大ticksPerWheel或拆分时间轮实例
单实例撑百万?别信,得靠分片 + 降级
Netty 的 HashedWheelTimer 单实例实测极限约 30–50 万活跃任务(延时集中在 1–60s,JDK17+,堆外内存充足)。想稳住百万级,必须放弃「一个轮子转所有」的幻想。
可行路径只有两条:
-
水平分片:按业务维度(如用户 ID 取模)分配到多个
HashedWheelTimer实例,每个实例负载压到 10 万以下;注意避免不同实例间任务依赖(如跨分片回调) - 分层降级:短延时(10s)写入 Redis Sorted Set 或 Kafka 延迟 Topic,由独立消费者拉取触发 —— 这才是 Kafka 和主流中间件的真实做法
最容易被忽略的一点:时间轮只解决「调度触发」,不解决「任务执行」。一旦 TimerTask.run() 里有阻塞 IO 或慢 SQL,整个时间轮线程就卡死。务必把耗时操作扔进业务线程池,run() 方法本身应控制在 1ms 内返回。

















