直接用 robfig/cron/v3 启动多实例会重复执行,因各进程独立解析 cron 表达式并触发,无跨节点协调能力;redis 锁加在执行体里属语义错误,无法解决“谁该触发”问题。

为什么直接用 github.com/robfig/cron/v3 启动多实例会重复执行
不是精度问题,是语义错误。每个 Go 进程都独立解析 * * * * *,各自触发自己的 goroutine —— 10 台机器就是 10 次调用,Redis 锁加在执行体里也晚了。锁只能防“并发进入”,不能替你决定“谁该触发”。
- robfig/cron 和 gocron 都无跨节点协调能力,
WithDistributedLocker是空接口,不带实现 - 强行在
cron.AddFunc里套SETNX,等于把锁逻辑塞进每秒轮询,放大 Redis 压力、增加竞争抖动 - 任务延迟不可控:cron 触发后才去抢锁,若锁被占,该次调度就丢弃,无法重试或排队
SET key value EX seconds NX 必须一次性发,别拆成 SETNX + EXPIRE
老写法 rdb.SetNX(ctx, k, v, 0).Err(); rdb.Expire(ctx, k, ttl) 有致命窗口:进程在两步之间崩溃,key 永久存在,锁彻底死锁。Redis 2.6.12+ 支持原子 SET 命令,Go 客户端 github.com/redis/go-redis/v9 的 Set 方法可封装它。
- 用
client.Set(ctx, key, value, redis.SetArgs{Mode: redis.SetNX, Expire: ttl}),底层发的是单条SET key value NX EX 300 -
value必须是全局唯一标识,如os.Getenv("HOSTNAME") + "-" + strconv.Itoa(os.Getpid()),不能是固定字符串"1"或时间戳 -
EX seconds至少设为任务最长执行时间的 2–3 倍(例如 P99=45s → 设 120s),太短会导致正常执行中锁过期、其他节点闯入
删锁必须走 Lua 脚本,DEL 是高危操作
直接 rdb.Del(ctx, key) 会误删别人刚拿到的锁。典型场景:A 执行慢,B 等超时后抢到锁,A 结束时无脑 DEL,把 B 的锁干掉了。只有 Lua 脚本能保证「读值→比对→删」三步原子执行。
Go 配置库,使用 spf13/viper — 分层优先级(flag > env >file > KV > default),提供 BindPFlag/BindPFlags、SetEnvPrefix + SetEnvKeyReplace 等功能。
- 脚本内容固定:
if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end - Go 中调用:
script := redis.NewScript(luaStr); script.Run(ctx, client, []string{key}, value) - 返回
1表示删除成功,0表示锁已易主或已过期 —— 别忽略返回值,更别在defer里无条件删锁(panic 时可能根本没拿到锁)
推荐「中心触发 + 多节点竞争」模型,别让所有节点跑 cron
让全部工作节点每秒都去 Redis 抢锁,既浪费连接又抬高延迟,还容易触发 Redis 频控。真正可控的做法是解耦触发与执行:一个轻量调度器负责准时推送任务 ID,所有节点监听队列、按需争锁。
- 调度器用
robfig/cron定时rdb.LPUSH(ctx, "tasks:trigger", "send_report_20260527") - 工作节点用
rdb.BRPOP(ctx, "tasks:trigger", 5)监听(超时别设太长,避免卡死) - 拿到任务 ID 后,再以该 ID 拼出锁 key(如
lock:send_report_20260527),调SET ... NX EX抢锁 - 任务必须幂等:哪怕因网络抖动导致同一任务被多个节点抢到(极小概率),结果也不能错
锁续期(renew)和崩溃恢复不在本文范围,但得提一句:如果任务执行时间波动极大,TTL 不足以覆盖 P999,就必须引入 watchdog goroutine 定期用 Lua 续期 —— 否则光靠 EX 就是埋雷。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










