用goroutine直接起定时器会重复执行,因多实例各自维护独立计时器,缺乏全局执行权共识;需通过redis set nx ex抢占锁或etcd lease+watch选主来确保单点执行。

为什么用 goroutine 直接起定时器会重复执行
单机场景下用 time.Ticker 或 time.AfterFunc 启动任务看似简单,但一旦部署多实例,每个节点都会独立触发——这不是“分布式”,是“多份并发执行”。常见现象包括:数据库记录被写入多次、消息被重复推送、扣款操作发生两次。根本原因在于缺乏全局协调机制,所有节点对“现在该不该执行”没有共识。
解决思路不是加锁,而是把“谁来执行”这件事交给一个中心化决策层。实际落地时,优先考虑已验证的分布式协调服务,而非自己实现选举或租约逻辑。
用 Redis 实现幂等性抢占最轻量可靠
不依赖 ZooKeeper 或 etcd,Redis 的 SET key value EX seconds NX 是事实标准方案:原子性地尝试设置一个带过期时间的唯一键,成功即获得执行权。失败说明其他节点已抢到,当前实例直接跳过。
-
NX确保只在键不存在时才设置,避免覆盖他人租约 -
EX时间必须显著短于任务预期执行时长(例如任务最长耗时 30s,租约设为 60s),防止任务卡住后租约过期,被其他节点误抢 - 每次执行前都需重新
SET更新租约(可用GETSET或 Lua 脚本),否则中途崩溃会导致租约残留 - 示例伪代码:
if redis.SetNX(ctx, "job:sync_user:lock", os.Getenv("HOSTNAME"), 60*time.Second).Val() { defer redis.Del(ctx, "job:sync_user:lock") runSyncUserJob() }
go-cron 和 robfig/cron 不适合分布式场景
这两个库本质是本地时间调度器,它们解析 cron 表达式、维护内部计时器,但完全 unaware of other instances。即使你把它们封装进微服务,只要没外挂分布式锁或选主逻辑,就仍是多副本各自跑一套,结果就是 N 倍重复。
真正可扩展的做法是拆分职责:
- 调度器(Scheduler):单一服务,负责按 cron 解析时间点,往消息队列(如 Kafka、NATS)发触发事件
- 执行器(Worker):无状态服务集群,消费队列消息,拿到任务 ID 后先抢 Redis 锁,成功再执行
- 这样调度与执行解耦,扩容 Worker 不影响调度精度,也天然避免重复
用 etcd 的 Lease + Watch 做更健壮的选主执行
当业务对一致性要求极高(比如金融级定时结算),Redis 的最终一致性可能不够。此时应切到 etcd,利用其强一致 Raft 日志和租约自动续期能力。
关键点:
- 每个 Worker 启动时创建一个带 TTL 的
Lease,并用该 Lease 绑定一个唯一 key(如/jobs/backup/leader) - 通过
CompareAndSwap(CAS)竞争写入,写入成功者成为 leader 并定期刷新 Lease - 非 leader 节点监听该 key,一旦发现变更或 Lease 过期,立即触发重新竞选
- 注意:不要在 leader 节点里嵌套长时间阻塞操作,续约心跳必须独立 goroutine 运行,否则 Lease 会意外过期
这比 Redis 方案重,但能精确控制“只有一个活节点在执行”,且故障转移延迟可控(通常
真正难的从来不是“怎么让任务跑起来”,而是“怎么证明它只跑了一次”。锁的粒度、租约时间、续期时机、失败回滚路径——这些细节没对齐,分布式定时任务就会在半夜三点给你发告警。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











