不能直接用 cron 包做分布式定时任务,因其是单机调度器,缺乏节点协调机制,会导致多实例重复执行;需引入外部协调者(如 redis)实现选主、分片、抢占式锁、心跳续期与幂等保障。

为什么不能直接用 cron 包做分布式定时任务
Go 标准库的 cron(如 github.com/robfig/cron)本质是单机调度器,没有节点间协调机制。多个实例同时启动后,所有节点都会触发同一任务,导致重复执行——这不是“分布式”,只是“多副本并发误触发”。真正要解决的是:**谁该执行、何时执行、失败后谁接管**。
常见错误现象:task executed 3 times on 3 nodes at 02:00;更隐蔽的问题是任务状态不一致,比如一个节点写了一半数据库就宕机,其他节点又重试,数据被覆盖或重复插入。
- 必须引入外部协调者(如 etcd、Redis、ZooKeeper)来选主或分片
- 任务定义需带唯一标识(
jobID),不能只靠时间表达式判断是否该跑 - 调度逻辑和执行逻辑必须分离:调度器决定“哪个节点跑”,执行器只管“怎么跑”
用 Redis + Lua 实现轻量级调度分配
Redis 的原子性操作适合做分布式锁和任务分发,比 etcd 更易落地。核心思路是:每个定时任务对应一个 jobKey(如 job:send_daily_report),每次触发时由所有节点竞争获取该 key 的执行权。
关键不是“谁先抢到”,而是“谁该抢”。推荐用节点 ID 做哈希取模分片:
func getTargetNode(jobID string, nodes []string) string {
h := fnv.New32a()
h.Write([]byte(jobID))
idx := int(h.Sum32()) % len(nodes)
return nodes[idx]
}
这样相同 jobID 每次都分给同一个节点,避免脑裂;即使节点增减,只要哈希算法不变,大部分任务仍能保持稳定归属。
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
- 不要用
SETNX简单加锁——它不保证公平性,且没超时续租机制 - 推荐用 Redlock 或基于
EVAL的 Lua 脚本实现“带 TTL 的抢占式锁”,锁过期时间应大于任务最长执行时间 - 任务触发时间点(如每分钟整点)由本地
time.Ticker驱动,但执行前必须调用 Redis 检查自己是否是目标节点
如何让任务失败后自动漂移重试
单纯靠“谁抢到谁执行”不够。如果目标节点在执行中崩溃,任务就丢了。需要引入心跳+超时检测机制。
做法是在任务开始执行时,往 Redis 写一个带 TTL 的标记(exec:jobID:node1),并在执行过程中定期 EXPIRE 续期。其他节点每隔几秒扫描所有未完成任务的标记,发现过期就认为原执行者已失联,发起抢占。
- 续期频率建议为 TTL 的 1/3(如 TTL=30s,则每 10s 续一次),避免网络抖动误判
- 漂移不是立即发生——要等原 TTL 过期后再抢,否则可能两个节点同时执行
- 任务本身需支持幂等:例如用
INSERT IGNORE或先查后插,防止漂移后重复写入
调度函数的典型结构与参数陷阱
所谓“调度函数”,其实是封装了上述逻辑的可复用函数,不是简单包装 time.AfterFunc。它的签名往往长这样:
func ScheduleJob(
jobID string,
spec string, // cron 表达式
execFn func(context.Context) error,
opts ...JobOption,
)
容易踩的坑集中在 opts 参数:
-
WithRetry(3)不代表重试 3 次——它只控制漂移后的重试次数,原始执行失败不计入 -
WithTimeout(20*time.Second)是指单次执行上限,不是调度周期间隔 -
WithShardKey("tenant_id")如果传空字符串,哈希结果固定为 0,所有任务全落到第一个节点 - spec 用
second精度(如*/5 * * * * *)时,必须确认所有节点时钟同步(ntpd或chrony),否则触发时间偏差可达秒级
最常被忽略的是时区。Go 的 cron 默认用本地时区解析表达式,而容器环境常为 UTC。务必显式指定:crontab.MustParse("0 0 1 * * ?").WithLocation(time.UTC)。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










