直接用time.ticker在多实例下必然重复执行,因其无全局调度协调能力,各实例独立触发;必须通过redis分布式锁(set nx+lua删锁)或etcd租约机制实现执行权仲裁,并辅以任务幂等性设计。

为什么直接用 time.Ticker 在多实例下必然重复执行
Go 原生的 time.Ticker 或 time.AfterFunc 完全无协调能力。只要部署 2 个及以上服务实例,每个都会独立触发任务——这不是“偶尔重复”,而是“必然重复”。常见于 Kubernetes 多副本、多机器部署或蓝绿发布期间。
根本原因:没有全局唯一调度权。你不能靠“加锁”解决,因为锁必须跨进程,本地 sync.Mutex 或 sync.Once 对其他实例完全无效。
用分布式锁(Redis + Lua)控制单点执行最实用
核心思路:每次任务触发前,所有实例竞争一个带过期时间的锁;仅抢到锁的实例执行,其余跳过。Redis 的 SET key value EX seconds NX 原子性足够,但需配合 Lua 脚本防误删(避免 A 拿到锁、超时未释放,B 续上后被 A 删除)。
- 推荐使用
github.com/go-redis/redis/v9,其SetNX+Eval可组合实现安全锁 - 锁 key 建议含业务名 + 时间窗口,例如
"job:send_daily_report:20240520",避免不同日期任务互相阻塞 - 过期时间必须显著长于任务最大执行耗时(比如任务最长 30s,设为 120s),否则可能任务未完锁已失,导致二次触发
- 务必在任务结束时显式释放锁(
DEL),且只删自己设置的 value(用 Lua 校验)
const luaScript = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
`
// 执行时:
// rdb.Eval(ctx, luaScript, []string{lockKey}, lockValue).Result()
用 ETCD 实现更可靠的租约机制(适合强一致性场景)
Redis 锁依赖网络稳定性与单点 Redis 可用性;ETCD 基于 Raft,天然支持租约(lease)、监听(watch)和前缀事务,更适合金融、账务类定时任务。
- 用
clientv3.Grant创建带 TTL 的 lease,再用clientv3.Put写入带 lease 的 key(如/jobs/heartbeat/report) - 定期调用
clientv3.KeepAlive续约;任务执行中持续续租,失败即自动释放 - 其他实例通过
clientv3.Get查 key 是否存在 +ModRevision判断是否被抢占,无需轮询 - 注意:ETCD 写压力比 Redis 高,高频短周期任务(如每 5 秒)不建议直接用 ETCD 做锁,可降级为每分钟检查一次主节点状态
别忽略任务幂等性——锁只是第一道防线
分布式锁会失效:网络分区、进程崩溃、GC STW 导致续约延迟……一旦锁丢失,重复执行就可能发生。所以任务逻辑本身必须可重入。
- 关键操作加数据库唯一约束(如订单生成任务,用
order_date + batch_id做联合唯一索引) - 写入前先查状态(例如 “今日报告是否已生成”,查表 or Redis flag)
- 避免“查-改-写”裸操作,改用原子指令:
UPDATE jobs SET status='done' WHERE date='20240520' AND status='pending' - 日志里记录
task_id和instance_id,方便事后追溯哪次是重复触发
锁解决的是“尽量不重复”,幂等性解决的是“重复了也不坏”。两者缺一不可,而后者常被跳过——直到线上出现双扣款、双发短信才意识到。











