cron/v3在k8s多实例下必然重复执行,因其无分布式协调能力;正确做法是仅用它作本地定时器触发抢锁函数,由etcd lease+watch实现任务级分布式互斥。

直接用 time.Ticker、gocron.NewScheduler() 或 cron/v3 启动定时任务,在多实例(尤其是 K8s)环境下必然重复执行——它们不协调、不选主、不仲裁,只是各自在本地倒计时。
为什么 cron/v3 一上 K8s 就多跑
每个 Pod 都会独立调用 cron.AddFunc("0 * * * *", sendReport),各自解析表达式、维护 timer heap、到点就触发。不是“高可用”,是“高重复”。日志里 send_report executed 出现 N 次/小时,数据库写冲突、第三方接口限流,都是典型症状。
- 错误认知:以为加个 Redis 锁或 DB 唯一索引就能兜底 → 实际上锁前业务逻辑可能已开始执行,资源浪费和误触发已发生
- 硬伤限制:
cron/v3不感知其他节点,也不提供分布式语义;WithDistributedLocker接口只是空方法,不实现锁,也不参与调度决策 - 正确姿势:把它当“本地闹钟”用,只负责准时调用一个轻量函数,比如
cron.AddFunc("@every 1m", func() { tryAcquireAndRun("send_daily_report") })
etcd lease + watch 抢执行权的实操要点
核心不是存任务,而是让所有节点就「谁来跑」达成瞬时共识。用 clientv3.Grant 创建带 TTL 的租约,再通过 OpPut 绑定到指定 key,抢到才真正执行。
- TTL 设为任务预期耗时的 2–3 倍(如任务通常 15s 完成,lease TTL 设 45s),避免网络抖动导致频繁漂移或假死
- 抢占必须用事务:
txn.If(txn.Version(), "=", 0).Then(OpPut(...)),否则多个节点同时 Put 会覆盖彼此 - 续租要起独立 goroutine,并监听
lease.KeepAlive返回 channel 关闭信号——关闭说明 lease 被 revoke,必须立即停止当前任务 -
watch必须带clientv3.WithPrevKV(),否则拿不到旧值,无法区分“是自己删的”还是“被别人抢走的”
task-specific lease 比全局 leader 更可靠
别搞 /scheduler/leader 这种单点 leader 模式。它容易成为瓶颈,且一旦 leader 挂了,所有任务都得等它恢复或漂移。更稳的做法是每个任务独占一个 lease key,比如 /scheduler/jobs/send_daily_report。
- 优势:故障隔离——A 任务 lease 失效不影响 B 任务;扩缩容无感知;无需中心调度器转发
- 执行前必须重新
Grant新 lease,不能复用旧 lease ID——旧 lease 过期后 key 自动删除,但别的节点可能还没抢到,造成空窗 - 锁值不要写
time.Now().Unix(),各节点时钟偏移会导致 key 计算不一致;改用 lease ID 或 etcd revision - 业务逻辑严禁写在
Do()或AddFunc回调里——必须等抢锁成功后再调doSendDailyReport(),否则 tick 周期会被拖慢甚至卡死
最容易被忽略的是:抢锁不是启动时做一次就完事,而是每次触发都要重试;续租 goroutine 必须响应 ctx.Done() 主动 Revoke;watch 缺少 WithPrevKV 就等于盲跑——你根本不知道 key 是怎么没的。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











