================================================================================ EasyGoAdmin 后端开发者文档 · 09 定时任务调度 ================================================================================ 代码位置:internal/logic/daemon.go(调度器)、internal/logic/job_handler.go (本地处理器注册表)、internal/logic/job.go(/api/job 业务)、 internal/logic/job_log.go(任务日志)、internal/task/(任务实现) 表:goframe_job(任务定义)、goframe_job_log(执行日志) -------------------------------------------------------------------------------- 1. 架构总览 -------------------------------------------------------------------------------- 数据驱动的常驻调度器: 启动(cmd.go 延时 2s)→ DaemonInit 加载 goframe_job 中 status=1 的任务 → 按 cron_expression 挂载 gcron → tick 触发 executeJob → 执行目标分派(HTTP 调用 或 本地处理器)→ 结果写 goframe_job_log。 职责分层: 调度基础设施(挂载/摘除/锁/执行/落日志)在 internal/logic 的 job 模块; 任务业务实现在 internal/task 包(依赖方向单向:task → logic,logic 禁止 反向 import task);由 internal/cmd 空白导入 internal/task 触发 init 注册。 -------------------------------------------------------------------------------- 2. 任务执行目标双形态(executeJob) -------------------------------------------------------------------------------- 读取任务行的 url 字段分派: http:// 或 https:// 开头 → HTTP 调用目标地址: params 为空 GET,非空 POST(Content-Type application/json,超时 30s); 非 200 或网络错误 → 失败(exception 记录 HTTP 状态与响应前 1000 字符)。 其余取值 → 视为本地处理方法名,从 jobHandlers 注册表查找: 签名 func(ctx context.Context, params string) (string, error) params 为任务参数(JSON 字符串,可为空);返回摘要写入任务日志 message。 未注册 → 失败“未找到本地任务处理方法”。 执行防护: * recover() 捕获 panic(不外泄影响调度器) * force=false 时校验 status=1,否则从调度器摘除(数据被外部改动的自愈) * 任务行不存在 → 摘除 -------------------------------------------------------------------------------- 3. 执行策略与执行方式 -------------------------------------------------------------------------------- execute_policy(列值): 1 立即执行 允许并发:gcron.Add,tick 即启动新执行;不加执行锁 2 执行一次 单例调度(gcron.AddSingleton),上次未结束跳过本次; 叠加执行锁(默认策略) 3 放弃执行 并发 tick 照常触发,但抢不到执行锁则放弃本次 is_sync(列值): 1 同步 在调度协程内执行(阻塞本条目下一 tick 的派发) 0 异步 go 协程执行(context.Background()) 执行锁 jobTryLock(策略 2/3 叠加): 键 job:lock:{id},utility/cache SET NX(TTL 300s 兜底——实例崩溃未释放时 自动过期;正常路径 defer 立即释放)。 多实例部署共享同一把锁(Redis 模式下防跨实例并发;内存降级时退化单机语义)。 cron 表达式:gf gcron 格式,默认 6 段(秒 分 时 日 月 周); 可用 # 前缀表示标准 5 段忽略秒(如 "# */5 * * * *" = 每 5 分钟)。 内置清理任务使用 6 段:"0 30 3 * * *" = 每日 03:30。 -------------------------------------------------------------------------------- 4. 本地处理器注册表(job_handler.go + internal/task) -------------------------------------------------------------------------------- logic.RegisterJobHandler(name, h):注册名全局唯一,重复注册进程直接 panic (暴露配置错误)。注册名与任务记录 url 字段值一一对应(数据驱动查找)。 internal/task 包约定: 每任务一个文件(snake_case),init 中注册; 处理方法签名 func(ctx, params) (string, error),返回值写任务日志摘要; 不在 HTTP 启动链路内,由 internal/cmd 空白导入触发加载。 内置任务清单: HelloWorld(hello_world.go) 示例任务,仅打日志,用于验证调度链路 SendSms(send_sms.go) 仅记录执行参数日志,不对接短信通道 LogClean(log_clean.go) 日志清理(见第 6 节) -------------------------------------------------------------------------------- 5. /api/job 管理接口(logic/job.go) -------------------------------------------------------------------------------- 七件套(page/list/detail/add/update/delete/batchDelete,perm sys:job:*) + 控制接口: GET /api/job/runOnce/:id sys:job:execute 立即执行一次(异步 go executeJob(force=true),未发布任务也可执行) GET /api/job/pause/:id sys:job:pause 摘除调度 + status=2(暂停) GET /api/job/resume/:id sys:job:resume 重新挂载 + status=1(运行中) POST /api/job/status sys:job:update 设状态(1→挂载,其他→摘除) GET /api/job/daemonStatus 登录即可 调度器状态 (running=gcron 条目数、jobIds、startTime) 业务钩子(ModuleSpec 声明): BeforeAdd 新增固定 status=0(未发布;发布走 Status/Resume) AfterUpdate 运行中任务先摘除再按新配置重挂;挂载失败降级 status=2 并报 “Cron表达式无效” BeforeDelete 存在性检查 + 摘除调度 status 语义:0 未发布 / 1 运行中 / 2 暂停。 枚举字典:job_status、job_execute_policy、job_is_sync(SerializeMaps 驱动 {field}Text 显示)。 5.1 调度执行流程图 启动(cmd, 延时2s) ─▶ DaemonInit(sync.Once) │ SELECT * FROM goframe_job WHERE status=1 ▼ 逐条 addJobToScheduler(id, cron, policy, isSync) │ policy=1 → gcron.Add (可并发,无锁) │ policy≠1 → gcron.AddSingleton (单例)+ 执行锁 ▼ tick 触发 ─▶ is_sync=1? ──是──▶ 同步执行 run() │否 ▼ go run(context.Background()) run(): policy≠1 → jobTryLock SET NX job:lock:{id} │ 抢锁失败 → 放弃本次(Debug 日志) ▼ 抢到(defer 释放) executeJob(id, force=false) ├─ 任务不存在/status≠1 → 摘除调度,返回 ├─ url=http(s) → gclient GET/POST(30s 超时,params 为 body) ├─ url=方法名 → jobHandlers 查找执行(task 包注册) └─ recover panic → 写日志 dao.JobLog.Insert(status/message/耗时/异常,create_user=daemon) 5.2 任务状态机 Add(固定置0) │ ▼ ┌─────────┐ Status(1) / Resume ┌─────────┐ │ 0 未发布 │─────────────────────▶│ 1 运行中 │◀──┐ └─────────┘ └────┬────┘ │ ▲ Status(2)/Pause │ │ Resume │ ─────────────────────▶│ │ │ 摘除调度 ▼ │ │ ┌─────────┐ │ │ Status(0) │ 2 暂停 │───┘ │◀─────────────────────────── └─────────┘ │ 摘除调度 Delete:软删(del_flag=1) + 摘除调度 运行中 Update:摘除 → 按新 cron 重挂(失败降级置 2 并报 Cron 无效) RunOnce:不看状态,异步 force 执行一次(不改状态) -------------------------------------------------------------------------------- 6. 内置任务 LogClean(task/log_clean.go) -------------------------------------------------------------------------------- 注册名 "LogClean";每日 02:30 由任务表配置驱动(内置任务数据在初始化菜单/ 字典数据中配置;url 字段值 = LogClean)。 参数:{"retentionDays": n} 可覆盖配置 job.logRetentionDays(默认 90), 参数为 0/缺失取配置值,配置也无效则回退 90。 行为:分批物理删除三张日志表(goframe_operation_log / login_log / job_log) 中 create_time 早于 now-retentionDays 的记录: 单批 5000 行(logCleanBatch),循环至不足一批; 单表单次任务上限 200000 行(logCleanMaxTotal,防长事务与 runaway)。 摘要返回各表清理条数,写入任务日志 message。 -------------------------------------------------------------------------------- 7. 与进程内 gcron 的其他条目 -------------------------------------------------------------------------------- 调度器与内置清理共用 gcron,但命名空间隔离: 业务任务条目键 job:{id}(server/info 的 Scheduler.mounted 只统计 "job:" 前缀) 上传清理条目 upload_temp_clean(cmd.go 注册,每日 03:30 logic.CleanTemp, 天数取 file.tempExpireDays) DaemonStatus 的 running 为 gcron 全部条目数(含非任务条目), Scheduler.mounted 按 "job:" 前缀过滤——两处口径不同,排障时注意。 -------------------------------------------------------------------------------- 8. 开发一个新定时任务的步骤 -------------------------------------------------------------------------------- 1) internal/task/{任务名}.go 实现 handler:func(ctx, params) (string, error) 2) init() 中 logic.RegisterJobHandler("TaskName", handler) (注册名=任务表 url 字段值;http(s) 地址则无需本地处理器) 3) 任务管理页新增记录:url 填 "TaskName",params 填 JSON 参数, cron_expression 填 6 段或 # 5 段表达式,execute_policy/is_sync 按需 4) Status 置为运行中(发布)→ 调度器挂载;观察 job/log 执行日志 5) go build ./... 并重启(init 注册发生在进程加载期) 注意:handler 内可复用 base 共享能力(WithTrans/ChunkQuery 等)与 dao; 长任务用分批 + 上限;HTTP 目标任务自带 30s 超时,长任务应改为本地处理器。 -------------------------------------------------------------------------------- 9. 多实例部署语义 -------------------------------------------------------------------------------- 单实例:gcron 各自挂载,正常运行。 多实例 + Redis:策略 2/3 经 job:lock:{id} 全局互斥(同一时刻至多一个实例 在执行该任务);仍会重复“触发”(各实例各自 tick),但未抢到锁的实例直接放弃。 多实例 + 内存降级:锁退化为单机语义,可能出现跨实例并发执行——生产环境 Redis 必须可用。