Go 语言 任务调度 实战:从原理到落地
起因
前段时间接手了一个内部小服务,打开一看,里面塞了十几个定时任务,全是一个套路:起个 goroutine,里面 for 循环加 time.Sleep。跑是能跑,但改个执行周期要重新编译发版,任务 panic 了进程直接挂,日志里也完全看不出哪个任务几点几分跑的、跑了多久。想着反正要重构,干脆把 Go 任务调度这块从头到尾捋一遍,顺手把踩的坑记下来,就有了这篇文章。
先把原理捋一遍
Go 里所有定时能力,归根到底都来自 runtime 的 timer。time.After、time.NewTimer、time.NewTicker 这些,底层都挂在每个 P 的 timer 堆上(1.14 之后从全局堆改成了每 P 一棵四叉堆),到点之后由调度器唤醒对应的 goroutine。平时不用关心这些,但有一个行为必须知道:time.Ticker 的 channel 容量是 1,如果上一个 tick 你还没消费掉,下一个 tick 会被直接丢弃。
这个行为对定时任务来说其实是好事,任务堆不起来;但如果你期望“每分钟必须执行一次”,而任务本身可能跑超过一分钟,那 ticker 就满足不了,得换思路。
最粗糙的版本:ticker + goroutine
不引依赖,先写个能跑的:
func runEvery(interval time.Duration, job func()) {
go func() {
t := time.NewTicker(interval)
defer t.Stop()
for range t.C {
job()
}
}()
}
不出意外的话,直接 go run 就能跑起来。但也就止步于能跑了,job 卡住整条流水线就卡住,panic 没人管。稍微改一下,把 job 丢进新 goroutine,顺便补个 recover:
for range t.C {
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("job panic: %v\n%s", r, debug.Stack())
}
}()
job()
}()
}
注意这里又会冒出新问题:interval 短、job 慢的话,goroutine 会越积越多。要么加个信号量限并发,要么老老实实同步跑,看任务性质决定。
上 cron 库
一旦需要“每天凌晨 3 点”、“工作日上午 9 点半”这种需求,ticker 就写不动了,老实上 robfig/cron:
c := cron.New(cron.WithSeconds()) // 从老项目抄来的表达式带秒的话,这个必须加,不加解析直接报错
c.AddFunc("0 0 3 * * *", cleanupTask)
c.AddFunc("CRON_TZ=Asia/Shanghai 0 30 9 * * MON-FRI", sendReport)
c.Start()
两个细节:一是 v3 默认不带秒字段,网上搜来的老配置第一个字段是秒,直接抄会报错;二是 cron 默认按服务器本地时间跑,容器里多半是 UTC,任务会提前 8 小时执行,用 CRON_TZ 前缀或者在创建时传 cron.WithLocation() 都能解决。这个坑我见过不止一次。
真正落地要补的几件事
调度框架只回答“什么时候跑”,落地时真正花时间的其实是下面这些:
超时控制。 用 context.WithTimeout 包一层,关键是把 ctx 真的传进任务里,任务内部去监听 ctx.Done()。只在外面包一层、里面不接,等于没做。
失败重试。 简单任务一个 for 循环加退避就够了,别一上来就引重试框架:
for i := 0; i < 3; i++ {
if err := doJob(ctx); err == nil {
break
}
time.Sleep(time.Duration(1<<i) * time.Second) // 1s、2s、4s,简单够用
}
错峰。 十几个任务都挤在整点跑,数据库压力会很难看。cron 表达式里故意把分钟错开,或者启动时给每个任务加个随机偏移。
分布式锁。 服务多副本部署后,每个实例都会触发任务,得抢锁:
ok, err := rdb.SetNX(ctx, "lock:cleanup", instanceID, 10*time.Minute).Result()
if err != nil || !ok {
return // 没抢到说明别的实例正在干,直接走人
}
defer rdb.Del(ctx, "lock:cleanup")
要是任务又多又重,就别自己造了,直接丢给 XXL-JOB 这类调度平台,看团队情况取舍。
踩过的坑
- Ticker 忘了
Stop(),服务跑久了堆了一堆常驻 ticker。 - 容器里没设
TZ,cron 任务提前 8 小时跑,一开始还以为是代码写错了。 - 任务执行时间是按半年前的数据量估的,后来涨了 20 倍,凌晨任务跑 3 小时,和下一个任务撞上了。
- panic 没 recover,一个冷门分支的空指针把整个进程带走,其他任务全陪葬。
写在最后
说到底,Go 里做任务调度没什么黑魔法:ticker 加 goroutine 解决一半,cron 库解决另外一半,剩下的全是工程问题——超时、重试、并发、锁、监控。别指望某个库全包圆了,先把“任务挂了能不能知道、能不能自动恢复”这两件事想清楚,其他都是体力活。
文中代码都在我自己的服务里实测跑过,直接抄走改改变量名就能用。要是你还在别的坑里摔过,欢迎留言补充。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。