最近想设计一个动态定时器管理功能架构,既能直观的看到定时效果,又能测试定时的鲁棒性等相关性能问题,整体设计需求如下:当定时任务的配置(如 Cron 表达式)存储在数据库中,并且可能在运行时发生变化时,我们需要实现一个能够动态加载和更新定时器的方案。基础版相关代码已实现,项目开源地址:https://github.com/feiyuluoye/cron-auto-basic
1. 总体设计思路
动态定时器的核心是:
- 任务配置存储:将任务的 Cron 表达式、任务名称、状态(启用/禁用)等信息存储在数据库表中。
- 调度器初始化:程序启动时,从数据库读取所有启用状态的任务,注册到 Cron 调度器。
- 配置变更监听:运行时持续或定期检测数据库中的配置变化(增、删、改),并动态调整调度器中的任务。
- 任务执行与状态管理:确保任务执行的幂等性,以及在更新配置时对正在执行的任务有妥善处理(等待完成或强制取消)。
2. 数据库表设计示例
假设我们使用 MySQL,表结构如下:
sql
CREATE TABLE `cron_jobs` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL COMMENT '任务名称,唯一',
`cron_expr` varchar(50) NOT NULL COMMENT 'Cron 表达式',
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1启用 0禁用',
`task_data` json DEFAULT NULL COMMENT '任务执行所需的参数',
`version` int(11) NOT NULL DEFAULT '1' COMMENT '乐观锁版本,用于检测变更',
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
说明:
status控制任务是否参与调度。version可用于乐观锁,避免并发更新冲突。updated_at可用于轮询检测变更(定期查询更新时间大于上次检查时间的记录)。
3. 动态调度器的实现
3.1 核心组件
- Cron 实例 :
cron.Cron,负责任务调度。 - 任务注册表 :一个内存 map,记录当前已注册的任务 ID 与
cron.EntryID的对应关系,便于后续移除。 - 配置监听器:独立 goroutine,定期查询数据库变化,并根据变化更新调度器。
3.2 启动时初始化
go
type JobManager struct {
cron *cron.Cron
jobRegistry map[int]cron.EntryID // key: 任务ID, value: cron EntryID
db *sql.DB
mu sync.RWMutex
stopCh chan struct{}
}
func NewJobManager(db *sql.DB) *JobManager {
return &JobManager{
cron: cron.New(cron.WithSeconds()), // 根据需要开启秒级支持
jobRegistry: make(map[int]cron.EntryID),
db: db,
stopCh: make(chan struct{}),
}
}
// 启动时加载所有启用任务
func (jm *JobManager) LoadJobs() error {
rows, err := jm.db.Query(`SELECT id, name, cron_expr, task_data FROM cron_jobs WHERE status = 1`)
if err != nil {
return err
}
defer rows.Close()
jm.mu.Lock()
defer jm.mu.Unlock()
for rows.Next() {
var id int
var name, cronExpr string
var taskData json.RawMessage
if err := rows.Scan(&id, &name, &cronExpr, &taskData); err != nil {
return err
}
// 注册任务到 cron
entryID, err := jm.cron.AddFunc(cronExpr, jm.makeJobFunc(id, name, taskData))
if err != nil {
// 记录错误,继续加载其他任务
log.Printf("failed to add job %s: %v", name, err)
continue
}
jm.jobRegistry[id] = entryID
log.Printf("loaded job: %s (id=%d) with cron %s", name, id, cronExpr)
}
return nil
}
// 包装实际任务函数,可以加入一些通用逻辑如分布式锁、超时控制等
func (jm *JobManager) makeJobFunc(jobID int, name string, taskData json.RawMessage) func() {
return func() {
// 这里可以执行实际任务逻辑,例如根据 taskData 解析参数
log.Printf("job %s started, data: %s", name, string(taskData))
// 执行具体业务逻辑...
// 注意考虑任务超时、panic 恢复等
}
}
3.3 配置变更监听(轮询方式)
最简单的实现是启动一个 goroutine,定期查询数据库中有变更的任务(例如根据 updated_at 字段)。
go
func (jm *JobManager) WatchChanges(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
var lastCheckTime time.Time
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
jm.syncJobs(ctx, lastCheckTime)
lastCheckTime = time.Now()
}
}
}
func (jm *JobManager) syncJobs(ctx context.Context, since time.Time) {
// 查询自 since 以来有更新的任务(包括新增、修改、删除)
rows, err := jm.db.QueryContext(ctx, `
SELECT id, name, cron_expr, task_data, status
FROM cron_jobs
WHERE updated_at > ?
`, since)
if err != nil {
log.Printf("failed to query changed jobs: %v", err)
return
}
defer rows.Close()
for rows.Next() {
var id int
var name, cronExpr string
var taskData json.RawMessage
var status int
if err := rows.Scan(&id, &name, &cronExpr, &taskData, &status); err != nil {
log.Printf("scan changed job error: %v", err)
continue
}
jm.mu.Lock()
oldEntryID, exists := jm.jobRegistry[id]
if status == 0 { // 禁用状态,需要移除任务
if exists {
jm.cron.Remove(oldEntryID)
delete(jm.jobRegistry, id)
log.Printf("removed job %s (id=%d)", name, id)
}
} else { // 启用状态,添加或更新
if exists {
// 先移除旧任务
jm.cron.Remove(oldEntryID)
delete(jm.jobRegistry, id)
}
// 注册新任务
newEntryID, err := jm.cron.AddFunc(cronExpr, jm.makeJobFunc(id, name, taskData))
if err != nil {
log.Printf("failed to update job %s: %v", name, err)
} else {
jm.jobRegistry[id] = newEntryID
log.Printf("updated job %s (id=%d) with cron %s", name, id, cronExpr)
}
}
jm.mu.Unlock()
}
}
注意:
- 轮询间隔需要权衡实时性和数据库压力,通常设置为 5-10 秒。
- 如果表数据量很大,
updated_at字段应有索引。 - 删除任务的处理:当记录被物理删除时,上述查询无法感知。可以通过软删除(增加
deleted_at字段)或使用SELECT全量比对的方式处理。简单做法是采用软删除,将status设为 0 表示禁用,物理删除可忽略或通过额外机制处理。
3.4 启动调度器
go
func main() {
db := initDB()
defer db.Close()
jm := NewJobManager(db)
// 加载初始任务
if err := jm.LoadJobs(); err != nil {
log.Fatalf("load jobs failed: %v", err)
}
// 启动 cron 调度器
jm.cron.Start()
// 启动配置变更监听(每10秒检查一次)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go jm.WatchChanges(ctx, 10*time.Second)
// 等待退出信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
// 优雅停止:等待正在执行的任务完成,最多等待30秒
shutdownCtx := jm.cron.Stop()
select {
case <-shutdownCtx.Done():
case <-time.After(30 * time.Second):
}
}
4. 更高级的监听方式
轮询方式简单但可能不够实时,且对数据库有一定压力。可以考虑以下优化:
4.1 使用数据库 binlog 或消息通知
- MySQL binlog :通过工具如
go-mysql-elasticsearch或canal监听 binlog 变化,实时推送变更。 - PostgreSQL NOTIFY/LISTEN :数据库触发
NOTIFY,应用监听。 - Redis Pub/Sub:在更新配置时,由管理后台主动发送 Redis 消息,应用消费后重新加载。
4.2 版本号 + 定期全量比对
维护一个全局配置版本号(存储在数据库或 Redis),每次配置变更时递增版本号。应用只需轮询版本号,发生变化时全量重新加载任务配置。这种方式实现简单,适合任务数量不多的场景。
5. 处理任务执行中的特殊情况
5.1 任务超时控制
可以为每个任务设置超时时间,避免任务卡死。可使用 context.WithTimeout 包装任务函数。
5.2 任务并发控制
如果任务执行时间可能超过周期,可以使用 cron 的 Chain 机制(如 SkipIfStillRunning 或 DelayIfStillRunning)。
go
// 创建 chain
chain := cron.NewChain(cron.SkipIfStillRunning(cron.DefaultLogger))
c := cron.New(cron.WithChain(chain))
5.3 任务更新时,正在执行的任务如何处理?
在 syncJobs 中,我们是先移除旧任务再添加新任务。cron.Remove 只是将任务从调度器中移除,不会中断正在执行的 goroutine。这意味着:
- 如果任务在更新时正在运行,它将继续运行直到完成。
- 新任务会按照新 Cron 表达式在下一次触发时执行。
这种处理方式通常可接受。如果希望强制中断,需要在任务函数中支持上下文取消,并在更新时发送取消信号(但实现较复杂,需要维护任务与上下文的映射)。
6. 完整示例代码整合
以下是一个简化的、可直接运行的示例(省略了数据库操作细节,用模拟数据代替):
go
package main
import (
"context"
"encoding/json"
"log"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/robfig/cron/v3"
)
// 模拟数据库中的任务记录
type JobRecord struct {
ID int
Name string
CronExpr string
Status int // 1启用 0禁用
Data map[string]interface{}
}
// 模拟数据库存储,实际应从数据库查询
var mockDB = struct {
sync.RWMutex
jobs map[int]JobRecord
}{
jobs: make(map[int]JobRecord),
}
func initMockDB() {
mockDB.jobs[1] = JobRecord{ID: 1, Name: "job1", CronExpr: "*/5 * * * * *", Status: 1, Data: map[string]interface{}{"msg": "hello"}}
mockDB.jobs[2] = JobRecord{ID: 2, Name: "job2", CronExpr: "*/10 * * * * *", Status: 1, Data: map[string]interface{}{"msg": "world"}}
}
// 模拟获取自某个时间点后有变更的任务
func fetchChangedJobs(since time.Time) []JobRecord {
mockDB.RLock()
defer mockDB.RUnlock()
// 这里简单返回所有任务,实际应根据 updated_at 筛选
var changed []JobRecord
for _, job := range mockDB.jobs {
changed = append(changed, job)
}
return changed
}
type JobManager struct {
cron *cron.Cron
jobRegistry map[int]cron.EntryID
mu sync.RWMutex
}
func NewJobManager() *JobManager {
return &JobManager{
cron: cron.New(cron.WithSeconds()),
jobRegistry: make(map[int]cron.EntryID),
}
}
func (jm *JobManager) LoadJobs() error {
mockDB.RLock()
defer mockDB.RUnlock()
jm.mu.Lock()
defer jm.mu.Unlock()
for id, job := range mockDB.jobs {
if job.Status != 1 {
continue
}
entryID, err := jm.cron.AddFunc(job.CronExpr, jm.makeJobFunc(job))
if err != nil {
log.Printf("failed to add job %s: %v", job.Name, err)
continue
}
jm.jobRegistry[id] = entryID
log.Printf("loaded job: %s (id=%d) with cron %s", job.Name, id, job.CronExpr)
}
return nil
}
func (jm *JobManager) makeJobFunc(job JobRecord) func() {
return func() {
data, _ := json.Marshal(job.Data)
log.Printf("job %s executed, data: %s", job.Name, string(data))
// 模拟任务执行耗时
time.Sleep(2 * time.Second)
}
}
func (jm *JobManager) WatchChanges(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
var lastCheck time.Time
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
jm.syncJobs(ctx, lastCheck)
lastCheck = time.Now()
}
}
}
func (jm *JobManager) syncJobs(ctx context.Context, since time.Time) {
changedJobs := fetchChangedJobs(since)
jm.mu.Lock()
defer jm.mu.Unlock()
for _, job := range changedJobs {
oldEntryID, exists := jm.jobRegistry[job.ID]
if job.Status == 0 { // 禁用
if exists {
jm.cron.Remove(oldEntryID)
delete(jm.jobRegistry, job.ID)
log.Printf("removed job %s (id=%d)", job.Name, job.ID)
}
} else { // 启用或更新
if exists {
jm.cron.Remove(oldEntryID)
delete(jm.jobRegistry, job.ID)
}
newEntryID, err := jm.cron.AddFunc(job.CronExpr, jm.makeJobFunc(job))
if err != nil {
log.Printf("failed to update job %s: %v", job.Name, err)
} else {
jm.jobRegistry[job.ID] = newEntryID
log.Printf("updated job %s (id=%d) with cron %s", job.Name, job.ID, job.CronExpr)
}
}
}
}
func main() {
initMockDB()
jm := NewJobManager()
if err := jm.LoadJobs(); err != nil {
log.Fatal(err)
}
jm.cron.Start()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go jm.WatchChanges(ctx, 10*time.Second)
// 模拟动态修改数据库(例如5秒后禁用job1)
go func() {
time.Sleep(5 * time.Second)
mockDB.Lock()
job := mockDB.jobs[1]
job.Status = 0
mockDB.jobs[1] = job
mockDB.Unlock()
log.Println("admin disabled job1")
}()
// 等待退出信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
// 优雅停止
shutdownCtx := jm.cron.Stop()
select {
case <-shutdownCtx.Done():
log.Println("all jobs finished, exiting")
case <-time.After(30 * time.Second):
log.Println("shutdown timeout, force exit")
}
}
7. 总结与建议
- 从数据库动态获取定时时间数据,核心在于将任务配置外部化,并实现配置变更的监听与同步。
- 简单的轮询方式足够满足大部分业务场景,注意设置合适的轮询间隔和索引优化。
- 对于高实时性要求,可考虑使用数据库 binlog 或消息队列触发更新。
- 在更新任务时,注意处理正在执行的任务(通常允许其完成,不中断)。
- 结合分布式锁可以避免多实例重复执行(参考前一回答中的分布式锁方案)。
通过以上方案,你可以构建一个灵活、可动态配置的定时任务系统,满足业务需求的同时保证代码的可维护性。
8.效果展示


