etcd Lease 是最轻量可靠的选主方案,需结合原子 Put、KeepAlive 监听、Watch leader key、外置任务元数据、context 超时与幂等防护,才能避免多实例重复执行。

用 etcd Lease 实现调度节点选主
多实例部署下任务重复执行,根本原因是没解决“谁来跑”这个问题。etcd 的 Lease + Put 原子写入是最轻量、最可靠的选主手段。
-
clientv3.Grant(ctx, 45)创建带 TTL 的租约,TTL 设为任务最长耗时的 2–3 倍(如任务通常 12s 完成,设 45s) - 抢占必须用
client.Put(ctx, "/scheduler/leader", "node-01", clientv3.WithLease(leaseID)),不能用 GET+SET 模拟 - 续租要起独立 goroutine,监听
lease.KeepAlive(ctx, leaseID)返回 channel 关闭事件——关闭即说明 lease 被 revoke - 所有非 leader 节点必须
client.Watch(ctx, "/scheduler/leader", clientv3.WithPrevKV()),靠PrevKV判断是自己删的还是被别人抢走的 - 客户端连接 etcd 必须启用
clientv3.WithRequireLeader(),否则可能连到非 leader 节点导致 Put 失败却不报错
robfig/cron/v3 只负责触发,不执行业务逻辑
把 cron.AddFunc 当成纯本地倒计时器,不是调度中心。它只该干一件事:在正确时间点调一个函数,别的都不碰。
- 错误写法:
cron.AddFunc("0 0 * * *", sendDailyReport)—— 部署 5 个 Pod 就发 5 封邮件 - 正确写法:
cron.AddFunc("0 0 * * *", func() { tryAcquireAndRun("send_daily_report") }) -
tryAcquireAndRun内部才做三件事:查 etcd leader key、用OpPut抢粒度锁(如/locks/task:send_daily_report)、锁成功才调真实函数 - 锁的
EX时间必须 ≥ 任务预期最大耗时 × 3;value 写入唯一标识(如os.Getenv("HOSTNAME")),出问题能快速定位 - 别在服务启动时就抢锁——节点异常退出后锁残留,后续永远无法触发;抢锁时机必须严格落在每次 cron 触发之后、执行之前
任务元数据必须外置,不可硬编码
定时表达式、执行路径、超时阈值这些信息一旦写死在代码里,每次变更都要发版,运维成本爆炸,且多实例配置极易不一致。
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
- 把任务定义存到 etcd 的
/scheduler/jobs/send_daily_report下,格式为 JSON:{"expr":"0 0 * * *","timeout":180,"handler":"send_email"} - 每个节点启动时拉取全量任务列表,并用
cron.Every(30).Seconds().Do(refreshJobs)定期同步,避免配置漂移 - 删除任务不能只删内存 job,必须先从 etcd 删除 key,再触发本地
cron.RemoveJob,否则下次 sync 又拉回来 - 任务 handler 名称必须映射到函数指针表(如
map[string]func(){ "send_email": doSendEmail }),避免字符串拼错或反射调用失败
执行层必须带 context 超时与幂等防护
抢到锁 ≠ 安全执行。网络延迟、DB 慢查询、第三方接口卡顿都可能导致任务 hang 住或重复进入。
- 每个真实执行函数必须接收
context.Context,并用context.WithTimeout(ctx, task.Timeout)包裹关键操作 - 执行前先查数据库或 Redis,确认
task_id是否已标记为 success;失败重试需检查retry_count - 数据库状态更新和消息 Ack 必须在同一个事务里完成,否则可能漏 Ack 或多执行
- 不要用
time.Now().Unix()拼锁 key 或判断窗口——各节点时钟偏移会导致 key 不一致或漏触发;改用 etcd 的Revision或统一 NTP 时间源
真正难的不是写完代码,而是让每个节点在故障、扩缩容、网络分区时仍能收敛到一致状态。etcd lease 的自动过期机制是底线,但 watch 的事件丢失、续租 goroutine 的 panic、锁 value 的覆盖误判,这些细节才是压测时突然崩掉的根源。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










