Go + Gin 优化动态定时任务系统:互斥控制、异常捕获与任务热更新

前言

在上一篇文章中,我们已经完成了定时任务系统的初步搭建,实现了从静态任务注册到动态任务调度的完整流程,并且支持了任务执行日志的自动记录。

不过,在实现过程中,我们也发现了一些存在的问题:

  • 任务并发问题 :虽然任务配置中包含是否允许并发的字段(concurrent),但在当前实现中尚未真正应用。所有任务默认都是并发执行的,这就导致如果任务执行时间较长,而调度周期较短,就会出现任务重叠执行,造成资源冲突和重复处理问题。
  • 任务异常风险 :当前任务执行过程中,如果遇到 panic 异常,调度器未做异常捕获,可能导致整个任务调度器崩溃,严重影响系统稳定性。
  • 任务热更新缺失:目前我们的任务配置需要在项目启动时加载,后续如果任务状态发生变化(比如启用、停用、新增、删除),必须重启服务才能生效,缺乏灵活的动态管理能力。

一、回顾:上篇中的任务加载器实现

在上一篇文章中,我们初步实现了从数据库加载任务并注册到调度器的功能,核心代码如下:

timer/loader.go

go 复制代码
func LoadJobsFromDB(cr *cron.Cron) {
	var jobs []model.SysJob
	err := global.GVA_DB.Where("status = ?", 1).Find(&jobs).Error
	if err != nil {
		global.GVA_LOG.Error("加载定时任务失败", zap.Error(err))
		return
	}

	for _, job := range jobs {
		jobCopy := job
		taskFunc, err := GetTask(jobCopy.InvokeTarget)
		if err != nil {
			global.GVA_LOG.Warn("找不到任务函数: " + jobCopy.InvokeTarget)
			continue
		}

		_, err = cr.AddFunc(jobCopy.Cron, func() {
			start := time.Now()
			global.GVA_LOG.Info("⏱ 开始任务: " + jobCopy.Name)

			execErr := taskFunc(jobCopy.Args)

			duration := time.Since(start).Milliseconds()

			log := model.SysJobLog{
				JobID:     jobCopy.ID,
				JobName:   jobCopy.Name,
				Args:      jobCopy.Args,
				StartTime: start,
				Duration:  duration,
				Status:    "success",
			}

			if execErr != nil {
				log.Status = "fail"
				log.ErrorMsg = execErr.Error()
				global.GVA_LOG.Error("任务失败: "+jobCopy.Name, zap.Any("err", execErr))
			} else {
				global.GVA_LOG.Info("任务完成: " + jobCopy.Name)
			}

			_ = global.GVA_DB.Create(&log).Error
		})

		if err != nil {
			global.GVA_LOG.Error("注册任务失败: "+jobCopy.Name, zap.Error(err))
		}
	}
}

虽然功能实现已经可用,但还存在一些明显的问题:

  • 没有异常捕获:如果任务内部发生 panic,调度器会崩溃。
  • 任务注册时没有判断重复注册,存在重复添加风险。
  • concurrent 字段未使用,所有任务默认并发执行,容易产生资源竞争和重复处理问题。

二、第一步优化:任务加载器增强

为了提升系统稳定性,我们对加载器进行如下优化:

  • 异常捕获(recover) :防止任务 panic 崩溃,保证调度器正常运行。
  • 防止重复注册任务:通过记录已注册任务的 ID,避免重复注册。
  • 支持并发配置 :根据 concurrent 字段,决定任务是否需要加锁防止并发。

优化后的代码如下:

timer/loader.go

go 复制代码
func LoadJobsFromDB(cr *cron.Cron) {
	var jobs []model.SysJob
	err := global.GVA_DB.Where("status = ?", 1).Find(&jobs).Error
	if err != nil {
		global.GVA_LOG.Error("加载定时任务失败", zap.Error(err))
		return
	}

	registered := make(map[uint]bool) // 记录已注册任务,防止重复注册

	for _, job := range jobs {
		jobCopy := job

		// 检查任务是否已注册
		if registered[jobCopy.ID] {
			global.GVA_LOG.Warn("任务已注册,跳过: " + jobCopy.Name)
			continue
		}
		registered[jobCopy.ID] = true

		taskFunc, err := GetTask(jobCopy.InvokeTarget)
		if err != nil {
			global.GVA_LOG.Warn("找不到任务函数: " + jobCopy.InvokeTarget)
			continue
		}

		var jobLock sync.Mutex // 每个任务独立锁

		_, err = cr.AddFunc(jobCopy.Cron, func() {
			defer func() {
				if r := recover(); r != nil {
					global.GVA_LOG.Error("任务运行异常: "+jobCopy.Name, zap.Any("panic", r))
					_ = global.GVA_DB.Create(&model.SysJobLog{
						JobID:     jobCopy.ID,
						JobName:   jobCopy.Name,
						Args:      jobCopy.Args,
						StartTime: time.Now(),
						Duration:  0,
						Status:    "fail",
						ErrorMsg:  fmt.Sprintf("panic: %v", r),
					}).Error
				}
			}()

			execFunc := func() {
				start := time.Now()
				global.GVA_LOG.Info("⏱ 开始任务: " + jobCopy.Name)

				err := taskFunc(jobCopy.Args)
				duration := time.Since(start).Milliseconds()

				log := model.SysJobLog{
					JobID:     jobCopy.ID,
					JobName:   jobCopy.Name,
					Args:      jobCopy.Args,
					StartTime: start,
					Duration:  duration,
					Status:    "success",
				}
				if err != nil {
					log.Status = "fail"
					log.ErrorMsg = err.Error()
					global.GVA_LOG.Error("任务失败: "+jobCopy.Name, zap.Any("err", err))
				} else {
					global.GVA_LOG.Info("任务完成: " + jobCopy.Name)
				}
				_ = global.GVA_DB.Create(&log).Error
			}

			if jobCopy.Concurrent {
				// 并发执行
				go execFunc()
			} else {
				// 非并发执行,使用锁
				jobLock.Lock()
				execFunc()
				jobLock.Unlock()
			}
		})

		if err != nil {
			global.GVA_LOG.Error("注册任务失败: "+jobCopy.Name, zap.Any("err", err))
		}
	}
}

这样一来,我们的任务加载器就变得更加稳健了:

  • 异常保护:即使任务 panic,调度器依然稳定运行。
  • 任务唯一性:避免重复注册,保持任务列表清晰有序。
  • 并发控制 :任务可以根据 concurrent 配置决定是否加锁执行,保证任务执行的安全性。

三、实现任务「动态热更新」功能

当前我们的任务加载器是在项目启动时统一加载任务配置并注册到调度器中的。

虽然已经支持了任务并发控制和异常保护,但是每当任务状态发生变化(比如启用、停用、删除任务),我们仍然需要手动重启服务才能使配置生效。

实现任务热更新!

让任务状态变化后(比如后台管理页面点击启用/停用按钮),调度器可以实时响应变化,实现真正的动态任务调度,无需重启服务。

实现思路:拆分加载器,支持「动态注册 & 动态移除任务」

为了实现任务的动态热更新,我们需要对现有的加载器逻辑进行拆分优化。

目前的 LoadJobsFromDB() 方法主要是用于项目启动时批量初始化任务,但要实现后台动态新增、修改、删除任务实时生效,我们必须让任务管理具备「实时操作能力」。

因此,我们将引入 动态任务管理接口 ,通过维护内存中的任务映射关系,实现对调度器的实时操作。

具体来说,我们需要提供以下两个核心能力:

功能 说明
动态注册任务 当后台新增任务或启用任务时,调用 RegisterJob(job model.SysJob),实时注册到调度器,无需重启服务。
动态移除任务 当后台删除任务或停用任务时,调用 RemoveJob(jobID uint),立即从调度器中移除对应任务,释放资源。

通过这两个接口,我们就可以在后台灵活动态地控制任务的生命周期,而不再依赖项目重启。


配合「任务映射表」设计

为了支撑上面的两个功能,我们还需要维护一个任务映射表,记录当前已注册任务的状态:

go 复制代码
// jobID -> cron.EntryID 映射关系
var jobEntryMap = make(map[uint]cron.EntryID)

这样我们就可以做到:

  • 动态新增任务:注册成功后保存 jobID 和 EntryID 映射;
  • 动态移除任务:通过 jobID 快速找到对应 EntryID,从调度器中移除;
  • 任务状态查询 / 批量刷新:后续扩展也非常方便。

动态任务管理:实现任务的动态注册与移除

在上面,我们设计了任务管理方案:通过维护 jobID → EntryID 的映射关系,结合两个对外方法,实现任务的动态管理。

接下来,我们就来实现核心功能代码。

修改后的任务管理器代码

timer/manager.go

go 复制代码
package timer

import (
    "fmt"
    "github.com/robfig/cron/v3"
    "go-gin-demo/global"
    "go-gin-demo/model"
    "go-gin-demo/timer/tasks"
    "go.uber.org/zap"
    "sync"
    "time"
)

// 内存任务映射表:jobID -> cron.EntryID
var jobEntryMap = make(map[uint]cron.EntryID) 
var jobMapLock sync.Mutex

// RegisterAllTasks 注册所有任务到注册表中
func RegisterAllTasks() {
    RegisterTask("ClearOrders", tasks.ClearOrders)
}

// LoadJobsFromDB 批量加载数据库任务到调度器
func LoadJobsFromDB(cr *cron.Cron) {
    var jobs []model.SysJob
    err := global.GVA_DB.Where("status = ?", 1).Find(&jobs).Error
    if err != nil {
       global.GVA_LOG.Error("加载定时任务失败", zap.Any("err", err))
       return
    }
    for _, job := range jobs {
       jobCopy := job
       _ = RegisterJob(jobCopy)
    }
}

// RegisterJob 动态注册任务
func RegisterJob(job model.SysJob) error {
    jobMapLock.Lock()
    defer jobMapLock.Unlock()

    // 先移除已存在的(防止重复)
    if entryID, exists := jobEntryMap[job.ID]; exists {
       global.GVA_CRON.Remove(entryID)
       delete(jobEntryMap, job.ID)
    }

     // 获取任务函数
    taskFunc, err := GetTask(job.InvokeTarget)
    if err != nil {
       global.GVA_LOG.Warn("找不到任务函数: " + job.InvokeTarget)
       return err
    }

    // 每个任务一个互斥锁,防止重复执行
    var jobLock sync.Mutex
    entryID, err := global.GVA_CRON.AddFunc(job.Cron, func() {
       defer func() {
          if r := recover(); r != nil {
             global.GVA_LOG.Error("任务运行异常: "+job.Name, zap.Any("panic", r))
             _ = global.GVA_DB.Create(&model.SysJobLog{
                JobID:     job.ID,
                JobName:   job.Name,
                Args:      job.Args,
                StartTime: time.Now(),
                Duration:  0,
                Status:    "fail",
                ErrorMsg:  fmt.Sprintf("panic: %v", r),
             }).Error
          }
       }()

       execFunc := func() {
          start := time.Now()
          global.GVA_LOG.Info("⏱ 开始任务: " + job.Name)
          err := taskFunc(job.Args)
          duration := time.Since(start).Milliseconds()

          log := model.SysJobLog{
             JobID:     job.ID,
             JobName:   job.Name,
             Args:      job.Args,
             StartTime: start,
             Duration:  duration,
             Status:    "success",
          }
          if err != nil {
             log.Status = "fail"
             log.ErrorMsg = err.Error()
             global.GVA_LOG.Error("任务失败: "+job.Name, zap.Any("err", err))
          } else {
             global.GVA_LOG.Info("任务完成: " + job.Name)
          }
          _ = global.GVA_DB.Create(&log).Error
       }

       // 判断任务是否允许并发执行
       if job.Concurrent {
          go execFunc()
       } else {
          jobLock.Lock()
          execFunc()
          jobLock.Unlock()
       }
    })

    if err != nil {
       global.GVA_LOG.Error("注册任务失败: "+job.Name, zap.Any("err", err))
       return err
    }

    jobEntryMap[job.ID] = entryID
    global.GVA_LOG.Info("动态注册任务成功: " + job.Name)
    return nil

}

// RemoveJob 动态移除任务
func RemoveJob(jobID uint) {
    jobMapLock.Lock()
    defer jobMapLock.Unlock()

    if entryID, ok := jobEntryMap[jobID]; ok {
       global.GVA_CRON.Remove(entryID)
       delete(jobEntryMap, jobID)
       global.GVA_LOG.Info(fmt.Sprintf("任务移除成功: jobID=%d", jobID))
    }
}

实现逻辑说明

  1. 任务注册流程

    • 检查任务是否已存在,存在则先移除;
    • 注册任务到 cron 调度器;
    • 保存 jobEntryMap 映射关系,便于后续管理。
  2. 任务移除流程

    • 通过 jobID 查询映射表;
    • 调用 cron 的 Remove() 方法,删除任务;
    • 从映射表中清除记录。
  3. 互斥与异常控制

    • 每个任务独立互斥锁,避免重复执行;
    • 执行任务函数时加上 recover(),防止任务 panic 崩溃调度器。

这样,我们的任务管理器就拥有了完整的「动态注册 & 移除」能力。


为什么要使用 sync.Mutex 保护 map?

在我们上面的代码中,我们维护了一个全局任务映射表:

go 复制代码
var jobEntryMap = make(map[uint]cron.EntryID)

这个 map 是全局共享的,并且在多个 goroutine 中同时读写。

因为:

  • 注册任务和移除任务操作,通常是后台通过接口触发的;
  • 有时候我们还会通过「任务热加载」定时拉取数据库任务配置;
  • 这些行为通常是异步并发执行的。

没有加锁会发生什么?

如果我们没有对 map 加锁保护,在并发情况下,就会发生:

  • 一个 goroutine 正在往 map 里写入任务:

    go 复制代码
    jobEntryMap[job.ID] = entryID
  • 另一个 goroutine 正在读或者删除任务:

    go 复制代码
    if entryID, exists := jobEntryMap[jobID]; exists {
        delete(jobEntryMap, jobID)
    }

此时 Go 运行时会直接 panic,报错如下:

go 复制代码
fatal error: concurrent map writes

注意!Go 的 map 本身是非线程安全的,并发读写会导致运行时崩溃(不像 PHP 那样只是逻辑错误,Go 是直接 crash)。

Go 原生提供的解决方案:sync.Mutex

为了解决这个问题,Go 提供了原生的互斥锁 sync.Mutex

它的作用就是:在多 goroutine 并发访问共享资源时,通过加锁保证操作的原子性。

在我们的项目里,定义锁对象:

go 复制代码
var jobMapLock sync.Mutex

然后在访问或修改 map 时,使用:

go 复制代码
jobMapLock.Lock()
// 安全操作 map
jobMapLock.Unlock()

就像我们在 PHP 里使用锁一样:

php 复制代码
$lock->acquire();
// 安全操作 Redis / 文件 / 数据库
$lock->release();

这样,无论多少个 goroutine 同时访问任务映射表,都会被排队等待执行,避免了并发读写冲突。

为什么必须加锁?

来看我们这段关键代码:

go 复制代码
if entryID, exists := jobEntryMap[job.ID]; exists {
    global.GVA_CRON.Remove(entryID)
    delete(jobEntryMap, job.ID)
}

如果多个 goroutine 同时执行(比如后台操作员点了两次"启用任务"按钮),就会发生:

  • 一个协程正在判断并移除任务;
  • 另一个协程同时在写入新的任务。

这就导致:

  • map 读写冲突
  • 多次重复写入,状态混乱
  • 甚至触发 fatal error,程序崩溃

加上互斥锁后,就可以这样:

go 复制代码
jobMapLock.Lock()

if entryID, exists := jobEntryMap[job.ID]; exists {
    global.GVA_CRON.Remove(entryID)
    delete(jobEntryMap, job.ID)
}

jobMapLock.Unlock()

这样,整个 map 的操作就是「原子操作」,确保在操作期间其它协程无法修改 map,保证线程安全。

串行任务的执行行为分析

在上一篇文章中,我们提到过这样一个场景:

  • Cron 表达式:

    cron 复制代码
    */1 * * * * * 

    每秒触发一次任务。

  • 任务耗时:

    假设任务执行时间为 5 秒

  • 任务并发配置:

    设置为 不允许并发,也就是:

    go 复制代码
    job.Concurrent = false

此时,在我们的任务执行逻辑中,走的就是串行执行分支:

go 复制代码
jobLock.Lock()
execFunc()
jobLock.Unlock()

这样会发生什么呢?

虽然 cron 调度器会每秒钟尝试触发一次任务,但由于我们加了互斥锁,任务会串行执行,后续触发的任务都会被阻塞在 jobLock.Lock() 上,必须等前一个任务执行完成后才会继续执行下一个。

结果:任务触发是每秒一次,但任务实际执行是:排队等待,上一个任务释放锁之后才开始。

也就是说,任务调度频率 > 任务处理能力,就会导致任务堆积

实际运行行为图示

时间(秒) 是否触发调度 是否真正执行任务
0 触发 执行
1 触发 被锁阻塞
2 触发 被锁阻塞
3 触发 被锁阻塞
4 触发 被锁阻塞
5 触发 上一个任务完成,开始执行

我们看到的效果并不是「每秒执行」,而是「任务排队」,实际上每 5 秒才执行一次任务。

这就是「任务堆积 / 拖延」问题

当任务调度频率远高于任务实际执行时间时,且配置为非并发执行(concurrent = false),就会导致:

  • 任务排队等待执行;
  • 后续任务全部被阻塞;
  • 如果任务执行时间持续过长,还可能导致任务积压,最终阻塞整个任务链路。

解决方案:两种场景应对任务堆积问题

针对上面「任务堆积 / 拖延」问题,我们可以根据不同业务场景,选择合适的解决策略。

场景 1:允许并发执行的任务(如:打印日志、爬取数据)

对于一些无状态、幂等的任务(例如日志输出、数据采集、爬虫任务等),我们可以允许任务并发执行,每次调度时开一个新的 goroutine 执行任务,互不干扰。

做法很简单,设置 job.Concurrent = true,让任务每次都开 goroutine 跑:

go 复制代码
if job.Concurrent {
	go execFunc() // 每次触发调度,开新 goroutine 执行任务
}

效果:

  • 每秒调度一次任务,任务独立并发执行;
  • 没有任务堆积,调度器畅通无阻。

注意事项:

  • 并发任务要注意共享变量的线程安全;
  • 例如任务中涉及写文件、写数据库、修改全局变量时,需要额外做同步保护。

场景 2:不允许并发执行的任务(如:发工资、库存结算)

对于需要严格保证任务单次执行、避免重复处理的场景(例如:发工资、结算库存等),

如果继续使用「锁排队」方案,反而会导致任务堆积,调度器被拖慢。
正确的做法是:丢弃正在执行期间的重复调度,避免阻塞。

方案:运行状态判断,任务未完成时跳过本轮调度。

实现方式:使用运行状态 map 记录任务是否在执行中。

go 复制代码
var runningJobMap = sync.Map{} // jobID -> bool

在任务执行前进行判断:

go 复制代码
if _, running := runningJobMap.LoadOrStore(job.ID, true); running {
	global.GVA_LOG.Warn("任务正在运行中,跳过本轮调度: " + job.Name)
	return
}
defer runningJobMap.Delete(job.ID)

效果:

  • 调度器持续正常运行,任务不会因为堆积而阻塞;
  • 保证任务不重入,避免业务重复处理;
  • 保证调度器稳定,不会出现「堆积爆炸」。

基于方案实现任务调度执行优化

接下来,我们就基于上面的设计方案,完善任务执行逻辑。

在任务注册时,根据 job.Concurrent 字段,动态判断执行模式:

修改后的任务执行部分代码

go 复制代码
// 根据 job.Concurrent 判断是否允许并发执行
if job.Concurrent {
	// 并发模式:直接启动新 goroutine 运行任务,不做运行状态检查
	go execFunc()
} else {
	// 非并发模式:先检查是否已有任务在执行,避免重入
	if _, running := runningJobMap.LoadOrStore(job.ID, true); running {
		global.GVA_LOG.Warn("任务已在运行中,跳过本轮执行: " + job.Name)
		return
	}

	// 执行任务,并在结束后删除运行状态标记
	go func() {
		defer runningJobMap.Delete(job.ID)

		// 串行执行任务时,进一步加锁防止内部重复执行
		jobLock.Lock()
		execFunc()
		jobLock.Unlock()
	}()
}

说明:

  • 并发模式:

    • 每次调度触发,直接新开 goroutine 执行任务;
    • 无需判断是否已有任务在运行,适合无状态、幂等的任务场景。
  • 非并发模式:

    • 调度前先通过 runningJobMap 判断任务是否正在执行:

      go 复制代码
      if _, running := runningJobMap.LoadOrStore(job.ID, true); running {
          // 已在运行,直接跳过
          return
      }
    • 如果没有在执行,则启动新的 goroutine,执行任务并在任务结束后删除运行标记。

    • 避免任务重入,防止堆积爆炸,保持调度器畅通。

  • 内部加锁:

    • 即便是在非并发模式下,为了保证任务函数内部的安全性,我们依然加上了 jobLock.Lock(),确保单任务内部不会发生竞态条件。

修改后的任务执行完整代码:

go 复制代码
package timer

import (
    "fmt"
    "github.com/robfig/cron/v3"
    "go-gin-demo/global"
    "go-gin-demo/model"
    "go-gin-demo/timer/tasks"
    "go.uber.org/zap"
    "sync"
    "time"
)

// jobEntryMap 用于存储 jobID 对应的 cron 任务 entryID
var jobEntryMap = make(map[uint]cron.EntryID) // jobID -> entryID

// jobMapLock 用于保护 jobEntryMap 的并发读写
var jobMapLock sync.Mutex

// runningJobMap 用于记录当前正在执行的非并发任务(jobID -> bool)。
// sync.Map 是线程安全的 map,可以并发读写。
var runningJobMap sync.Map

// RegisterAllTasks 注册所有静态任务函数映射(例如在 init 时注册)
// 这里示例注册了 "ClearOrders" 任务,映射到 tasks.ClearOrders 函数
func RegisterAllTasks() {
    RegisterTask("ClearOrders", tasks.ClearOrders)
}

// LoadJobsFromDB 从数据库加载启用的定时任务,并注册到全局 cron 实例中
func LoadJobsFromDB(cr *cron.Cron) {
    var jobs []model.SysJob
    err := global.GVA_DB.Where("status = ?", 1).Find(&jobs).Error
    if err != nil {
       global.GVA_LOG.Error("加载定时任务失败", zap.Any("err", err))
       return
    }

    // 对于循环中的每个 job,显式复制一份防止闭包引用问题
    for _, job := range jobs {
       jobCopy := job
       _ = RegisterJob(jobCopy)
    }
}

// RegisterJob 注册单个任务到全局 cron 实例,并保存映射关系
func RegisterJob(job model.SysJob) error {
    jobMapLock.Lock()
    defer jobMapLock.Unlock()

    // 先移除已存在的(防止重复)
    if entryID, exists := jobEntryMap[job.ID]; exists {
       global.GVA_CRON.Remove(entryID)
       delete(jobEntryMap, job.ID)
    }

    // 通过任务函数名称获取对应的函数
    taskFunc, err := GetTask(job.InvokeTarget)
    if err != nil {
       global.GVA_LOG.Warn("找不到任务函数: " + job.InvokeTarget)
       _ = global.GVA_DB.Create(&model.SysJobLog{
          JobID:     job.ID,
          JobName:   job.Name,
          Args:      job.Args,
          StartTime: time.Now(),
          Duration:  0,
          Status:    "fail",
          ErrorMsg:  fmt.Sprintf("任务函数未注册: %v", err),
       }).Error
       return err
    }

    // jobLock 用于保护同一任务在非并发情况下串行执行
    var jobLock sync.Mutex

    // 将任务注册到 cron 中,AddFunc 返回 entryID,用于后续移除任务
    entryID, err := global.GVA_CRON.AddFunc(job.Cron, func() {
       // 捕获任务中的 panic,防止整个进程崩溃
       defer func() {
          if r := recover(); r != nil {
             global.GVA_LOG.Error("任务运行异常: "+job.Name, zap.Any("panic", r))
             _ = global.GVA_DB.Create(&model.SysJobLog{
                JobID:     job.ID,
                JobName:   job.Name,
                Args:      job.Args,
                StartTime: time.Now(),
                Duration:  0,
                Status:    "fail",
                ErrorMsg:  fmt.Sprintf("panic: %v", r),
             }).Error
          }
       }()

       // 定义执行任务的逻辑
       execFunc := func() {
          start := time.Now()
          global.GVA_LOG.Info("⏱ 开始任务: " + job.Name)
          err := taskFunc(job.Args)
          duration := time.Since(start).Milliseconds()

          log := model.SysJobLog{
             JobID:     job.ID,
             JobName:   job.Name,
             Args:      job.Args,
             StartTime: start,
             Duration:  duration,
             Status:    "success",
          }
          if err != nil {
             log.Status = "fail"
             log.ErrorMsg = err.Error()
             global.GVA_LOG.Error("任务失败: "+job.Name, zap.Any("err", err))
          } else {
             global.GVA_LOG.Info("任务完成: " + job.Name)
          }
          _ = global.GVA_DB.Create(&log).Error
       }

       // 根据 job.Concurrent 判断是否允许并发执行
       if job.Concurrent {
          // 并发模式:直接启动新 goroutine 运行任务,不做运行状态检查
          go execFunc()
       } else {
          // 非并发模式:先检查是否已有任务在执行,避免重入
          if _, running := runningJobMap.LoadOrStore(job.ID, true); running {
             global.GVA_LOG.Warn("任务已在运行中,跳过本轮执行: " + job.Name)
             return
          }
          // 执行任务,并在结束后删除运行状态标记
          go func() {
             defer runningJobMap.Delete(job.ID)
             // 串行执行任务时,如果需要避免同一任务内部重复执行,可以加锁
             jobLock.Lock()
             execFunc()
             jobLock.Unlock()

             // 安全兜底:任务结束后检测任务是否仍然在 jobEntryMap 中
             jobMapLock.Lock()
             _, exists := jobEntryMap[job.ID]
             jobMapLock.Unlock()
             if !exists {
                global.GVA_LOG.Warn(fmt.Sprintf("任务执行完成,但任务已被后台移除: jobID=%d, jobName=%s", job.ID, job.Name))
                _ = global.GVA_DB.Create(&model.SysJobLog{
                   JobID:     job.ID,
                   JobName:   job.Name,
                   Args:      job.Args,
                   StartTime: time.Now(),
                   Duration:  0,
                   Status:    "warn",
                   ErrorMsg:  "任务执行完成,但任务已被后台移除",
                }).Error
             }
          }()
       }
    })

    if err != nil {
       global.GVA_LOG.Error("注册任务失败: "+job.Name, zap.Any("err", err))
       return err
    }

    // 保存任务的 entryID 到映射表中,方便后续移除任务
    jobEntryMap[job.ID] = entryID
    global.GVA_LOG.Info(" 动态注册任务成功: " + job.Name)
    return nil
}

// RemoveJob 根据 jobID 移除任务
func RemoveJob(jobID uint) {
    jobMapLock.Lock()
    defer jobMapLock.Unlock()

    if entryID, ok := jobEntryMap[jobID]; ok {
       global.GVA_CRON.Remove(entryID)
       delete(jobEntryMap, jobID)
       global.GVA_LOG.Info(fmt.Sprintf("任务移除成功: jobID=%d", jobID))
    } else {
       global.GVA_LOG.Warn(fmt.Sprintf("尝试移除任务,但任务不存在: jobID=%d", jobID))
    }
}

定时任务完整执行流程讲解

到目前为止,我们的定时任务系统已经完成了「设计 → 编码 → 效果验证」。

为了让整个体系更加清晰,下面我们结合上面的最终代码,来梳理一下定时任务的完整执行流程。

1. 启动时,初始化加载数据库中的任务

入口:LoadJobsFromDB()

go 复制代码
func LoadJobsFromDB(cr *cron.Cron) {
	var jobs []model.SysJob
	err := global.GVA_DB.Where("status = ?", 1).Find(&jobs).Error
	// 错误处理...
	for _, job := range jobs {
		jobCopy := job // 避免闭包引用问题
		_ = RegisterJob(jobCopy)
	}
}
  • 查询数据库中「启用状态」的任务
  • 循环注册所有任务到调度器
  • 每个任务调用 RegisterJob() 进行注册

2. 任务注册 RegisterJob 流程

入口:RegisterJob(job model.SysJob)

go 复制代码
jobMapLock.Lock()
defer jobMapLock.Unlock()
  • 首先对 jobEntryMap 加锁,避免并发读写 map 导致冲突。
2.1. 如果任务已存在,先移除:
go 复制代码
if entryID, exists := jobEntryMap[job.ID]; exists {
	global.GVA_CRON.Remove(entryID)
	delete(jobEntryMap, job.ID)
}
  • 避免重复注册,防止任务多次执行。
2.2. 检查任务函数是否注册:
go 复制代码
taskFunc, err := GetTask(job.InvokeTarget)
if err != nil {
	global.GVA_LOG.Warn("找不到任务函数: " + job.InvokeTarget)
	return err
}
  • 判断任务目标函数是否存在,避免空指针异常。
2.3. 注册任务到调度器:
go 复制代码
entryID, err := global.GVA_CRON.AddFunc(job.Cron, func() { ... })
  • 注册任务到 cron 调度器,返回 entryID。
  • 用于后续动态移除任务。

3. 任务执行流程

任务执行函数内逻辑:

3.1. 异常保护
go 复制代码
defer func() {
	if r := recover(); r != nil {
		// 写日志 + 落库
	}
}()
  • 防止任务 panic,保护整个调度器稳定。

3.2. 定义任务具体执行逻辑

go 复制代码
execFunc := func() {
	start := time.Now()
	// 执行业务逻辑
	duration := time.Since(start).Milliseconds()
	// 记录日志和数据库执行记录
}
  • 记录任务开始时间、结束时间
  • 执行完成后写入日志和数据库
3.3. 执行模式判断(并发 / 非并发)

如果允许并发:

go 复制代码
if job.Concurrent {
	go execFunc()
}
  • 每次触发调度新建 goroutine 执行,互不影响。

如果不允许并发:

go 复制代码
if _, running := runningJobMap.LoadOrStore(job.ID, true); running {
	// 任务已在执行中,跳过
	return
}
go func() {
	defer runningJobMap.Delete(job.ID)
	jobLock.Lock()
	execFunc()
	jobLock.Unlock()
}()
  • 执行前判断任务是否正在运行中,避免重入
  • 执行后删除运行标记,释放任务状态

4. 动态移除任务 RemoveJob 流程

入口:RemoveJob(jobID uint)

go 复制代码
jobMapLock.Lock()
defer jobMapLock.Unlock()

if entryID, ok := jobEntryMap[jobID]; ok {
	global.GVA_CRON.Remove(entryID)
	delete(jobEntryMap, jobID)
	global.GVA_LOG.Info(fmt.Sprintf("任务移除成功: jobID=%d", jobID))
}
  • 加锁保护 map 操作
  • 调用调度器的 Remove() 方法移除任务
  • 删除 jobEntryMap 映射关系
  • 记录任务移除日志

完整流程链路

scss 复制代码
系统启动 → LoadJobsFromDB → RegisterJob
                          → 检查是否重复任务
                          → 检查目标函数是否注册
                          → 注册到调度器(AddFunc)
                          → 调度器触发执行
                                → execFunc() 执行业务逻辑
                                → 根据是否并发,决定执行模式
                                → 成功失败记录日志
动态删除任务 → RemoveJob → 移除任务映射和调度器中的任务

附:Service 层实现动态添加与修改任务

为了配合我们完成的任务调度器核心模块,我们需要在 service 层提供任务新增和修改的功能,供后台接口调用,实现任务的「动态新增 / 修改 / 删除」。其他控制器路由等模块逻辑的这里就不展示了哈。

下面是完整的示例代码:

service/job.go

go 复制代码
package service

import (
	"errors"
	"go-gin-demo/global"
	"go-gin-demo/model"
	"go-gin-demo/model/request"
	"go-gin-demo/timer"
)

// JobCreate 创建任务
func JobCreate(req request.AddJobRequest) error {
	var job model.SysJob

	// 检查任务名称是否已存在
	if err := global.GVA_DB.Where("name = ?", req.Name).First(&job).Error; err == nil {
		return errors.New("任务名称已存在")
	}

	// 检查调用目标函数名是否已存在
	if err := global.GVA_DB.Where("invoke_target = ?", req.InvokeTarget).First(&job).Error; err == nil {
		return errors.New("调用目标函数名已存在")
	}

	// 构建任务数据
	job = model.SysJob{
		Name:         req.Name,
		Group:        req.Group,
		Cron:         req.Cron,
		InvokeTarget: req.InvokeTarget,
		Args:         req.Args,
		Status:       *req.Status,
		Concurrent:   req.Concurrent,
		Remark:       req.Remark,
	}

	// 数据库创建任务
	if err := global.GVA_DB.Create(&job).Error; err != nil {
		return errors.New("创建任务失败")
	}

	// 如果任务状态为启用,立即注册到调度器
	if *req.Status == 1 {
		return timer.RegisterJob(job)
	}

	return nil
}

// JobUpdate 修改任务
func JobUpdate(req request.UpdateJobRequest) error {
	var job model.SysJob

	// 查询任务是否存在
	if err := global.GVA_DB.Where("id = ?", req.ID).First(&job).Error; err != nil {
		return errors.New("任务不存在")
	}

	// 更新任务状态字段
	if err := global.GVA_DB.Model(&job).Update("status", *req.Status).Error; err != nil {
		return errors.New("更新任务状态失败")
	}

	// 状态判断:启用 / 停用
	if *req.Status == 1 {
		// 启用任务:重新注册任务
		if err := timer.RegisterJob(job); err != nil {
			return errors.New("注册任务失败")
		}
	} else {
		// 停用任务:移除任务
		timer.RemoveJob(job.ID)
	}

	return nil
}

说明

  • JobCreate() 方法:

    • 首先判断任务名称和目标函数是否重复,避免注册冲突;
    • 数据库中插入任务记录;
    • 如果任务状态是「启用」,调用 RegisterJob(),即时注册任务到调度器。
  • JobUpdate() 方法:

    • 检查任务是否存在;
    • 更新任务状态;
    • 启用状态下重新注册任务,禁用状态则移除任务。

这样,通过后台管理页面,前端调用这两个接口就能实现:

  • 新增任务:立刻生效,添加调度任务;
  • 修改任务状态:启用则加载,停用则立即移除任务。

最后

经过上面两篇文章的完整设计与实现,我们完成了一个功能相对完善的 定时任务调度中心

目前已经具备的能力包括:

  • 动态任务管理:支持任务的新增、修改、删除,实时生效,无需重启服务。
  • 灵活的并发控制:支持任务并发 / 串行模式,根据业务需求灵活选择。
  • 任务执行日志记录:每次任务执行都自动记录日志,包含成功、失败、异常等信息,方便排查问题。
  • 异常保护机制:任务执行过程中发生 panic 也能被安全捕获,避免调度器崩溃。
  • 线程安全保障:通过合理的锁机制,保障并发场景下任务映射表和状态表的数据安全。

上面我们实现的任务调度中心,是比较适合我们目前的单服务器项目体系;但如果将来项目采用集群部署或微服务架构,就需要考虑任务重复执行、状态同步、分布式锁等问题。针对这种场景,可以进一步演进为分布式定时任务调度中心,实现多节点协作,统一管理,避免任务冲突和状态不一致。

相关推荐
chxii22 分钟前
2.2goweb解析http请求信息
go
Asthenia041227 分钟前
为什么说MVCC无法彻底解决幻读的问题?
后端
Asthenia041228 分钟前
面试官问我:三级缓存可以解决循环依赖的问题,那两级缓存可以解决Spring的循环依赖问题么?是不是无法解决代理对象的问题?
后端
Asthenia041229 分钟前
面试复盘:使用 perf top 和火焰图分析程序 CPU 占用率过高
后端
Asthenia041229 分钟前
面试复盘:varchar vs char 以及 InnoDB 表大小的性能分析
后端
Asthenia041230 分钟前
面试问题解析:InnoDB中NULL值是如何记录和存储的?
后端
Asthenia04121 小时前
面试官问我:TCP发送到IP存在但端口不存在的报文会发生什么?
后端
Asthenia04121 小时前
HTTP 相比 TCP 的好处是什么?
后端
Asthenia04121 小时前
MySQL count(*) 哪个存储引擎更快?为什么 MyISAM 更快?
后端
Asthenia04121 小时前
面试官问我:UDP发送到IP存在但端口不存在的报文会发生什么?
后端