time.Ticker不能直接用作任务调度器,因其不处理任务执行状态,易导致panic丢任务、执行堆积、无法取消和时钟漂移漏触发;应使用container/heap构建优先队列,配合单timer、worker pool、context控制与execID幂等机制实现可靠内存调度。
直接用 time.ticker 或裸 go f() 实现基于内存的任务调度,90% 的场景会在线上崩——不是功能写不出来,而是 panic 丢任务、执行堆积、无法取消、时钟漂移漏触发这四类问题几乎必现。
别把 time.Ticker 当调度器用
time.Ticker 只发信号,不管任务是否跑完。一旦某个任务执行耗时超过间隔,后续 tick 就会被积压或并发触发:
- 任务执行 8 秒,
time.NewTicker(5 * time.Second)会连续触发两次(第 5 秒和第 10 秒),导致两个实例同时运行 - 任务里 panic 未 recover → 整个 ticker goroutine 静默退出,后续所有任务全丢
- 系统 NTP 校正时,
time.Now().Hour() == 9这类判断可能跳过或重复执行一次 - 没有 ID、无法按名称停止某项任务,也不能临时暂停或动态改间隔
若真要用,必须加状态锁:sync.Mutex 或 atomic.Bool 确保单次执行中不响应新 tick;每次启动前确认 ticker.Stop(),否则 goroutine 泄漏。
用 container/heap 实现带优先级的内存队列
标准库 container/heap 是唯一无需外部依赖、可控性强的优先队列方案。自己排序 slice 或引入第三方库,反而增加不确定性。
- 任务结构体必须含
Priority int(数值越小优先级越高)、NextRunAt time.Time、ID string -
Less方法先比Priority,再比NextRunAt,避免高优任务被低优任务饿死 - 主循环用单个
*time.Timer指向堆顶任务:每次变更后先timer.Stop()再timer.Reset(),否则会 panic 或泄漏 - 不要用
time.Now().UnixNano()做超时判断——机器间时钟偏差会导致误判,改用单调递增序列号或本地逻辑时钟
执行器必须带 worker pool + context + recover
每个任务不能裸起 goroutine,否则 panic 杀进程、无并发控制、无法统一限流。
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
立即学习“go语言免费学习笔记(深入)”;
- 固定数量 worker(如 4–8 个)从 channel 拿任务,channel 缓冲建议设为 100,防生产者阻塞
- 每个 worker 必须包
defer func() { recover() }(),捕获 handler panic 并转为可重试错误 - 任务函数签名强制为
func(context.Context, map[string]interface{}) error,确保能传入取消信号与参数 - 执行中必须定期检查
ctx.Err() != nil,及时退出;禁止在Run()里起长期 goroutine 而不绑定 ctx,否则节点重启后变成幽灵进程
任务注册要零侵入,但必须带幂等控制
别要求用户实现 interface 或继承 base struct。靠反射识别函数签名即可注册,但幂等性必须由调度器兜底。
- 任何函数只要满足
func(context.Context, map[string]interface{}) error就能注册,调度器自动提取函数名作jobType - 每个任务带唯一
jobID和execID(形如"job-abc123-exec-456"),worker 执行前查本地execMap map[string]bool,命中则返回ALREADY_EXECUTED - 网络抖动会让 leader 重复下发同一
jobID,仅靠jobID不够,必须靠execID区分不同执行轮次 - 禁止在任务函数里做全局状态修改(如直接改 package var),所有状态应通过参数传入或由上下文管理
最易被忽略的是:任务定义看似简单,但执行上下文(context.Context)、幂等边界(execID 而非 jobID)、以及 timer 生命周期管理(Stop() + Reset() 成对)这三点,任意一个没对齐,就不是“基于内存”的稳定调度,只是个玩具 demo。

















