robfig/cron/v3在K8s中重复执行,因其仅做本地时间解析和goroutine调度,不感知其他Pod进程,多实例各自解析相同cron表达式并独立触发;正确做法是分离触发与执行,用etcd或Redis分布式锁(Lua原子校验+唯一标识+保守TTL)确保仅一节点执行。

多节点任务抢占不是“加个锁就完事”,而是必须明确「谁触发」「谁争锁」「谁执行」「谁清理」四个角色,否则必然双跑或漏跑。
为什么 robfig/cron/v3 一上 K8s 就重复执行
它只做本地时间解析和 goroutine 调度,不感知其他进程。K8s 启多个 Pod,每个都独立解析 cron.ParseStandard("0 0 * * *"),然后各自 cron.AddFunc,结果就是每天零点所有节点同时调用业务函数。
常见错误写法:
cr := cron.New()
cr.AddFunc("@daily", doBackup) // ❌ 直接写业务逻辑
cr.Start()正确做法是让它只负责「触发调度请求」:
立即学习“go语言免费学习笔记(深入)”;
- 把
doBackup拆成tryAcquireAndRun("backup_daily") -
tryAcquireAndRun内部用 etcd lease 或 Redis SETNX 抢锁 - 抢到才真正执行
doBackup,没抢到直接 return
Redis 抢占式锁必须用 Lua 校验 value
直接 DEL key 是高危操作——任务执行超时、GC 暂停、网络延迟都可能导致你删掉别人刚续上的锁。
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
必须用原子 Lua 脚本释放:
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end客户端调用时传入的 ARGV[1] 必须是当前节点唯一标识,比如 os.Getenv("HOSTNAME") + "-" + strconv.Itoa(os.Getpid()),不能是固定字符串或时间戳。
TTL 设置要保守:设为任务最长执行时间的 2–3 倍。例如任务通常 20s 完成,TTL 至少设 60s。
别让所有节点都轮询抢锁
每秒都 SET task:send_report NX EX 60,既是 Redis QPS 浪费,又容易触发连接打满或频控熔断。
推荐「中心触发 + 多节点竞争」模型:
- 单独部署一个轻量调度器(哪怕只是个常驻 Go 进程),按 cron 表达式算出下次执行时间戳
- 用
LPUSH task:queue "send_report:20260525080000"推送带时间戳的任务 ID - 所有 worker 节点监听
BRPOP task:queue 5,拿到后立刻对lock:send_report:20260525080000抢锁 - 避免用 Pub/Sub:
redis.PubSub不自动重连,Redis 重启后连接永久失效
真正的难点不在锁怎么写,而在任务语义是否清晰:这个任务成功与否,由谁判定?失败后要不要回滚上游状态?锁释放时机和任务完成状态更新必须严格对齐,否则会出现“锁已删但任务还在跑”或“任务已成功但锁没释放”的撕裂状态。

















