分布式定时任务必须通过抢占式锁(如redis setnx)实现执行权原子分配,而非各节点独立触发;任务配置需外置可动态更新,高可靠场景应优先使用asynq等成熟框架。

直接用 time.Ticker 或 cron.AddFunc 会重复执行
所有节点各自跑一套调度器,表达式一样、逻辑一样,照样每个实例都触发一遍。你看到日志里同一时间戳出现 3 次 sync_cache,不是“运气差”,是设计必然结果。
这不是定时不准的问题,是根本没做分布式协调。哪怕加了 NTP 同步、把 time.Now() 拉齐,也拦不住 5 台机器同时进入 if now.After(nextRun) 分支。
- 别试图用数据库
UPDATE ... WHERE version = x做锁:事务延迟 + 隔离级别 + 网络抖动会让它在重试时误判状态 -
robfig/cron和gocron默认都是内存态,WithDistributedLocker接口只是占位符,不带实现 - 真正要解决的不是“怎么更准地触发”,而是“此刻该谁执行”——这个决策必须原子化、可验证、带超时
用 redis.SetNX 实现抢占式锁是最小可行方案
不需要额外中间件,只要一个 Redis 实例,所有节点在每次执行前竞争同一个 key,抢到才干活。核心是两件事:抢锁时机 + 锁安全释放。
- 用
rdb.SetNX(ctx, "job:cleanup:lock", hostname, 90*time.Second),TTL 设为任务最大耗时的 3 倍(比如任务最长 30s,TTL 就设 90s) -
value必须写唯一标识(如os.Getenv("HOSTNAME") + ":" + strconv.Itoa(os.Getpid())),不能写固定字符串,否则无法区分是谁占的锁 - 删锁必须用 Lua 脚本校验 value:
if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end,防止 A 执行慢、锁过期后被 B 抢走,然后 A 结束时误删 B 的锁 - 别用
GETSET模拟:无法区分“锁还有效”和“锁已过期但没被清理”的情况,会导致漏锁或误删
任务元数据必须外置,不能硬编码在 Go 文件里
如果每个服务都写 cron.AddFunc("0 2 * * *", cleanup),改个表达式就得全量发布。真正需要的是运行时可变、多实例共享的配置源。
- 把 cron 表达式、任务名、超时时间、重试次数存到 Redis Hash 或 PostgreSQL 表里,而不是代码里
- 各节点启动后轮询拉取最新配置(比如每 30 秒查一次
HGETALL job:config),有变更再 reload 本地调度器 - key 命名别用
time.Now().Unix()拼接——不同机器时钟偏移会导致计划时间错乱;改用中心生成的下一次执行时间戳,或统一用 NTP 校准后的time.Now().UTC().Truncate(time.Minute) - 任务函数名不要直接反射调用,先映射到白名单函数表,避免任意代码执行风险
高可靠场景优先选 asynq,而不是手撸锁逻辑
当任务需要失败重试、延迟执行、优先级队列、执行历史追溯时,自己维护锁+续期+回滚的成本远高于引入成熟框架。
-
asynq把定时任务转成“注册周期性任务”,由单个(或主备) server 解析 cron 表达式,再通过 Redis 队列分发,天然避免重复 - 它内置幂等消费、失败自动进 retry 队列、web UI 查看执行记录——这些不是锦上添花,而是生产环境刚需
- 注意:
asynq的调度 server 是单点(可主备),但执行器可以无限水平扩展;别把它当成纯消息队列用,它的cron模块才是解决分布式定时的核心 - 如果已有 etcd 或 ZooKeeper,也可以用它们做 leader 选举,让当选 leader 的节点负责调度,其余只执行——但得自己处理脑裂、租约续期、任务分片等细节
最容易被忽略的一点:锁的 TTL 不是拍脑袋定的。它必须大于任务最大执行时间,且留出网络延迟余量;但也不能设太大,否则节点宕机后锁残留太久,导致任务长时间不执行。这个平衡点得靠真实压测数据来调,不是文档里抄个数字就能用的。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











