定时任务

pkg/core/cron 解决的是一个很具体的问题:多副本部署下, 一个定时任务每个周期只跑一次,而不是每个副本各跑一次。 做法是每个副本都起自己的定时器,到点抢一个以 job 命名的 redis 认领。

三种调度形态

一个 cron.Job 必须恰好设置 Interval / Schedule / At / Delay 中的一个, 多设或全不设在 Add 时 panic。

形态 字段 语义
固定周期 Interval time.Duration 每 N 时间跑一次。它同时是认领的 TTL,所以一次运行必须远快于它
cron 表达式 Schedule string 标准 5 段("0 9 * * *")加 @every 5m / @daily 这类描述符,按调度器本地时区。表达式在 Add 时解析,非法即启动失败
一次性(绝对时刻) At time.Time 到点跑一次。时间已过则在启动时立刻跑
一次性(相对延迟) Delay time.Duration 调度器启动后 N 时间跑一次,与 At 互斥

其余两个字段:Name 是 job 的标识,同时也是锁 key 与日志字段; Run func(ctx context.Context) error 干活,ctx 在调度器关闭时取消。 RunOnStart 让 interval job 在启动时立刻跑一次、不等第一个周期, 它只对 interval job 有效,配在别的形态上 Add 会 panic。

sched := cron.New(cron.Config{
    Redis:   rc,               // nil = 单副本,不做选主(rc 来自 env.Redis())
    Timeout: s.cfg.JobTimeout, // 单次运行的上限
})
sched.Add(
    cron.Job{Name: "heartbeat", Interval: heartbeat, RunOnStart: true, Run: s.heartbeat},
    cron.Job{Name: "reconcile", Interval: reconcile, Run: s.reconcile},
    cron.Job{Name: "report", Schedule: report, Run: s.report},
    cron.Job{Name: "notify", Delay: notify, Run: s.notify},
)

参数非法(缺 Name、缺 Run、调度没恰好设一个、坏 cron 表达式、 RunOnStart 配在非 interval job 上)一律在 Add 时 panic: 配错的定时任务应该在启动时报错,而不是几周后才发现它从没跑过。

用 env.Block 注册

func (s *cronService) Setup(ctx context.Context, env *verticle.Env) error {
    if err := env.DecodeService(&s.cfg); err != nil {
        return err
    }
    rc, ok := env.Redis()
    s.sched = cron.New(cron.Config{Redis: rc, Timeout: s.cfg.JobTimeout})
    s.sched.Add(s.jobs(...)...)

    // verticle 启动它、退出时取消它——在关掉基础设施之前
    env.Block("cron", s.sched.Run)
    return nil
}

Scheduler.Run(ctx) 阻塞到 ctx 取消,然后等正在跑的 job 返回。 交给 env.Block 而不是自己 go sched.Run(ctx), 是因为 verticle 保证关闭顺序:先停 job,再关 DB / redis。 反过来 job 会在已经关掉的连接上跑。

配置侧不需要额外开关,配了 redis: 段就自动选主:

# 配了 → 多副本之间选主,每个周期只跑一个
# 没配 → 每副本都跑(适合单副本)
redis:
  addr: ${REDIS_ADDR:localhost:6379}

关键设计:认领持有整个时间槽

这是这个包唯一必须记住的实现细节:认领不在 job 跑完后释放, 而是让它自然过期。原因是各副本的定时器不同步——每个进程的启动时刻不同。

sequenceDiagram
    autonumber
    participant A as 副本 A(t=0,10,20…)
    participant R as redis
    participant B as 副本 B(t=5,15,25…)
    Note over A,B: interval = 10ms,两台机器的定时器错开 5ms
    A->>R: Claim("cron:job", ttl=10ms)
    R-->>A: true,A 拿到这个槽
    A->>A: Run 执行,1ms 后返回
    Note over A,R: 认领不释放,继续占用到 t=10
    B->>R: Claim("cron:job") 在 t=5
    R-->>B: false —— 这个槽已被占用
    Note over B: 跳过本次 tick
    Note over R: t=10 认领自然过期
    A->>R: 重新 Claim 下一个槽
    R-->>A: true
				
若认领在 run 结束时就释放,B 在自己的 t=5 tick 上会抢到一个空的 claim, 于是每个周期执行两次,选主形同虚设。让 claim 活满一整个 interval, 语义就变成"这个周期已被占用"。

三种形态的槽长度:

形态 认领 TTL 后果
Interval Job.Interval 本身 run 必须远小于 interval。interval 5s 却跑了 8s,另一个副本会在它还没结束时接下同一个逻辑周期——用 Config.Timeout 兜底
Schedule 本 cron 槽的长度(下一次触发 − 本次触发) 错峰副本在同一槽内抢不到 claim,所以每槽只跑一次。错过不补跑,和主机 cron 一致
At / Delay 永不过期(SetNX 不带 TTL) 抢到一次就永久占用,跨副本最多执行一次:哪怕某次运行失败也不会自动重试

一次性任务的承诺是 at-most-once——宁可漏,不可双跑。一次失败就是一个 error 日志, 剩下的交给人工。单副本(不配 redis)则无条件跑一次。

Name 就是锁 key

key 由 Config.Prefix(默认 "cron:")加 job 名组成, 底层是 redis.Client.Claim(ctx, key, ttl)——一次 SetNX。 它与 TryLock 的区别就在返回值里的那个决定:Claim 的调用方 从不释放,键交给 TTL 自然过期;TryLock 返回一个释放函数, 适合"做完就交还"的场景。

运行失败与依赖不可用

情况 行为
interval / cron job 返回 error 记 error 日志,下一轮照常跑——一次坏运行不会停掉循环
一次性 job 返回 error 记 error 日志,不再重试(认领已永久占用)
redis 不可用 跳过这个周期,记一条 warn。宁可漏一轮,也不让所有副本一起跑——那是一次惊群
抢不到认领 静默返回(另一个副本持有这个槽),这不是错误
run 超过 Config.Timeout ctx 被取消,job 自己决定怎么收尾(Timeout 为零则由调度器的 ctx 唯一约束)

每次运行结束都有一条日志:成功的记 dur_ms,失败的额外带 err。

测试

不需要 Docker:redistest.Start(t) 起进程内的 miniredis, claim 就能工作。验证选主时不要去调未导出的 runOnce, 用两个各自跑起来的 scheduler 让它们真的抢一次 claim—— 参考 examples/cron 的 TestLeaderElectionRunsOneInstancePerInterval 与 TestOneShotLeaderElection。 cron 表达式 job 在测试里用 @every 1s(robfig 的真实时钟最快一秒一档, 亚秒会被向上取整)。

相关示例