Go 后台定时任务启停不生效怎么办?先查调度同步链路

Go 后台里的定时任务启停不生效,判断顺序不要从前端开关开始,而是先查数据库状态、再查内存调度同步、最后查执行日志。更稳的排查顺序是:确认数据库里的 status 是否真的变了,再确认保存或切换状态后有没有把这个变化同步到内存里的 cron 调度器,最后再看执行日志有没有写入。很多后台项目的坑就在这里:表里的任务已经禁用,进程里的调度器还拿着旧 job;或者表里已经启用,但没有重新注册到调度器,页面看着正常,到了时间就是不跑。

这篇只讲 Go 后台里的应用内定时任务,不讲 Linux crontab,也不讲 Kubernetes CronJob。核验时点是 2026-08-10,案例来自 XYGo Admin 当前 master 分支和 v1.4.6 Release,项目技术栈是 GoFrame + Vue3,定时任务、RBAC 菜单和后台 API 都在同一个管理后台里。它在这里不是要你照搬项目,而是拿一个真实源码链路当排查样本。

主查询:Go 后台定时任务启停不生效,应该先查哪里

先查三件事。

第一,接口有没有把状态写进库。前端点启用或禁用时,不应该只改 Vue 的表格状态,必须请求后端,例如:

ts 复制代码
// web/src/api/backend/system/cron.ts
export function fetchCronStatus(params: { id: number; status: number }) {
  return adminRequest.post({ url: '/cron/status', params })
}

第二,后端更新 sys_cron.status 之后,有没有同步调度器。只更新数据库不够,因为定时任务通常已经被注册到内存里的 cron 实例。数据库是配置来源,不是正在运行的调度器本身。

go 复制代码
// server/internal/logic/cron/cron.go
func UpdateStatus(ctx context.Context, in *adminin.CronStatusInp) error {
    _, err := dao.SysCron.Ctx(ctx).Where("id", in.Id).Data(g.Map{
        "status":     in.Status,
        "updated_at": uint(time.Now().Unix()),
    }).Update()
    if err != nil {
        return err
    }
    syncJobSchedule(ctx, in.Id)
    return nil
}

第三,调度同步要区分启用和禁用。启用要 StartJob,禁用要 StopJob,不能只靠下一次服务重启。

go 复制代码
func syncJobSchedule(ctx context.Context, id uint64) {
    var item entity.SysCron
    _ = dao.SysCron.Ctx(ctx).Where("id", id).Scan(&item)
    if item.Id == 0 {
        return
    }
    if item.Status == 1 {
        _ = cronlib.StartJob(&cronlib.CronJob{
            Id:      item.Id,
            Name:    item.Name,
            Title:   item.Title,
            Pattern: item.Pattern,
            Params:  item.Params,
            Policy:  item.Policy,
            Count:   item.Count,
        })
    } else {
        cronlib.StopJob(item.Id, item.Name)
    }
}

能走到这里,至少可以排除一类问题:前端开关只是显示问题。如果数据库状态、接口响应和调度同步都能对上,再去看任务函数本身。

子问题一:数据库 status 正确,任务为什么还在跑

这种情况通常不是 SQL 错,而是"旧 job 没停"。排查时先用 SQL 读任务状态,再查执行日志是否在禁用后仍继续写入。

sql 复制代码
SELECT id, title, name, pattern, status, updated_at
FROM sys_cron
WHERE name = 'log_clean';

SELECT id, cron_id, name, status, err_msg, created_at
FROM sys_cron_log
WHERE cron_id = 1
ORDER BY id DESC
LIMIT 20;

如果 sys_cron.status = 0,但禁用后的时间点仍有新日志,优先看后端禁用接口是否执行到了 cronlib.StopJob(item.Id, item.Name)。如果没有这一步,页面上"禁用"只是配置禁用,运行中的 job 还会等下一次 tick。

还有一个容易忽略的点:保存任务也要同步调度。很多后台只在服务启动时加载已启用任务,编辑表达式或参数后没有重新注册,结果页面显示新表达式,实际运行还是旧节奏。XYGo Admin 的 Save 在写库后也调用 syncJobSchedule(ctx, id),这条链路值得照着检查。

go 复制代码
// server/internal/logic/cron/cron.go
func Save(ctx context.Context, in *adminin.CronSaveInp) (uint64, error) {
    // 省略参数检查和 Insert/Update
    syncJobSchedule(ctx, id)
    return id, nil
}

如果你的项目没有类似逻辑,最小修复不是重启服务,而是在保存和状态变更两个入口都补一次调度同步。

子问题二:点击"立即执行"成功,能证明定时调度正常吗

不能。在线执行和定时调度是两条链路。

在线执行只说明这个任务标识能找到、任务函数能跑一次,不能证明它已经按 cron 表达式加入调度器。源码里这两个入口是分开的:

go 复制代码
func OnlineExec(ctx context.Context, id uint64) (string, error) {
    var item entity.SysCron
    err := dao.SysCron.Ctx(ctx).Where("id", id).Scan(&item)
    if err != nil {
        return "", err
    }
    if item.Id == 0 {
        return "", gerror.New("任务不存在")
    }
    return cronlib.OnlineExec(&cronlib.CronJob{
        Id:     item.Id,
        Name:   item.Name,
        Title:  item.Title,
        Params: item.Params,
    })
}

所以排查时不要只点"立即执行"。更可靠的做法是:

  1. 把表达式临时改成一个短周期,比如每分钟一次。

  2. 保存后确认接口返回成功。

  3. 等待两个周期。

  4. sys_cron_log 是否新增记录。

  5. 再把表达式改回去。

如果在线执行成功,但周期日志不新增,问题多半在 StartJob、表达式解析、任务状态同步,或进程里没有真正启动调度器。

子问题三:执行日志没有新增,要查回调还是查列表 total

先分清"没有写入"和"写入了但页面没显示"。

应用内任务要能留下执行记录,一般需要调度器在任务结束时回调业务层写日志。XYGo Admin 的记录入口是 recordLog

go 复制代码
func recordLog(ctx context.Context, cronId uint64, name, title, params string, status int, output, errMsg string, takeMs int) {
    _, _ = dao.SysCronLog.Ctx(ctx).Data(g.Map{
        "cron_id":    cronId,
        "name":       name,
        "title":      title,
        "params":     params,
        "status":     status,
        "output":     output,
        "err_msg":    errMsg,
        "take_ms":    takeMs,
        "created_at": uint(time.Now().Unix()),
    }).Insert()
}

如果表里没有记录,就查调度器有没有设置日志回调。这个调用在服务启动时完成:

go 复制代码
func StartAll(ctx context.Context) {
    cronlib.SetLogCallback(recordLog)
    // 加载 status=1 的任务并 StartJob
}

如果表里有记录,页面却显示空,才去查列表接口和分页 total。后端列表至少要做两件事:先 Count(),再分页 Scan(),最后把 total 放回响应。

go 复制代码
count2, err := m.Clone().Count()
// ...
return &adminin.CronLogListModel{
    List: list,
    PageRes: form.PageRes{
        Page:     in.Page,
        PageSize: in.PageSize,
        Total:    count2,
    },
}, nil

控制器层也要确认没有把 total 丢掉。server/internal/controller/admin/cron.goCronLogList 会把 result.Total 补回 PageRes.Total。这类细节看起来小,但会直接导致"明明有日志,前端分页显示 0"。

子问题四:清空日志失败,和启停不生效是一类问题吗

不是一类,但经常一起出现,因为都在"定时任务管理"页面里。

清空日志失败常见原因是 ORM 不允许无条件删除。GoFrame 对无 WHERE 的 Delete 有保护,直接清空整张日志表可能被拦住。XYGo Admin v1.4.6 的 Release 里提到过"定时任务执行日志清空全部失效",源码里的修复方式是清空全部时补一个恒真条件:

go 复制代码
func LogClear(ctx context.Context, in *adminin.CronLogClearInp) error {
    m := dao.SysCronLog.Ctx(ctx)
    if in.CronId > 0 {
        m = m.Where("cron_id", in.CronId)
    } else {
        m = m.Where("1=1")
    }
    _, err := m.Delete()
    return err
}

这个修复只解决"清空日志"按钮,不解决任务启停。别把两个问题混在一起修。启停不生效查 UpdateStatus -> syncJobSchedule -> StartJob/StopJob;清空失败查 LogClear -> Delete 的 WHERE 条件。

可复制的排查清单

排查线上问题时,可以按下面顺序跑,不要一上来就重启服务。

bash 复制代码
# 1. 查任务配置是否真实更新
SELECT id,title,name,pattern,status,updated_at FROM sys_cron WHERE name='你的任务标识';

# 2. 查最近执行记录
SELECT id,cron_id,name,status,err_msg,take_ms,created_at
FROM sys_cron_log
WHERE name='你的任务标识'
ORDER BY id DESC
LIMIT 20;

# 3. 查后端是否存在两个同步入口
# 保存任务后:Save -> syncJobSchedule
# 切换状态后:UpdateStatus -> syncJobSchedule

# 4. 区分在线执行和周期调度
# 在线执行成功,只能证明任务函数能跑一次,不能证明 StartJob 生效

如果你有接口访问日志,再补一条:看点启停时是否真的请求了 /admin/cron/status,点立即执行时是否请求了 /admin/cron/onlineExec。这两个 URL 在 api/admin/admin_cron.go 和前端 web/src/api/backend/system/cron.ts 里都能对上。

子问题五:保存任务后马上生效,是否一定安全

保存后马上同步调度是必要的,但还要补两个保护。

一个是任务标识必须存在。否则用户在后台随手填一个 name,数据库能保存,调度器却找不到对应函数,到了执行时间只会失败。源码里保存前会检查注册表:

go 复制代码
if cronlib.GetTask(in.Name) == nil {
    return 0, gerror.Newf("任务标识 '%s' 未在代码中注册,请先在 internal/crons/ 中实现并注册", in.Name)
}

另一个是任务标识最好做唯一性检查。两个任务共用同一个 name,后面停止或启动时就很容易停错、覆盖或重复注册。排查重复时可以直接查表:

sql 复制代码
SELECT name, COUNT(*) AS c
FROM sys_cron
GROUP BY name
HAVING COUNT(*) > 1;

如果这条 SQL 有结果,先修数据,再查调度器。否则你会看到很怪的现象:A 任务禁用了,B 任务也停了;或者页面只显示一个任务在跑,日志里却出现两个相同 name 的记录。

适用边界

这套方法适用于 GoFrame、Gin 或其他 Go 后台里"数据库保存任务配置,进程内 cron 调度器负责执行"的设计。只要配置层和运行层分离,就要查同步链路。

它不适用于几类场景:Linux crontab 没跑、Kubernetes CronJob 没创建 Pod、消息队列 Worker 消费失败、浏览器里的 setInterval 定时器异常。这些问题的调度器不在 Go 进程内,排查入口完全不同。

也别把它当成性能优化文章。这里解决的是状态一致性:页面、数据库、内存调度器和执行日志要对得上。

源码案例可看 GitHub 仓库:GitHub - z312193608/xygo-admin: Open-source admin framework built with GoFrame v2 and Vue 3, featuring RBAC, full-stack CRUD code generation, MySQL/PostgreSQL, plugins, and single-binary deployment. · GitHub。核验版本是 2026-08-10 的 master 分支和 v1.4.6 Release。读源码时重点看 server/internal/logic/cron/cron.goserver/internal/controller/admin/cron.goserver/api/admin/admin_cron.goweb/src/api/backend/system/cron.ts。如果你的项目也有"状态改了但任务照跑"的情况,先别急着加锁或重启,把这条链路打一遍,通常能很快定位到漏同步的入口。真正要修的是保存、启停、启动加载和日志回调这几个入口是否一致;只改其中一个入口,线上迟早还会出现页面状态和实际调度不一致。

相关推荐
小的~~1 小时前
ThreadLocal 、InheritableThreadLocal 与 TransmittableThreadLocal 的进阶指南
java·开发语言
运维开发笔记1 小时前
3.1 Go if 语句学习笔记
golang
键盘会跳舞1 小时前
C++:std::pair 源码级深度剖析 —— 关联容器的基石
开发语言·c++·pair·关联容器
(Charon)1 小时前
【C++】手写线程安全队列:mutex + condition_variable 实现生产者消费者模型
c语言·开发语言·c++
AINative软件工程1 小时前
LLM 应用的 Feature Flag 工程实践:Prompt、模型与 AI 行为的生产安全灰度
后端·llm·ai编程
崖边看雾2 小时前
同步上下文管理器规则
开发语言·数据库
weixin_431600442 小时前
前端对接 SSE 的两种常见方式
前端·后端·学习·ai·sse·nest.js
Larcher8 小时前
React Router 不只是页面跳转:从 SPA 路由到权限守卫的完整实践
javascript·后端
Larcher8 小时前
大模型为什么每次回答都不一样?一文搞懂 Temperature、Top K 与 Top P
javascript·后端