小Cの已经记不起来的博客

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 这类调度平台,看团队情况取舍。

踩过的坑

写在最后

说到底,Go 里做任务调度没什么黑魔法:ticker 加 goroutine 解决一半,cron 库解决另外一半,剩下的全是工程问题——超时、重试、并发、锁、监控。别指望某个库全包圆了,先把“任务挂了能不能知道、能不能自动恢复”这两件事想清楚,其他都是体力活。

文中代码都在我自己的服务里实测跑过,直接抄走改改变量名就能用。要是你还在别的坑里摔过,欢迎留言补充。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放