Go定时任务:cron与分布式调度

发布时间:2026/8/19 2:59:35
Go定时任务:cron与分布式调度 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(logtimegithub.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(contextfmtlogtimegithub.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{returnDistributedCron{client:client,keyPrefix:cron:lock:,lockTTL:lockTTL,}}// TryLock 尝试获取分布式锁// 返回true表示获取成功应该执行任务// 返回false表示已有其他实例在执行跳过本次func(dc*DistributedCron)TryLock(ctx context.Context,taskNamestring)(bool,error){key:dc.keyPrefixtaskName// 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.keyPrefixtaskName// 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.keyPrefixtaskName value,_:dc.client.Get(ctx,key).Result()// 执行任务iferr:task();err!nil{returnerr}// 释放锁returndc.Unlock(ctx,taskName,value)}锁的TTL设置要小心。设太短任务没跑完锁就过期了另一个实例拿到锁又跑一遍等于白加锁。设太长实例崩溃后锁很久不释放这段时间任务就一直没人执行。保险做法是TTL设为任务平均执行时间的3倍同时任务执行中定期续期。三、任务补偿机制定时任务可能因为服务重启、数据库故障等原因漏执行。关键任务需要有补偿机制服务启动时检查上次执行时间如果错过了就补跑。packageschedulerimport(contextfmtlogtimegithub.com/redis/go-redis/v9)// Compensator 任务补偿器typeCompensatorstruct{client*redis.Client// Redis存储上次执行时间prefixstring// key前缀}// NewCompensator 创建补偿器funcNewCompensator(client*redis.Client)*Compensator{returnCompensator{client:client,prefix:cron:last_run:,}}// RecordExecution 记录任务执行时间func(cp*Compensator)RecordExecution(ctx context.Context,taskNamestring)error{key:cp.prefixtaskName// 记录当前时间戳设置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.prefixtaskName// 读取上次执行时间lastRunStr,err:cp.client.Get(ctx,key).Result()iferrredis.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)ifelapsedinterval{// 超过间隔时间说明漏执行了补偿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(logtimegithub.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_TZAsia/Shanghai 0 2 * * *。但这个语法容易和标准cron表达式混在一起我更推荐用WithLocation统一配置。五、对比分析调度方案精确度分布式支持补偿机制运维成本单机cron秒级不支持需自建低分布式cron(Redis锁)秒级支持需自建中K8s CronJob秒级支持(按需)内置重试低单机cron适合中小项目任务数量少且没有高可用要求。分布式cron用Redis锁保证任务只执行一次适合多实例部署但补偿机制要自己写。K8s CronJob是云原生方案配置简单自带并发控制和失败重试前提是你的服务跑在K8s上。总结定时任务看起来简单时区和分布式锁是两个最容易踩的坑。robfig/cron库用起来方便但一定要显式指定时区别依赖系统时区。多实例部署必须加分布式锁TTL设为任务执行时间的3倍以上。关键任务加补偿机制服务重启后检查上次执行时间补跑漏掉的任务。下一篇聊WebSocket进阶重点讲心跳保活和断线重连。