必须用 Redis 实现执行权仲裁:通过 SET key value EX seconds NX 抢占锁,value 为全局唯一标识,TTL 设为任务最长执行时间的 2–3 倍;删锁须用 Lua 脚本校验 value,避免误删;推荐中心触发 + 多节点竞争模型,配合幂等性设计与状态条件更新。

直接用 time.Ticker 或 github.com/robfig/cron/v3 启动多实例,任务必然重复执行——这不是精度问题,是语义错误。必须加一层“执行权仲裁”,而 Redis 是最轻量、可控、无需额外组件的落地选择。
用 SET key value EX seconds NX 做抢占式锁,别碰 GETSET
所有节点在触发前统一争一个带 TTL 的锁 key,抢到才执行。关键不是“谁先注册”,而是“谁在窗口内持有有效锁”。
-
value必须是全局唯一标识,比如os.Getenv("HOSTNAME") + "-" + strconv.Itoa(os.Getpid()),不能是固定字符串或时间戳 -
EX seconds要 ≥ 任务最长执行时间的 2–3 倍(如任务最长 40s,TTL 至少设 120s),太短会导致锁提前过期、并发进入 - 绝对不要用
GETSET模拟原子锁:它无法区分“锁已存在”和“锁已过期但未被清理”,极易引发误判 - Go 客户端推荐用
github.com/redis/go-redis/v9的SetNX方法,但注意它不支持原子设置 EX+NX+value,得用Do直接调原生命令
删锁必须用 Lua 脚本校验 value,DEL 是高危操作
直接 DEL key 会误删别人持有的锁,尤其在任务执行时间波动、网络延迟或 GC 暂停时风险极高。
- 释放锁必须走 Lua:
if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end - 脚本执行是原子的,能 100% 避免“读-判-删”三步竞态
- 客户端调用时传入 key 和当前节点的
value,返回 1 表示删除成功,0 表示锁已被他人持有或已过期 - 别在 defer 里无条件删锁——任务 panic 或 context 超时退出时,可能根本没拿到锁,删了别人的
避免所有节点都跑 cron,改用“中心触发 + 多节点竞争”模型
让每个节点每秒都去 Redis 抢锁,既是资源浪费,也放大了锁竞争压力,还容易触发 Redis 频控或连接打满。
立即学习“go语言免费学习笔记(深入)”;
- 只部署一个轻量级调度器(哪怕只是个固定 IP 的 Go 进程),按 cron 表达式计算下次执行时间戳,用
LPUSH task:queue推送任务 ID 到 List - 所有工作节点监听
BRPOP task:queue 5,拿到任务后立即抢锁(key 用task:send_report:20260513073000这种带计划时间戳的格式) - 不用 Pub/Sub:go-redis/v9 的
*redis.PubSub不自动重连,Redis 重启后连接永久失效,得自己封装重试+重建逻辑,复杂度陡增 - 任务体建议用 JSON 字符串,字段至少含
id、handler、timeout,别把业务参数硬编码进 key 名
锁只是第一道防线,幂等性设计不能省
分布式环境下,锁丢失、网络分区、进程崩溃都可能导致任务被重复推送或执行。锁保证“同一时刻最多一个”,但不保证“一生只执行一次”。
- 每个任务执行前查数据库或 Redis 记录是否已成功标记(如
task:send_report:20260513073000:done),有则直接跳过 - 任务状态更新必须带条件:比如
UPDATE jobs SET status='done' WHERE id=? AND status='pending',靠影响行数判断是否真正处理 - 不要依赖“锁没抢到就万事大吉”——要记录每次抢锁失败的日志,监控异常频次,否则故障时完全无感知
- 如果任务本身不可重入(如扣款),锁 + 幂等键(如订单号+任务类型+时间窗口)双保险才是生产可用底线
真正难的不是写对那几行 SET 和 EVAL,而是想清楚“谁负责触发”“锁什么时候设”“任务失败后由谁重试”“时间戳用本地还是 NTP 同步”。这些决策点一旦定错,压测时看不出问题,上线后凌晨两点开始告警风暴。


















