Go 后端定时任务:从 time.Ticker 到分布式防重入
Go 后端定时任务:从 time.Ticker 到分布式防重入
点餐小程序上线后,有一类需求迟早会冒出来:每天凌晨清理过期未支付订单、每小时汇总一次销量、每周一早上给老板推一份经营简报。这些活都不难写,难的是让它在生产环境里稳定地跑、不重跑、不漏跑。定时任务的坑,大多不在业务逻辑,而在调度和执行方式上。
time.Ticker:够用,但有三个坑
最简单的写法是 time.NewTicker 加一个 for 循环:
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
for range ticker.C {
syncOrders()
}
第一个坑是丢 tick:Ticker 的 channel 缓冲只有 1,如果某次任务执行耗时超过了间隔,下一个 tick 会被直接丢掉——这对“每小时汇总”可能是可接受的,但对“每天只跑一次的对账”就可能漏掉一次执行。第二个坑是漂移:如果用 time.Sleep 循环写,任务本身的耗时会累进到下一次执行时间里,跑着跑着就从凌晨 3 点漂到早上 6 点。第三个坑是泄漏:Ticker 用完必须 Stop(),否则它会一直持有 goroutine 和 channel,服务跑久了就是一处隐性泄漏。
cron 表达式:可读性更好,时区是重点
任务一多,Ticker 循环就难维护了,这时适合用 cron 表达式描述调度,例如 0 0 3 * * * 表示每天凌晨 3 点执行。比起手写时间算术,表达式一眼就能看懂,也方便改。
但 cron 有两个容易踩的点。一是位数:有的库用 5 位(分、时、日、月、周),有的用 6 位(多一位秒),照抄网上的表达式前先确认库的格式,错一位就是完全不同的执行时间。二是时区:cron 默认按进程所在机器的本地时区跑,容器默认是 UTC,写着“凌晨 3 点”的任务,实际会在北京时间 11 点执行。部署在云服务器上的 Go 服务,一定要在代码里把时区显式指定为 Asia/Shanghai,不要依赖机器默认设置。
多实例部署:防重入是必答题
单机跑定时任务,上面这些就够了。一旦服务扩到两个实例,问题就来了:两个实例会在同一时刻各自跑一遍清理任务,同一批订单可能被重复处理,同一份简报可能被重复推送。
最常见的解法是用分布式锁:任务开始前先抢锁,抢到了才执行,抢不到就直接跳过。Redis 的 SET key value NX EX 30 一行就能实现:NX 保证只有一个实例能拿到锁,EX 给锁一个 30 秒过期时间,防止拿到锁的实例崩溃后锁永远不释放。但要注意两点:锁的过期时间要大于任务的正常耗时,否则任务还没跑完锁就过期了,另一个实例会进来重跑;任务本身要尽量幂等——即使真的重跑一次,也不能造成重复扣款、重复发券这种不可逆的后果。
如果业务量还小,不想引入 Redis,也有更轻的办法:把定时任务独立成一个单独部署的实例,或者用数据库的一行“任务心跳”记录做互斥,抢到行锁的实例才执行。方案可以简单,但“同一时刻只跑一份”这个保证不能省。
失败了怎么办:重试要有度,沉默是最坏的
定时任务失败后最忌讳的是静默吞掉:日志里什么都没有,老板早上发现简报没来,才知道昨晚的任务挂了。正确的做法是每次执行都记录开始、结束、耗时和影响行数,失败时记 error 级日志并接告警。重试可以加,但要加退避间隔——失败后立刻原地重试三次,遇到的是下游服务抖动还好,遇到的是数据库挂了,只会把雪崩推得更快。
小结:一份上线前的检查清单
写定时任务之前,先过一遍这张清单:执行间隔会不会漂移、时区写死没有、Ticker 记得 Stop 了吗、多实例下谁来保证只跑一份、失败了有没有日志和告警、任务本身是不是幂等的。单机场景下 systemd 的 timer 也可以承担定时调度,简单可靠;但一旦涉及多实例、复杂调度,代码里的 cron 加分布式锁才是更通用的解法。定时任务的代码量通常不大,但它是凌晨三点替你值班的同事——上线前多花十分钟检查,半夜就少一次被叫醒的电话。
- 点赞
- 收藏
- 关注作者
评论(0)