定时任务
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
三种形态的槽长度:
| 形态 | 认领 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
- 多副本必须用同一个 job 名——这正是选主的前提。
- 不同 job 必须用不同名字,否则会互相吞掉对方的周期。
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 的真实时钟最快一秒一档,
亚秒会被向上取整)。
相关示例
- examples/cron:四种 job 各一,
/api/v1/jobs读回每 job 的运行次数。 - 服务与生命周期:
env.Block与关闭顺序。 - 缓存模式:另一处用到 redis 原子操作的地方。