
为什么 time.Ticker 在万级定时任务下会吃光内存
因为每个 time.Ticker 实例背后都持有一个独立的 goroutine + channel + timer 结构,不是轻量对象。10,000 个 time.NewTicker(1 * time.Second),实际会创建上万个活跃 goroutine 和等量的 runtime.timer 节点,GC 压力陡增,内存占用线性上涨——这不是泄漏,是设计使然。
常见错误现象:runtime: out of memory、pprof 显示大量 runtime.timer 占用 heap、goroutine 数稳定在数万级别且不下降。
- 适用场景:需要为大量连接/设备/租户各自维护一个周期性动作(如心跳检测、状态刷新、过期清理),但周期未必完全一致
- 别硬套
time.Ticker:它适合“全局统一节奏”,不适合“千人千频” - 兼容性无问题:标准库无依赖,纯 Go 实现即可替代
用 hashicorp/go-timerwheel 替代时要注意的三件事
这个库是目前最贴近生产需求的时间轮实现,但默认配置和误用方式极易翻车。
- 轮子大小(
tick和size)必须按最大容忍延迟反推:比如业务允许 ±200ms 偏差,就设tick = 100 * time.Millisecond,size = 64或128;设太大浪费内存,太小导致槽位冲突、插入退化为链表遍历 -
TimerWheel.ScheduleFunc()返回的*timerwheel.Timer必须显式.Stop(),否则底层 slot 槽位不会释放,长期运行后内存只增不减 - 不要在
func回调里做同步阻塞操作(如 HTTP 调用、数据库查询):时间轮调度线程是单 goroutine,卡住会导致后续所有定时器集体延迟
自己手写简易分层时间轮的关键取舍点
如果不想引入第三方、又不愿被 go-timerwheel 的泛型或生命周期管理绊住,可以写一个两级轮(毫秒级粗粒度 + 微秒级细粒度),但必须守住三条线:
立即学习“go语言免费学习笔记(深入)”;
- 第一层轮(如 64 槽 × 50ms)只存「未来 3.2 秒内」的任务;超出的扔进第二层(用
heap.Interface维护最小堆),避免单轮过大 - 所有定时器对象必须复用:用
sync.Pool管理timerEntry结构体,禁止每次new - 停止逻辑要能穿透两层:
Stop()不仅从当前槽移除,还要检查是否在堆里,否则残留任务会在某次 tick 后诡异触发
示例关键判断:if t.nextAt.Before(time.Now().Add(wheel.tick)) { heap.Push(&secondLayer, t) }
调试时怎么确认时间轮真正在省内存
不能只看 RSS,得看 runtime 底层指标和分配模式。
- 用
runtime.ReadMemStats对比:重点关注Mallocs(每秒新分配对象数)和NumGoroutine—— 正常应稳定在个位数,而非随定时器数量增长 - pprof 查
go tool pprof http://localhost:6060/debug/pprof/heap,过滤timerwheel或你自定义的结构体名,确认没有重复出现的 slice 或 map 实例 - 加一行日志:
log.Printf("wheel slots used: %d / %d", wheel.usedSlots(), wheel.size),如果长期 > 80%,说明size设小了或任务分布太集中
真正难的是任务动态增删下的槽位碎片——轮子不会自动压缩,得靠定期 rehash 或预估峰值来预留余量










