定时任务开发指南
系统在「系统配置 → 定时任务」提供数据库驱动的任务管理,支持新增、修改、启停、立即执行和执行日志查询。调度器仅运行后端明确注册的处理器,不允许管理员在数据库中配置 Shell 命令、Go 函数名或任意 URL。
运行机制
定时任务只由管理端 API 服务调度:
server_api service admin启动并完成数据库自检;- 创建调度器,注册系统允许执行的处理器;
- 从
sys_scheduled_task拉取所有启用任务; - 校验处理器标识和 Cron 表达式后加入运行队列;
- 保存或删除任务时热刷新队列,无需重启服务;
- 服务停止时不再接收新任务,并在优雅停机期限内等待运行中的任务退出。
用户端 API 服务不会启动调度器,避免管理端和用户端两个进程重复执行。同一管理端部署多个实例时,每次执行前通过 MySQL GET_LOCK 获取任务级命名锁,同一任务同一时刻只允许一个实例执行,未获得锁的实例记录为「已跳过」。
Cron 表达式
新任务使用六段表达式,最小粒度为一秒:
秒 分 时 日 月 周已有五段表达式仍可运行,缺少的秒字段按 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 | 每次成功、失败或跳过的执行快照、开始/结束时间、耗时和错误信息 |
数据库脚本:
# 新安装时调度表已包含在核心全量中;已有系统通过 database upgrade 升级。脚本同时创建「系统配置 → 定时任务」页面及保存、删除、立即执行按钮权限。普通管理员需要重新分配菜单权限,超级管理员直接可见。
新增一个任务处理器
任务处理器由四部分组成:稳定标识、管理端显示信息、参数验证器、执行函数。建议把业务任务放在 internal/common/taskhandler,由管理端启动流程统一注册。
下面以「关闭超时订单」为例。
1. 定义参数和校验器
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. 注册处理器
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 之前完成注册:
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 时调度器会恢复并记录失败,但处理器仍应主动处理可预期错误。
测试与验证
至少覆盖以下场景:
- 合法与非法 JSON 参数;
- 参数边界值;
- 处理器重复注册;
- 六段秒级、五段兼容和非法 Cron;
- Context 超时或取消;
- 重复执行时业务幂等性;
- 管理端立即执行后的成功和失败日志。
提交前执行:
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 前注册、标识是否一致,以及部署的管理端二进制是否为最新版本。