因为time.ticker只在单进程内生效,多实例会各自触发导致必然重复执行;必须通过etcd租约选主或redis分布式锁实现全局执行权仲裁,并辅以任务幂等性设计。

为什么不能直接用 time.Ticker 做分布式定时任务
因为 time.Ticker 只在单进程内生效,多个实例会各自触发,导致任务重复执行。哪怕加了数据库锁,也无法解决启动瞬间的竞态——比如 5 个节点同时读到「下次该我跑了」,再争锁,已经晚了。
真正要的是「全局唯一调度权」+「故障自动漂移」。常见错误是把单机逻辑套上 Redis 锁就以为分布式了,结果锁过期时间设短了,任务还没跑完锁就丢了;设长了,节点宕机后新节点得等很久才能接管。
- 必须用带租约(lease)的协调机制,比如
etcd的Lease.Grant+Lease.KeepAlive - 调度器(scheduler)和执行器(worker)要分离:一个节点只负责发令,多个节点抢着接令
- 避免用 MySQL 时间轮查,高并发下
SELECT FOR UPDATE会成瓶颈
用 etcd 实现去中心化选主与任务分发
etcd 的 Lease 和 CompareAndDelete(CAS)能天然支持「心跳续租 + 主节点抢占」。关键不是谁先连上 etcd,而是谁的 lease 最久且未被删除。
典型流程:所有节点尝试用相同 key(如 /scheduler/leader)写入自己的 ID + 当前 lease ID;只有第一个写入成功者成为 leader;其余节点监听该 key,一旦消失就立刻重试抢占。
- leader 不直接执行任务,而是往
/tasks/queue推送 JSON 消息(含 task_id、cron 表达式、payload) - worker 节点从
/tasks/queue监听并消费,消费成功后删除消息(用Delete+PrevKV防重复) - 务必设置 lease TTL ≥ 任务最长执行时间 × 2,否则 worker 挂了但 lease 还在,任务就卡住
cron.ParseStandard 解析失败的三个隐藏条件
Go 标准库 cron.ParseStandard 看似简单,但实际部署时高频出错:不是表达式写错,而是环境差异导致行为不一致。
- 它默认使用
time.Local时区,而容器或 k8s pod 往往没配TZ环境变量,结果变成 UTC,和你本地测试不一致 - 不支持
@yearly这类符号别名,必须用0 0 1 1 *;用错会静默返回nil,没判空就 panic - 秒级精度需用第三方库(如
robfig/cron/v3),标准cron包只支持最小分钟级
建议统一在服务启动时强制设置:os.Setenv("TZ", "Asia/Shanghai"),再调用 time.LoadLocation 显式传给解析器。
任务幂等性不能只靠数据库 INSERT IGNORE
很多团队在 worker 里对任务记录做 INSERT IGNORE INTO tasks_run (task_id, run_at) VALUES (?, ?),以为就能防重。但问题在于:如果插入成功但后续业务逻辑崩溃,这条记录就变成了「假完成」,下次调度还会跳过它。
真正安全的做法是「状态机驱动」:任务记录初始为 pending,执行中更新为 running,成功后才置为 succeeded;worker 启动时扫描超时的 running 记录,按策略重试或标记失败。
- 数据库字段必须有
updated_at+timeout_seconds,用于识别卡死任务 - 不要用单条 SQL 判断是否执行过,要用
SELECT ... FOR UPDATE锁住记录再检查状态 - HTTP 类任务还要考虑下游服务响应超时但实际已处理成功,此时应依赖下游的幂等 key,而非本端状态
分布式定时任务最难的从来不是怎么触发,而是怎么确认「真的做完了」——这个确认过程本身也得是分布式的、可重入的、带上下文快照的。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











