beego 的 toolbox.cron 是单机内存调度器,不支持分布式协调;实现集群定时任务需自行添加选主或哈希分片机制,并结合 redis 锁、心跳检测与幂等设计来避免重复执行和任务丢失。

Beego 本身不提供分布式定时任务协调能力,@Scheduled 那套在 Spring 里管用,但在 Beego 里压根不存在;直接起多个 Beego 实例跑相同定时逻辑,必然重复执行。要实现集群协调,必须自己补上「谁该执行」的判定层。
Beego 中定时任务的默认行为就是单机独占
Beego 的 toolbox.Cron 是纯内存调度器,基于 time.Ticker + 小顶堆(优先队列)实现,所有任务注册后只在当前进程内触发。它不感知其他节点,也不和任何外部系统通信。这意味着:
- 你部署 3 个 Beego 实例,每个实例都按 cron 表达式准时执行同一段代码
- 没有锁、没有选主、没有心跳,只有“各自为政”
- 哪怕你用
beego.BConfig.RunMode == "prod"切换环境,也改变不了这个事实
所以别指望靠配置开关或重写 Cron.AddFunc 就能“自动变分布”。它从设计上就不是为集群准备的。
加 Redis 锁不是终点,而是起点
很多人第一步想到的是“加个 Redis 分布式锁”,比如用 SETNX job:send-sms:2026-08-21-22:00 true EX 300 NX。但这样只解决了一半问题:
- 锁过期时间必须严格大于任务最长执行时间,否则可能被误删导致重复执行
-
SETNX没有续租机制,任务卡住时锁自动释放,另一个节点会立刻抢入——造成脑裂 - 没做节点健康检测,如果持有锁的节点崩溃,锁到期后虽能释放,但中间存在空窗期,任务可能漏掉
更稳妥的做法是用 Lua 脚本封装「加锁 + 设置 TTL + 原子校验」,例如:
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("EXPIRE", KEYS[1], ARGV[2])
else
return redis.call("SET", KEYS[1], ARGV[1], "EX", ARGV[2], "NX")
end
其中 ARGV[1] 是唯一节点标识(如 node-01),ARGV[2] 是 TTL(建议设为任务最大耗时 × 2)。
哈希分片比选主更稳定,适合长期运行的任务
对周期性固定任务(如每小时统计订单量),推荐用一致性哈希把 jobID 映射到具体节点,而不是每次触发都抢锁。参考你知识库里的 getTargetNode 函数:
func getTargetNode(jobID string, nodes []string) string {
h := fnv.New32a()
h.Write([]byte(jobID))
idx := int(h.Sum32()) % len(nodes)
return nodes[idx]
}
这种方案的优势在于:
- 只要节点列表和哈希算法不变,同一个
jobID永远落在同一个节点,避免频繁漂移 - 节点增减时,仅部分
jobID重新分配,不会全量震荡 - 不需要中心协调者,各节点本地计算即可判断“该不该执行”
注意:nodes 列表必须全局一致(可通过 etcd/Consul 或 Redis 的 SMEMBERS scheduler:nodes 维护),不能靠本地配置文件硬编码。
任务失败后的漂移必须依赖超时+心跳双机制
光靠哈希分片或锁,无法应对节点中途宕机。比如 node-02 正在执行 job:clean-expired-session,突然 panic,任务就卡死。此时需要:
- 每个节点定期往 Redis 写心跳:
SET scheduler:node:02:hb "alive" EX 30 - 任务开始前检查目标节点是否存活:
EXISTS scheduler:node:02:hb - 任务启动时记录开始时间戳,执行中每 30 秒用 Lua 脚本刷新一次 TTL
- 若发现目标节点心跳丢失,且任务已超时,则由其他节点主动接管(需幂等设计)
最容易被忽略的是:接管逻辑不能简单“重试”,而要先确认原任务是否真失败了——比如查数据库里有没有生成中间状态记录,或者用 Redis 的 GETSET 做抢占式标记。否则两个节点同时认为“对方挂了”,又会重复执行。











