分布式定时任务不能直接用 time.Ticker 或 cron.FuncJob,因为多实例会重复执行;必须通过 Redis 分布式锁或 asynq 等调度框架实现单例执行与可靠分发。

为什么不能直接用 time.Ticker 或 cron.FuncJob 做分布式定时任务
单机 time.Ticker 或 github.com/robfig/cron/v3 的 cron.FuncJob 在多实例部署时会重复执行——每个节点都按自己节奏触发,没有协调机制。这不是“定时不准”,而是根本性语义错误:你期望“每天凌晨 2 点只执行一次”,结果 5 个服务实例各自跑一遍。
真正需要的不是“更准的定时器”,而是“带分布式锁的调度决策 + 可靠的任务分发”。关键矛盾在调度层(谁来决定此刻该谁执行),不在执行层(执行本身可以很轻量)。
- 常见错误现象:
panic: job already registered with name "sync_cache"或日志里同一任务时间戳密集出现 3–5 次 - 典型场景:订单超时关闭、报表汇总、缓存预热——这些任务绝不允许并发执行
- 别试图用数据库 UPDATE … WHERE version = x 做锁:网络延迟 + 事务隔离级别 + 重试逻辑会让它变成状态地狱
用 redis.SetNX + 过期时间实现轻量级调度锁
核心思路:所有节点在触发前先争抢一个带 TTL 的 Redis key,抢到的节点才执行,其余跳过。不依赖外部调度中心,对现有架构侵入小。
注意 SET key value EX seconds NX 必须原子执行,Go 客户端推荐用 github.com/redis/go-redis/v9 的 SetNX 方法:
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
ok, err := rdb.SetNX(ctx, "job:clean_expired_orders:lock", os.Getenv("HOSTNAME"), 30*time.Second).Result()
if err != nil {
log.Printf("redis lock check failed: %v", err)
return
}
if !ok {
return // 锁被占,本次跳过
}
// ✅ 抢到锁,执行任务
cleanExpiredOrders()
- 过期时间(TTL)必须显著长于任务最大执行时间(建议 ≥ 3×),否则任务未完成锁就失效,其他节点可能并发进入
- value 写入当前实例标识(如
os.Getenv("HOSTNAME")或 pod UID),便于排查哪个节点在执行 - 不要用
GETSET模拟:无法区分“锁已存在”和“锁已过期但未被清理”的情况
用 github.com/hibiken/asynq 处理高可靠、可追踪的分布式任务
当任务需失败重试、延迟执行、优先级控制或执行历史追溯时,asynq 是目前 Go 生态最成熟的方案。它把定时任务转为“带 cron 表达式的周期性任务注册”,由单个(或主备) asynq server 负责解析调度,再通过 Redis 队列分发给多个 worker。
关键配置点:
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
- server 启动时必须设置唯一
ServerID,多个 server 实例会自动选主,避免重复调度 - worker 启动时用
asynq.NewServeMux()注册 handler,不写 cron 表达式——那是 server 的事 - 任务注册在 server 侧,例如:
srv.RegisterPeriodic(&asynq.PeriodicTaskConfig{CronSpec: "0 0 * * *", Task: asynq.NewTask("send_daily_report", nil)}) - 失败任务默认重试 25 次,可通过
asynq.RetryConfig{MaxRetry: 3}调整,避免雪崩
注意:asynq server 和 worker 可以部署在同一进程(开发方便),但生产环境务必分离——server 是调度大脑,worker 是手脚,混部会导致调度延迟升高。
时间漂移与跨时区任务怎么处理
服务器本地时间 ≠ 业务期望时间。比如你设了 "0 2 * * *",但集群节点分布在 UTC+8 / UTC+0 / UTC-5,有的机器没开 NTP,结果任务在不同时间触发。
解决方案只有两个,且必须二选一:
- 所有节点强制使用 UTC 时间,并在业务代码里做时区转换(推荐):cron 表达式统一写 UTC,
time.Now().In(time.UTC)判断逻辑,输出日志也打 UTC 时间戳 - 用支持时区的调度库,如
asynqv0.30+ 的asynq.WithLocation(time.FixedZone("CST", 8*60*60)),但要注意:Redis 本身无时区概念,这个 location 只影响 server 解析 cron 的行为,worker 仍需用相同 zone 格式化日志和判断条件
最容易被忽略的是夏令时切换窗口:某些地区每年两次跳变,如果任务恰好卡在 2:00–3:00,可能漏跑或双跑。UTC 方案天然规避此问题。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










