学完这篇,你能在 Go 项目里写出一个既能单机跑、又能在多实例部署下不重复执行、并且能安全重启的定时任务系统。
单机定时任务用 robfig/cron 十分钟就能跑通,真正的难点在「多副本部署后任务被跑了两遍」。下面按步骤来。
第一步:装库,跑起第一个任务
go get github.com/robfig/cron/v3
最小可用示例:
package main
import (
"fmt"
"github.com/robfig/cron/v3"
)
func main() {
c := cron.New(cron.WithSeconds()) // 6 段:秒 分 时 日 月 周
c.AddFunc("0 30 3 * * *", func() { // 每天 03:30:00
fmt.Println("跑批开始")
})
c.AddFunc("@every 5m", syncOrders) // 固定间隔,从进程启动时算
c.Start()
select {} // 阻塞主协程
}
支持的表达式:标准 5 段、加秒的 6 段、@every 5m、@daily、@hourly。
注意:cron.New() 默认是 5 段(分 时 日 月 周)。要写秒级必须传 cron.WithSeconds(),此时秒在最前面。忘了传却写了 6 段,AddFunc 会直接返回解析错误——很多人卡在这里,以为是自己表达式写错了。
第二步:加 panic 恢复和并发保护
裸跑的 cron 有两个致命问题:任务 panic 会带崩整个进程;上一轮还没跑完,下一轮又起来了。
c := cron.New(
cron.WithSeconds(),
cron.WithChain(
cron.Recover(cron.DefaultLogger), // 捕获 panic,不崩进程
cron.SkipIfStillRunning(cron.DefaultLogger), // 上轮没跑完就跳过本轮
),
)
SkipIfStillRunning 是直接丢弃本轮;如果你希望排队等前一轮结束再执行,换成 DelayIfStillRunning。两者选一个,别同时挂。
注意:Recover 和 SkipIfStillRunning 的日志参数建议换成你自己的 logger,默认输出到 stdout,容器里很容易被淹没。
第三步:优雅退出
进程被 kill 时,正在执行的任务会被硬切断。用信号 + c.Stop() 收尾:
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
c.Start()
<-ctx.Done()
stopCtx := c.Stop() // 停止调度新任务,返回的 ctx 在所有运行中任务结束后 Done
select {
case <-stopCtx.Done():
case <-time.After(30 * time.Second):
log.Println("任务未在 30s 内结束,强制退出")
}
第四步:先想清楚,为什么多实例会重复跑
把服务部署成 3 个副本,每个副本都 c.Start(),那 03:30 这个任务就会被执行 3 次。分布式调度的本质只有一句话:同一时刻,同一个任务只能有一个执行者。下面三种方案按复杂度递增排列。
第五步:三种分布式调度方案
方案一:Redis 锁(最常用,够用)
抢锁成功才执行,TTL 兜底防止持有者宕机后死锁。释放时必须校验 token,否则会误删别人的锁。
const unlockScript = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end`
func withLock(ctx context.Context, rdb *redis.Client, key string, ttl time.Duration, fn func()) {
token := uuid.NewString()
ok, err := rdb.SetNX(ctx, key, token, ttl).Result()
if err != nil || !ok {
return // 没抢到,本次跳过
}
defer rdb.Eval(ctx, unlockScript, []string{key}, token)
fn()
}
注意:TTL 必须大于任务的最长耗时。任务偶尔跑 10 分钟、TTL 只给了 5 分钟,锁提前过期后另一个实例会插进来,重复执行照样发生。耗时不可控的任务,要么加大 TTL,要么做锁续期。
方案二:数据库抢占(不想引入 Redis 时)
用一张 task_lock 表,靠 UPDATE 的影响行数判断归属:
UPDATE task_lock
SET owner = ?, expire_at = DATE_ADD(NOW(), INTERVAL 5 MINUTE)
WHERE name = ? AND (expire_at IS NULL OR expire_at < NOW());
RowsAffected() == 1 才执行任务。MySQL 的行锁保证了原子性,逻辑和 Redis 锁完全一致,只是把「过期时间」从 TTL 换成了字段。
方案三:etcd / Consul 选主
如果集群里已经有 etcd,用租约 + 选主更干净:只有 leader 节点启动 cron,其余节点待命,leader 掉线后自动切换。
sess, _ := concurrency.NewSession(client, concurrency.WithTTL(10))
defer sess.Close()
mu := concurrency.NewMutex(sess, "/cron/order-sync")
if err := mu.TryLock(ctx); err != nil {
return // 没抢到,跳过本次
}
defer mu.Unlock(ctx)
另外,gocron 这类库在 v2 版本里内置了分布式 Locker 接口(有 Redis 实现),把锁直接下沉到调度器内部,写法更省事。但各版本 API 有差异,接入前先确认你用的版本对应文档。
第六步:三件必须补的事
- 幂等:锁只是降低重复概率,不能保证 100%。任务内部要有唯一键(订单号 + 业务类型)或状态机判断,重复执行时能安全返回。
- 时区:容器默认 UTC,
03:30 会变成北京时间 11:30。显式指定 cron.WithLocation(time.LoadLocation("Asia/Shanghai"))。
- 时钟:多机时钟漂移会让锁的过期判断失准,集群务必开 NTP 同步。
小结
robfig/cron/v3 默认 5 段,秒级要加 cron.WithSeconds()。
- 生产环境必挂
Recover,并在 SkipIfStillRunning / DelayIfStillRunning 中二选一。
- 优雅退出用
signal.NotifyContext + c.Stop() 返回的 ctx 等待收尾。
- 多实例的核心是「同一时刻只有一个执行者」:Redis SETNX、数据库 UPDATE 抢占、etcd 选主,三者选一。
- 锁的 TTL 要大于任务最长耗时,释放锁必须校验 token。
- 锁不能替代幂等,任务本身要能安全重入。