说实话,直接用 time.Ticker 或者裸 go f() 去写内存任务调度,十有八九线上会出问题——不是功能实现不了,而是 panic 丢任务、执行堆积、无法取消、时钟漂移漏触发,这四个坑几乎必踩。

别把 time.Ticker 当调度器用
time.Ticker 本质上就是个信号发生器,它只管按时间发信号,从不关心任务是否执行完毕。一旦某个任务耗时超过时间间隔,问题就来了:
- 任务执行 8 秒,但定时器设的是 5 秒,第 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 杀进程、无并发控制、无法统一限流,这些坑一个都不能踩。
- 固定数量 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。