分布式定时任务必须用原子SET NX+EX命令抢锁,value需全局唯一且TTL设为P99×2.5,删锁须用Lua校验value,触发与执行应解耦,任务逻辑须幂等并支持checkpoint续跑。

直接用 robfig/cron/v3 或 gocron 启动多个 Go 实例,任务必然重复执行——这不是配置问题,是模型错误。分布式抢占的核心不是“谁先看到时间点”,而是“谁在执行窗口内合法持有锁”。
抢锁必须原子、带唯一标识、TTL 要留足余量
所有节点在同一触发时刻竞争同一个 Redis key,但必须用单条原子命令完成:写入值 + 设置过期 + 仅当 key 不存在才成功。
-
SET task:send_report "host-abc:12345" EX 120 NX是唯一安全写法;拆成SetNX+Expire有死锁风险(进程崩溃在中间) - value 必须全局唯一:
os.Getenv("HOSTNAME") + "-" + strconv.Itoa(os.Getpid()),不能用固定字符串或时间戳 - TTL 不是“任务预计耗时”,而是“P99 执行时间 × 2.5”(比如最长跑 48s,就设 120s),太短会导致锁提前释放、其他节点闯入
- 客户端推荐用
github.com/redis/go-redis/v9的Do方法发原生命令,别依赖封装不全的SetNX
删锁必须走 Lua 脚本,且要校验 value
直接 DEL 是高危操作,尤其在任务执行波动、GC 暂停或网络延迟时,极易误删别人刚抢到的锁。
- Lua 脚本内容固定:
if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end - Go 中调用:
script.Run(ctx, client, []string{key}, value),返回1才算真正删掉,0表示锁已易主或已过期 - 绝不能在
defer里无条件删锁——panic 或 context cancel 时可能根本没拿到锁,删了等于帮别人清道 - 别忽略返回值,更别把脚本封装成“静默删除”工具函数
触发和执行必须解耦,避免所有节点轮询抢锁
让 10 台机器每秒都去 Redis 抢一次锁,既浪费连接数,又抬高延迟,还容易触发 Redis 频控。真正可控的做法是分离职责。
Go 配置库,使用 spf13/viper — 分层优先级(flag > env >file > KV > default),提供 BindPFlag/BindPFlags、SetEnvPrefix + SetEnvKeyReplace 等功能。
立即学习“go语言免费学习笔记(深入)”;
- 用一个轻量中心调度器(比如单实例
cron/v3)准时推送任务 ID 到 Redis List 或 Pub/Sub - 所有工作节点监听该通道,收到任务 ID 后再执行
SET ... NX抢锁 —— 锁竞争只发生在真正需要执行的那一刻 - 如果必须多节点共用队列,别依赖 Redis List 的天然“争抢”,改用 Hash 存各 worker 的
last_seen时间戳,由调度器按活跃度分发 - 高频表达式(如
* * * * *)必须改造成低频触发 + 内部队列分发,否则 Redis 压力不可控
任务逻辑本身得扛住重复、中断与超时
即使锁机制完美,任务仍可能因 panic、context cancel、OOM 或网络中断而中途退出。抢占逻辑只是准入控制,不是执行保障。
- 每个任务必须幂等:写 DB 前查状态、发消息前查是否已发、文件操作加版本号或 checksum
- 关键步骤后主动刷 checkpoint 到 Redis 或 etcd(比如
task:send_report:progress:host-abc:12345),重启后可续跑 - 别依赖
context.WithTimeout自动中断——asynq 等库的 timeout 是从入队算起,不是从执行开始;需在 handler 内手动记录 start time 并轮询判断 - panic 恢复必须显式做:
recover()后记录 error + 当前 payload + timestamp,否则失败日志里看不到原始输入
最常被忽略的点是:锁的有效性 ≠ 任务的成功性。抢到锁只是获得执行资格,后续所有 IO、计算、状态更新都得独立容错。没有幂等和 checkpoint,锁再稳也救不了数据不一致。

















