Go 定时任务库对比:cron、gocron 与分布式锁选型

阿乐
阿乐 管理员 黑卡会员
发布于 2026-09-10 11:00 ·15 浏览 ·0 回复

单体服务里跑几个定时任务,`time.Ticker` 往往就够了。可一旦服务上了 K8s 多副本、任务从「每天清一次日志」变成「每分钟对账」,事情就复杂起来:任务要按 cron 表达式调度、要能动态增删、还要保证同一时刻只有一个实例在跑。这时候库的选型和锁的方案就得一起想清楚,而不是分开拍脑袋决定。

robfig/cron:最经典的那块基石

`github.com/robfig/cron`(v3)几乎是 Go 生态里 cron 表达式的默认实现,很多框架内部也是直接包一层。它最大的价值是稳定:API 十年没什么大变化,行为可预期。

c := cron.New(cron.WithSeconds()) // 默认 5 段,加了才支持秒
c.AddFunc("0 */5 * * * *", func() { syncOrders() })
c.Start()
defer c.Stop()

要注意几个坑:默认是 5 段表达式(分 时 日 月 周),带秒需要显式开 `WithSeconds()`;`AddFunc` 返回的 EntryID 才是在线删除任务的唯一凭据;`Stop()` 只是停止调度,不会等待正在执行的任务,所以退出前需要用 `cron.WithChain(cron.Recover(...))` 兜住 panic,并自己用 WaitGroup 或 context 等任务收尾。

它不解决的问题也很明确:没有任务持久化、没有执行历史、没有分布式语义。它就是「本地调度器」,简单、可靠,适合单实例任务或者你打算自己在外层加锁的场景。

go-co-op/gocron:更好用的调度层

gocron v2 是社区接手后的重写版本,走的是链式 API 路线:

s := gocron.NewScheduler(time.UTC)
_, _ = s.Every(5).Minutes().SingletonMode().
    StartAt(time.Now().Add(time.Minute)).
    Do(syncOrders)
s.StartAsync()

相比 robfig,它的优势在于调度表达式与运行时控制更友好:`Every().Day().At("10:30")` 这种链式写法可读性远超 cron 字符串;`SingletonMode()` 能从调度层面跳过上一次还没跑完的任务;任务句柄可以拿到下次执行时间,做展示和告警都方便。v2 里还引入了 `gocron.Job` 的生命周期查询(`LastRun`、`NextRun`、`IsRunning`),排查线上问题省不少事。

代价是 API 在 v1→v2 有过破坏性变更,团队升级时要注意;生态上没有 robfig 那么「到处都是」。

顺便一提,gocron 官方还有一个 `gocron/v2` + `distributed locker` 的组合(社区维护的 locker 实现),但要清楚:所有这类 locker 都只是在执行前抢一把锁,可靠性上限取决于锁本身。

核心分歧:调度 ≠ 只执行一次

单机场景下这两个库的差别只是「好不好用」。真正的分水岭在多副本部署

假设服务起了 3 个 Pod,每个 Pod 都有调度器,那么同一时刻会有 3 次触发。你有两个选择:

1. 只有 Leader 调度:用 etcd 选举或 K8s Lease 选主,非 Leader 的调度器直接不启动。适合任务集合固定的场景,缺点是主挂了切换期间任务会漏。
2. 都调度,执行前抢锁:每个副本都触发,抢到锁的那个干活。任务不会被漏掉,但要求所有任务幂等,且锁的可靠性足够。

绝大多数项目走第 2 条路,因为实现简单、故障恢复快。

分布式锁怎么选

Redis(go-redis / redsync) 是最常见的选择。要点是:

- 用 `SET key value NX PX ttl` 原子加锁,不要用 `SETNX` + `EXPIRE` 两步。
- value 用随机 UUID,释放时用 Lua 脚本比对 value 再删,否则可能删掉别人的锁。
- TTL 必须覆盖任务执行时间,长任务要么设大 TTL,要么做续期(看门狗),但续期逻辑本身会引入复杂度。
- 单节点 Redis 主从切换时锁可能丢失,Redlock 的争论(Kleppmann vs antirez)至今没有定论——结论是:不要用锁来保证正确性,只用来减少重复执行

etcd(`go.etcd.io/etcd/client/v3/concurrency`)基于 Raft,`concurrency.NewMutex` 带 Lease 和自动续约,语义比 Redis 干净得多,还顺手支持选主。代价是延迟更高、需要维护 etcd 集群。如果你的项目本来就在用 etcd 做服务发现,选它几乎零成本。

ZooKeeper 的临时顺序节点是最「教科书」的方案,但 Go 生态维护活跃度一般,新项目基本不考虑了。

落地时的几个建议

第一,任务必须幂等。不管锁多可靠,都可能出现「任务执行完了但锁还没过期,下一个周期又被触发」;也不排除网络抖动导致的重复投递。幂等是唯一能托底的东西。

第二,调度与执行解耦。别在 `Do()` 里直接跑业务,扔到带缓冲的 channel 或队列里,配合 `Singleflight` 去重、超时控制、panic recover,调度层只管「到点触发」。

第三,可观测性。任务名、开始时间、耗时、成功失败、下次执行时间,这些指标直接打到 Prometheus,出问题时你会感谢当初的自己。gocron 能直接拿到 `NextRun`,robfig 需要自己维护。

第四,优雅退出。`Stop()` 之后要等正在跑的任务结束,否则就是最常见的「Pod 被滚动更新,任务执行到一半断了」。

小结

选型可以按这个顺序判断:单实例、任务简单,直接 robfig/cron 或 `time.Ticker` 别过度设计;需要在线增删任务、可读的调度表达式、执行状态查询,选 gocron v2;多副本部署就在此基础上加一层分布式锁,Redis 够用但记住它的可靠性边界,etcd 更稳但要接受运维成本。库只是把「什么时候跑」管好,「会不会重复跑、跑挂了怎么办」永远得靠你自己的幂等设计和可观测性。

本文转载自 阿乐技术社区,原文地址:https://www.leleweb.cn/thread-205.html
转载请注明出处,版权归原作者所有。
他们都看过 2 人浏览过
阿乐不能说的秘密

全部回复 0

还没有回复,来抢沙发~