九一八事变纪念日|1931年9月18日,日本侵略者制造九一八事变,开启了长达14年的侵华战争。警钟长鸣,吾辈自强!

Go 定时任务与分布式调度:cron 库与 leader 选举

CLARA轻量论坛系统
CLARA轻量论坛系统 星耀SVIP管理员 黑卡会员
发布于 2026-09-18 16:33 ·2 浏览 ·0 回复

结论:单机跑定时任务用 `robfig/cron` v3 就够了,但只要你的服务是多副本部署(两台上、滚动发布、K8s 多 Pod),就必须加一层协作——要么每次执行抢分布式锁,要么用 leader 选举让只有一台机器持有调度权。选错这一步,你的账单报表会跑 N 遍,发券会发 N 倍。

先分清"触发"和"执行"是两件事

cron 库只解决"什么时候调用这个函数",它完全不知道你有几台机器在跑同一份代码。多副本环境下的重复执行不是 bug,是默认行为。

最小可用方案是三层:cron 库负责触发 → 分布式锁负责"同一时刻只有一个人跑" → 业务幂等负责"就算跑重了也不出错"。三层缺任何一层,线上迟早出事故。很多人只做了第一层就上线,然后在某个凌晨被数据对不上叫醒。

robfig/cron v3 的正确写法

v3 默认是 5 字段(分 时 日 月 周),需要秒级精度要显式加 `cron.WithSeconds()`,此时必须写 6 个字段,否则解析报错。这是最常见的踩坑点。

c := cron.New(
    cron.WithSeconds(),
    cron.WithLocation(mustLoad("Asia/Shanghai")),
    cron.WithChain(
        cron.Recover(cron.DefaultLogger),        // 单个 job panic 不拖垮整体
        cron.SkipIfStillRunning(cron.DefaultLogger), // 上一轮没跑完就跳过,防堆积
    ),
)
c.AddFunc("0 */5 * * * *", syncOrders) // 每 5 分钟
c.Start()                              // 非阻塞;c.Run() 才阻塞
defer c.Stop()                         // Stop 会返回等待运行中任务结束的 ctx

三个必须注意的点:时区——容器默认 UTC,不加 `WithLocation` 你的"每天 8 点"会变成北京时间 16 点;Recover——不加中间件,一个 job 的 panic 就能影响调度器;SkipIfStillRunning——任务耗时超过间隔时,v3 默认会并发起新一轮,导致雪崩。

多副本的三种协作方式

分布式锁:每台机器都持有调度器,但执行前抢锁,抢到才跑。适合执行频率低、耗时短的任务。Redis 实现:

ok, _ := rdb.SetNX(ctx, "lock:syncOrders", token, 30*time.Second).Result()

释放必须用 Lua 比对 token,不能直接 `DEL`(可能删掉别人续期的锁):

if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end

leader 选举:只有一个实例真正持有调度权,其余实例待命。etcd 用 `concurrency.NewSession` + `NewElection().Campaign()`,K8s 业内置了 `coordination.k8s.io/v1` Lease 资源和 client-go 的 `leaderelection`。官方示例的默认参数是 LeaseDuration 15s、RenewDeadline 10s、RetryPeriod 2s,改动这三个值要满足 `LeaseDuration > RenewDeadline > RetryPeriod`,否则会反复脑裂。

外置调度:用 K8s CronJob 并设 `concurrencyPolicy: Forbid`,或干脆起一个单副本 scheduler 服务,业务实例完全不碰定时逻辑。这是运维成本最低的做法。

几个容易忽略的工程细节

leader 切换是有窗口期的——旧 leader 在网络分区时可能还在跑任务,所以幂等永远不能省,别以为选了 leader 就万事大吉。

所有任务加 0~30 秒的随机抖动,避免整点几百个任务同时打 DB。每次执行写一条 run log(任务名、实例 ID、耗时、结果),否则出问题你连"是谁跑的"都不知道。任务里用 `ctx` 并监听退出信号,K8s 加 `preStop` 睡眠,否则滚动发布会打断正在执行的任务。

顺带说一句,PHP 生态里也有类似的分层思路,比如 Clara BBS 的插件公共设施就提供 `Cron::register` 定时任务注册(懒触发、零配置),把"什么时候跑"交给框架、把"跑什么"留给业务。

怎么选

单实例、任务轻:`robfig/cron` + Recover + SkipIfStillRunning,收工。多实例、任务少而重:Redis 锁 + token 校验 + 续约。多实例、任务多且要求严格不重复:etcd 或 K8s Lease 做 leader 选举。已经在 K8s 上且任务本身能当一次性 Job 跑:直接用 CronJob,别自己造轮子。

记住一句话:**调度框架只保证"触发",不保证"只触发一次",也不保证"执行成功"**。把幂等、日志、超时控制和优雅退出做扎实,比纠结用哪个库重要得多。

本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-489.html
转载请注明出处,版权归原作者所有。

全部回复 0

还没有回复,来抢沙发~