先说几个核心判断:Cron 任务运行时间越长,执行延迟越明显,超过八成的根因并非算法问题,而是三个默认行为——不防任务重叠、不控制执行时长、不校准时区设置。这三点叠加起来,时间漂移就成了迟早会发生的问题。

下面逐一分解。
为什么 cron 任务会越跑越晚
根本原因不是 cron 库本身“不准”,而是它的默认设计缺少三个关键保护机制。以一个耗时 8 秒的任务配合 "*/5 * * * *"(每5分钟一次)为例,如果某次执行意外阻塞,下一次调度依然按原计划触发。此时前序任务尚未结束,新的 goroutine 又被拉起,时间戳就这样一步步累积偏移。短短几分钟后,实际执行时间和计划时间就可能相差一两分钟。
用 cron.WithChain(cron.DelayIfStillRunning()) 防止任务堆积
这是应对时间漂移最直接有效的方案。它让 cron 检测到上一个 job 尚未结束时,直接跳过本次调度,而不是让新实例并发执行。
cron.DelayIfStillRunning()的含义并非“等待它结束再执行”,而是“跳过本次”,这样才能避免雪崩效应- 务必配合
cron.Recover()使用,否则一旦出现 panic,整个链路就会失效 cron.SkipIfStillRunning()需要谨慎使用:它会直接丢弃任务,适合通知类场景;而DelayIfStillRunning更适合状态同步类任务(比如数据库刷数)- 参数可选:
cron.DefaultDelay是默认延迟阈值(10ms),你也可以传入自定义 duration 来替代
时区错位才是最大的漂移源,不要依赖 time.Local
本地开发时,设置“每天 9:00 执行”一切正常,一旦部署到容器中就会莫名其妙变成 UTC 时间——因为多数镜像没有安装 tzdata,time.LoadLocation("Asia/Shanghai") 会静默回退到 UTC,而 cron.New() 默认使用 time.Local,结果就是把错误的时区当成了正确的。
- 生产环境必须显式传入 location:
cron.New(cron.WithLocation(loc)),其中loc, _ := time.LoadLocation("Asia/Shanghai") - 绝对不要用
time.Now().Location()动态获取——在 Docker 中它大概率返回UTC,而且完全不报错 - 配置文件里保存 cron 表达式时,必须连带保存
"location": "Asia/Shanghai",不能只存"spec": "0 9 * * *" - 验证方法:启动后调用
c.Entries(),检查每个 entry 的Next字段是否符合预期(用fmt.Printf("%v", e.Next.In(loc))输出)
用 time.Ticker 做底层驱动反而更精准?
robfig/cron/v3 底层实际上依赖 time.AfterFunc 和排序数组来维护下一次触发时间。高频任务(比如秒级)配合大量 job 时,排序开销相当明显——pprof 显示 sort.Sort 在不同场景下可能占据 CPU 高峰的 30% 以上。这时候不妨自己用 time.Ticker 对齐整点,手动计算 next run time。
- 适用场景:固定周期任务(如每 30 秒)、任务数量较少时
- 关键写法:
t := time.NewTicker(time.Second * 30),然后在for range t.C里调用time.Now().Truncate(30 * time.Second).Add(30 * time.Second)计算下一个整点 - 优点:无排序、无反射、无 goroutine 泄漏风险;缺点:不支持
@daily这类语义表达式,需要自己解析 - 注意:
time.Ticker本身并不防漂移——如果某次处理耗时超过 30 秒,下一次 tick 会立刻触发,因此仍要加select { case <-time.After(30 * time.Second): ... }来控制单次执行上限
说一个容易被忽视的现实:“漂移”往往不是时间真的不准,而是你根本没意识到某个任务已经连续三次被 DelayIfStillRunning 跳过。日志里只有一行“skipped”,没有附带 entry ID 和持续时长,排查问题时只能对着监控曲线猜测。这才是最让人头疼的地方。
