gin多实例下定时任务重复执行是因为每个进程独立初始化cron调度器,导致n个节点运行n个相同任务;解决方案包括redis分布式锁(需匹配锁过期与任务耗时、原子删锁)或go-quartz(依赖外部存储实现主节点选举)。

为什么Gin多实例下定时任务会重复执行
因为每个Gin进程都独立初始化了cron调度器,比如用robfig/cron/v3时,只要cron.New()被调用,就启动一个本地调度循环——集群里N个节点,就跑N个相同Cron表达式的任务实例。
常见现象包括:数据库清理脚本每天执行N次、报表生成任务并发写入冲突、支付对账任务重复校验导致告警刷屏。
- 这不是Gin的问题,而是调度器本身无分布式协调能力
-
cron默认不感知其他节点,也不检查自己是否是“唯一执行者” - 哪怕用了
gin-vue-admin的timer.go封装,只要没加外部锁机制,照样重复
用Redis分布式锁控制单点执行
最轻量、落地最快的方案:所有节点在真正执行任务前,先抢一个Redis锁,抢到才运行,没抢到直接跳过。
关键点不在锁本身,而在锁的生命周期与任务执行时间的匹配:
- 锁的
expire必须大于任务最长可能耗时(例如任务最多跑80秒,锁设120秒) - 解锁必须用Lua脚本原子校验key和value,防止A节点锁超时自动释放后,B节点误删A的锁
- 推荐用
github.com/go-redis/redis/v9+SET key value EX 120 NX实现加锁 - 别用
time.Sleep模拟等待重试,应使用指数退避+随机抖动(如第一次等100ms,第二次150–250ms)
示例片段:
func safeRunTask(ctx context.Context, taskName string, fn func()) {
lockKey := "cron:lock:" + taskName
lockValue := uuid.NewString()
// 加锁,带自动过期
ok, err := rdb.SetNX(ctx, lockKey, lockValue, 120*time.Second).Result()
if err != nil || !ok {
return // 抢锁失败,不执行
}
defer func() {
// 原子删锁:只删自己设的值
script := redis.NewScript(`if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`)
script.Run(ctx, rdb, []string{lockKey}, lockValue)
}()
fn() // 真正执行任务
}
用go-quartz替代原生cron实现分布式感知
go-quartz不是简单替换cron,它把调度器拆成Scheduler和JobStore两层,后者可插拔为MySQL/PostgreSQL/etcd——节点启动时从同一存储加载任务定义,并通过存储的行级锁或租约机制选举主节点。
相比Redis锁,它的优势在于任务元数据统一管理,但代价是引入额外依赖和复杂度:
- 必须启用
jobstore(如quartz.NewJDBCJobStore),否则仍是单机行为 - 所有节点连接同一个数据库,且表结构需提前初始化(
quartz_schema.sql) - 节点故障时,其他节点会在下次心跳检测后接管任务,存在最多30秒延迟(取决于
checkInterval配置) - 不支持动态修改Cron表达式,除非配合外部API更新数据库中的
TRIGGERS表
gin-vue-admin中timer模块的改造要点
如果你用的是gin-vue-admin,它的server/utils/timer/timed_task.go默认是纯内存调度,改造重点不在加功能,而在“关掉默认行为”:
- 注释掉
t.cronList[cronName].corn.Start()这行——避免每个节点自启调度器 - 把
AddTaskByFuncWithSecond注册的任务改为仅存入全局map,不立即启动 - 新增一个
StartCronOnlyIfLeader方法,内部调用Redis锁判断后再启动对应cron实例 - 注意
taskManager里的corn字段是私有变量,需导出或加包装方法才能控制启停
真正难的不是代码怎么写,而是确认哪一层该加锁:是在HTTP触发入口、还是在cron.EntryID回调里、抑或封装成中间件?选错位置会导致锁粒度太粗(影响其他任务)或太细(漏锁)。











