Skip to content

定时任务开发指南 ​

系统在「系统配置 → 定时任务」提供数据库驱动的任务管理,支持新增、修改、启停、立即执行和执行日志查询。调度器仅运行后端明确注册的处理器,不允许管理员在数据库中配置 Shell 命令、Go 函数名或任意 URL。

运行机制 ​

定时任务只由管理端 API 服务调度:

  1. server_api service admin 启动并完成数据库自检;
  2. 创建调度器,注册系统允许执行的处理器;
  3. 从 sys_scheduled_task 拉取所有启用任务;
  4. 校验处理器标识和 Cron 表达式后加入运行队列;
  5. 保存或删除任务时热刷新队列,无需重启服务;
  6. 服务停止时不再接收新任务,并在优雅停机期限内等待运行中的任务退出。

用户端 API 服务不会启动调度器,避免管理端和用户端两个进程重复执行。同一管理端部署多个实例时,每次执行前通过 MySQL GET_LOCK 获取任务级命名锁,同一任务同一时刻只允许一个实例执行,未获得锁的实例记录为「已跳过」。

Cron 表达式 ​

新任务使用六段表达式,最小粒度为一秒:

text
秒 分 时 日 月 周

已有五段表达式仍可运行,缺少的秒字段按 0 处理。

写法说明
*任意值
1,5,10多个离散值
1-5连续范围
*/10按步长执行

常用示例:

表达式执行周期
*/10 * * * * *每 10 秒
0 */5 * * * *每 5 分钟
0 0 */2 * * *每 2 小时
30 15 2 * * *每天 02:15:30
0 0 9 * * 1,3,5每周一、三、五 09:00
0 30 8 1,15 1,6,12 *1、6、12 月的 1 日和 15 日 08:30

管理端表单可按秒、分钟、小时、天、周、月构建表达式;「高级组合」支持直接组合六个字段。日和周同时设置为具体值时,遵循 Cron 规则,满足任一条件就会触发。

为避免高频任务耗尽资源,不支持 @every 100ms 等描述符和亚秒级周期。

数据表 ​

表作用
sys_scheduled_task任务名称、处理器、Cron、JSON 参数、状态、超时和最近执行结果
sys_scheduled_task_log每次成功、失败或跳过的执行快照、开始/结束时间、耗时和错误信息

数据库脚本:

bash
# 新安装时调度表已包含在核心全量中;已有系统通过 database upgrade 升级。

脚本同时创建「系统配置 → 定时任务」页面及保存、删除、立即执行按钮权限。普通管理员需要重新分配菜单权限,超级管理员直接可见。

新增一个任务处理器 ​

任务处理器由四部分组成:稳定标识、管理端显示信息、参数验证器、执行函数。建议把业务任务放在 internal/common/taskhandler,由管理端启动流程统一注册。

下面以「关闭超时订单」为例。

1. 定义参数和校验器 ​

go
package taskhandler

import (
    "bytes"
    "encoding/json"
    "errors"
)

type closeTimeoutOrderPayload struct {
    TimeoutMinutes int `json:"timeout_minutes"`
    BatchSize      int `json:"batch_size"`
}

func decodeCloseTimeoutOrderPayload(raw json.RawMessage) (*closeTimeoutOrderPayload, error) {
    var payload closeTimeoutOrderPayload
    decoder := json.NewDecoder(bytes.NewReader(raw))
    decoder.DisallowUnknownFields()
    if err := decoder.Decode(&payload); err != nil {
        return nil, errors.New("任务参数格式错误")
    }
    if payload.TimeoutMinutes < 1 || payload.TimeoutMinutes > 10080 {
        return nil, errors.New("timeout_minutes必须在1到10080之间")
    }
    if payload.BatchSize < 1 || payload.BatchSize > 1000 {
        return nil, errors.New("batch_size必须在1到1000之间")
    }
    return &payload, nil
}

验证器会在管理端保存任务时执行,因此错误参数不会进入调度队列。执行函数仍应再次解析,防止数据库被绕过接口直接修改。

2. 注册处理器 ​

go
package taskhandler

import (
    "context"
    "encoding/json"
    "errors"

    "gorm.io/gorm"

    "server_api/internal/common/scheduler"
)

func RegisterOrderTasks(manager *scheduler.Manager, db *gorm.DB) error {
    return manager.Register(
        scheduler.HandlerOption{
            Value:       "order.close_timeout",
            Label:       "关闭超时订单",
            Description: "批量关闭超过指定时间仍未支付的订单",
            Example:     `{"timeout_minutes":30,"batch_size":100}`,
        },
        func(raw json.RawMessage) error {
            _, err := decodeCloseTimeoutOrderPayload(raw)
            return err
        },
        func(ctx context.Context, raw json.RawMessage) error {
            payload, err := decodeCloseTimeoutOrderPayload(raw)
            if err != nil {
                return err
            }
            result := db.WithContext(ctx).
                Model(&Order{}).
                Where("status = ? AND created_at < ?", OrderPending, timeoutPoint(payload.TimeoutMinutes)).
                Limit(payload.BatchSize).
                Update("status", OrderClosed)
            if result.Error != nil {
                return errors.New("关闭超时订单失败")
            }
            return nil
        },
    )
}

处理器标识建议使用 {业务模块}.{动作},例如 order.close_timeout、message.retry_failed。标识写入数据库后应保持稳定,不要随意重命名。

3. 在启动阶段注册 ​

在 cmd/cmd.go 创建调度器之后、调用 Start 之前完成注册:

go
app.Scheduler = scheduler.New(app.DB)

if err := taskhandler.RegisterOrderTasks(app.Scheduler, app.DB); err != nil {
    return fmt.Errorf("注册订单定时任务失败: %w", err)
}

if err := app.Scheduler.Start(); err != nil {
    return fmt.Errorf("启动定时任务调度器失败: %w", err)
}

Register 会拒绝空标识、空名称、空处理函数、重复标识,以及调度器启动后的注册操作。

4. 在管理端创建任务 ​

重启管理端 API 后,新增的处理器会出现在任务处理器下拉框中。创建任务时填写:

  • 任务名称:关闭超时订单;
  • 任务处理器:关闭超时订单;
  • 执行周期:例如每分钟;
  • 任务参数:{"timeout_minutes":30,"batch_size":100};
  • 执行超时:根据单批数据量设置;
  • 初次上线建议先设为停用,使用「立即执行」验证后再启用。

开发约束 ​

  • 处理器必须使用传入的 context.Context 调用数据库、HTTP 等依赖,才能响应执行超时和服务停机;
  • 单次执行应有明确上限,大数据任务使用分页或批次,避免长事务、全表锁和内存暴涨;
  • 任务应尽量具备幂等性;涉及状态流转时使用条件更新、唯一索引或事务保证并发一致性;
  • 不要在任务参数中保存密码、Token、Secret 等敏感信息;任务参数和错误信息会进入管理页面或日志;
  • 不要注册通用 Shell、SQL、反射函数调用或任意 URL 请求处理器;
  • 手动执行与定时执行使用同一套分布式锁、超时和日志逻辑;
  • 错误应返回面向运维人员可理解的中文信息,不要把数据库驱动错误直接暴露到管理端;
  • 任务发生 panic 时调度器会恢复并记录失败,但处理器仍应主动处理可预期错误。

测试与验证 ​

至少覆盖以下场景:

  1. 合法与非法 JSON 参数;
  2. 参数边界值;
  3. 处理器重复注册;
  4. 六段秒级、五段兼容和非法 Cron;
  5. Context 超时或取消;
  6. 重复执行时业务幂等性;
  7. 管理端立即执行后的成功和失败日志。

提交前执行:

bash
cd server_api
gofmt -l .
go vet ./...
go test -race ./...

cd ../admin_client
pnpm exec vue-tsc --noEmit
pnpm build

新增或调整定时任务接口后,还需同步 go_basic_frame_wiki/public/openapi/admin_openapi.yaml,并重新生成 Wiki 管理端接口文档。

常见问题 ​

为什么只在管理端服务运行? ​

项目的管理端 API 和用户端 API 是两个独立进程。若两端都加载任务,会产生重复调度;因此只有管理端负责调度,用户端专注用户请求。

多台管理端服务器会不会重复执行? ​

不会同时执行。每个实例都会加载配置,但执行前必须竞争 MySQL 任务锁,只有一个实例获得锁,其余实例记录「已跳过」。业务处理器仍需保持幂等,以应对进程在外部副作用完成后、结果落库前异常退出等边界情况。

修改任务后是否需要重启? ​

不需要。通过管理端保存或删除任务后会热刷新调度器。新增后端处理器属于代码变更,需要重新编译并重启管理端服务。

为什么任务显示未注册? ​

数据库中的 handler 在当前二进制中不存在。检查处理器是否在 Start 前注册、标识是否一致,以及部署的管理端二进制是否为最新版本。