Go定时任务:cron与分布式调度
摘要: 本篇讲解Go语言定时任务调度,使用robfig/cron库配置cron表达式,实现分布式锁防止多实例重复执行,设计任务补偿机制处理漏执行场景,分享时区配置错误导致任务提前一小时执行的踩坑经验,对比单机cron、分布式cron和K8s CronJob三种方案。
开篇故事
去年我们有个对账系统,每天凌晨2点跑一批结算任务,把前一天的订单数据汇总生成报表。开发环境跑了一个月没问题,上了生产环境第二天运营就找过来说报表数据不对,凌晨1点的数据没跑进去。
排查了一上午才发现,生产服务器时区是UTC,开发机器是CST。cron表达式写的是0 2 * * *,在UTC时区下2点就是北京时间早上10点,任务确实跑了,但跑的时候当天的订单还没产生多少。运营以为任务没跑,实际上跑了但时间不对。
后来又遇到一个坑。我们部署了3个实例做高可用,cron任务在每个实例都配置了一份。凌晨2点三个实例同时触发,同一个对账任务跑了3遍,数据库出现了重复记录。这两个坑让我把定时任务的时区和分布式锁认真梳理了一遍。
一、robfig/cron库基础用法
robfig/cron是Go生态里最成熟的定时任务库,支持标准cron表达式和秒级精度的扩展表达式。
packagemainimport("log""time""github.com/robfig/cron/v3")funcmain(){// 创建cron调度器// cron.New()默认用标准表达式(5位: 分 时 日 月 周)// cron.New(cron.WithSeconds())用6位表达式(秒 分 时 日 月 周)c:=cron.New(cron.WithLogger(cron.PrintfLogger(log.Default())),// 自定义日志cron.WithLocation(time.Local),// 指定时区)// 添加任务,标准表达式: 每天凌晨2点执行id1,err:=c.AddFunc("0 2 * * *",func(){log.Println("执行每日对账任务")runReconciliation()})iferr!=nil{log.Fatalf("添加任务失败: %v",err)}log.Printf("对账任务已注册, ID=%d",id1)// 每5分钟执行一次健康检查id2,err:=c.AddFunc("*/5 * * * *",func(){log.Println("执行健康检查")checkHealth()})iferr!=nil{log.Fatalf("添加任务失败: %v",err)}log.Printf("健康检查已注册, ID=%d",id2)// 启动调度器,所有任务开始按计划执行c.Start()log.Println("cron调度器已启动")// 使用Select阻塞主goroutine,防止程序退出select{}}// runReconciliation 模拟对账任务funcrunReconciliation(){log.Println("开始汇总订单数据...")time.Sleep(2*time.Second)// 模拟耗时操作log.Println("对账任务完成")}// checkHealth 模拟健康检查funccheckHealth(){log.Println("检查服务状态...")}cron表达式语法需要记清楚。5位标准格式是分 时 日 月 周,每位用空格分隔。*表示任意值,*/5表示每5个单位,0 2 * * *就是每天2点。robfig/cron还支持描述性写法,比如@hourly表示每小时,@every 5m表示每5分钟,比表达式好记。
二、分布式锁防止重复执行
单机cron没问题,多实例部署时每个实例都会触发同一个任务。需要对账任务只跑一次,就得加分布式锁。用Redis实现一个简单的分布式锁,谁先抢到锁谁执行,其余实例跳过。
packageschedulerimport("context""fmt""log""time""github.com/redis/go-redis/v9")// DistributedCron 分布式定时任务调度器typeDistributedCronstruct{client*redis.Client// Redis客户端keyPrefixstring// 锁key前缀lockTTL time.Duration// 锁过期时间,防止任务崩溃后锁不释放}// NewDistributedCron 创建分布式调度器// lockTTL必须大于任务最大执行时间,否则任务还没跑完锁就过期了funcNewDistributedCron(client*redis.Client,lockTTL time.Duration)*DistributedCron{return&DistributedCron{client:client,keyPrefix:"cron:lock:",lockTTL:lockTTL,}}// TryLock 尝试获取分布式锁// 返回true表示获取成功,应该执行任务// 返回false表示已有其他实例在执行,跳过本次func(dc*DistributedCron)TryLock(ctx context.Context,taskNamestring)(bool,error){key:=dc.keyPrefix+taskName// SET key value NX EX ttl// NX: key不存在才设置,保证只有一个实例能拿到锁// EX: 设置过期时间,防止锁泄漏value:=fmt.Sprintf("%d",time.Now().UnixNano())ok,err:=dc.client.SetNX(ctx,key,value,dc.lockTTL).Result()iferr!=nil{returnfalse,fmt.Errorf("获取锁失败: %w",err)}returnok,nil}// Unlock 释放锁// 使用Lua脚本保证只有锁的持有者才能释放// 防止任务执行慢,锁过期后被其他实例拿到,当前实例误删别人的锁func(dc*DistributedCron)Unlock(ctx context.Context,taskNamestring,valuestring)error{key:=dc.keyPrefix+taskName// Lua脚本: 先比较value再删除,保证原子性luaScript:=` if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end `_,err:=dc.client.Eval(ctx,luaScript,[]string{key},value).Result()returnerr}// RunWithLock 带分布式锁执行任务// 封装了获取锁、执行、释放锁的完整流程func(dc*DistributedCron)RunWithLock(ctx context.Context,taskNamestring,taskfunc()error)error{// 尝试获取锁locked,err:=dc.TryLock(ctx,taskName)iferr!=nil{returnerr}if!locked{// 其他实例正在执行,跳过log.Printf("任务 %s 已被其他实例执行,跳过",taskName)returnnil}// 获取锁的值,用于释放时验证key:=dc.keyPrefix+taskName value,_:=dc.client.Get(ctx,key).Result()// 执行任务iferr:=task();err!=nil{returnerr}// 释放锁returndc.Unlock(ctx,taskName,value)}锁的TTL设置要小心。设太短,任务没跑完锁就过期了,另一个实例拿到锁又跑一遍,等于白加锁。设太长,实例崩溃后锁很久不释放,这段时间任务就一直没人执行。保险做法是TTL设为任务平均执行时间的3倍,同时任务执行中定期续期。
三、任务补偿机制
定时任务可能因为服务重启、数据库故障等原因漏执行。关键任务需要有补偿机制,服务启动时检查上次执行时间,如果错过了就补跑。
packageschedulerimport("context""fmt""log""time""github.com/redis/go-redis/v9")// Compensator 任务补偿器typeCompensatorstruct{client*redis.Client// Redis存储上次执行时间prefixstring// key前缀}// NewCompensator 创建补偿器funcNewCompensator(client*redis.Client)*Compensator{return&Compensator{client:client,prefix:"cron:last_run:",}}// RecordExecution 记录任务执行时间func(cp*Compensator)RecordExecution(ctx context.Context,taskNamestring)error{key:=cp.prefix+taskName// 记录当前时间戳,设置30天过期returncp.client.Set(ctx,key,time.Now().Unix(),30*24*time.Hour).Err()}// CheckAndCompensate 检查是否需要补偿执行// interval: 任务正常执行间隔// 如果距离上次执行超过interval,说明漏执行了,触发补偿func(cp*Compensator)CheckAndCompensate(ctx context.Context,taskNamestring,interval time.Duration,taskfunc()error,)error{key:=cp.prefix+taskName// 读取上次执行时间lastRunStr,err:=cp.client.Get(ctx,key).Result()iferr==redis.Nil{// 没有记录,说明是第一次执行或记录被清除了// 直接执行任务并记录时间log.Printf("任务 %s 无历史记录,首次执行",taskName)iferr:=task();err!=nil{returnerr}returncp.RecordExecution(ctx,taskName)}iferr!=nil{returnfmt.Errorf("读取执行记录失败: %w",err)}// 解析上次执行时间戳lastRunUnix,err:=parseUnix(lastRunStr)iferr!=nil{returnerr}lastRun:=time.Unix(lastRunUnix,0)now:=time.Now()// 计算距上次执行的时间差elapsed:=now.Sub(lastRun)ifelapsed>=interval{// 超过间隔时间,说明漏执行了,补偿log.Printf("任务 %s 漏执行,距上次 %v,触发补偿",taskName,elapsed)iferr:=task();err!=nil{returnerr}}// 记录本次执行时间returncp.RecordExecution(ctx,taskName)}// parseUnix 解析Unix时间戳字符串funcparseUnix(sstring)(int64,error){vartsint64_,err:=fmt.Sscanf(s,"%d",&ts)returnts,err}补偿机制有个问题要考虑。如果服务挂了3天,恢复后补偿任务跑一次就行,还是把3天每天补跑一次。这取决于业务场景。对账类任务一般跑一次就行,取最新数据。分批同步类任务可能需要按天补跑,否则中间数据丢失。
四、踩坑经验:时区配置导致任务提前执行
这个坑就是开篇故事里说的。线上服务器是UTC时区,开发机器是CST。cron表达式0 2 * * *在UTC时区下2点执行,等于北京时间10点。
问题出在cron调度器默认用系统时区。开发机系统时区是Asia/Shanghai,生产服务器系统时区是UTC。同一份代码两台机器跑出来的执行时间差了8小时。
修复方法是显式指定时区,不依赖系统时区。
packagemainimport("log""time""github.com/robfig/cron/v3")funcmain(){// 方案1: 用WithLocation指定固定时区// 不管服务器时区是什么,任务都按CST执行loc,err:=time.LoadLocation("Asia/Shanghai")iferr!=nil{log.Fatalf("加载时区失败: %v",err)}c:=cron.New(cron.WithLocation(loc),// 显式指定北京时间cron.WithLogger(cron.PrintfLogger(log.Default())),)// 这样配置后,0 2 * * * 始终在北京时间凌晨2点执行c.AddFunc("0 2 * * *",func(){log.Println("北京时间凌晨2点执行对账任务")})c.Start()select{}}robfig/cron还支持在表达式里直接指定时区,写法是CRON_TZ=Asia/Shanghai 0 2 * * *。但这个语法容易和标准cron表达式混在一起,我更推荐用WithLocation统一配置。
五、对比分析
| 调度方案 | 精确度 | 分布式支持 | 补偿机制 | 运维成本 |
|---|---|---|---|---|
| 单机cron | 秒级 | 不支持 | 需自建 | 低 |
| 分布式cron(Redis锁) | 秒级 | 支持 | 需自建 | 中 |
| K8s CronJob | 秒级 | 支持(按需) | 内置重试 | 低 |
单机cron适合中小项目,任务数量少且没有高可用要求。分布式cron用Redis锁保证任务只执行一次,适合多实例部署,但补偿机制要自己写。K8s CronJob是云原生方案,配置简单,自带并发控制和失败重试,前提是你的服务跑在K8s上。
总结
定时任务看起来简单,时区和分布式锁是两个最容易踩的坑。robfig/cron库用起来方便,但一定要显式指定时区,别依赖系统时区。多实例部署必须加分布式锁,TTL设为任务执行时间的3倍以上。关键任务加补偿机制,服务重启后检查上次执行时间补跑漏掉的任务。下一篇聊WebSocket进阶,重点讲心跳保活和断线重连。