不能直接用 time.Ticker 做分布式定时任务,因为它是单进程内存级调度,多实例会重复执行;需通过 Redis+Lua 实现抢占式分布式锁,配合幂等键与状态机保障唯一性、容错性和可维护性。

为什么不能直接用 time.Ticker 做分布式定时任务
因为 time.Ticker 是单进程内存级调度,每个服务实例都会独立触发一次,节点越多重复执行越严重。你看到日志里同一秒内出现 3 次「订单超时检查」,大概率就是没做分布式互斥。
真正需要的是:仅一个节点执行,其余节点感知并跳过;故障转移时能自动接管;任务定义可动态增删(不重启)。
- 别把
cron.Schedule直接塞进 Echo 的GET /admin/cronhandler 里——它只在当前进程生效 - 避免用文件锁或本地 SQLite 做协调——跨机器无效
- Redis 的
SET key value EX 30 NX是最轻量的分布式锁起点,但注意锁续期和死锁风险
用 Redis + Lua 实现原子抢占式任务调度
核心思路:每个任务注册一个唯一 task_key,每次执行前用 Lua 脚本尝试 SETNX 占坑,成功才执行,并设置带 TTL 的锁键。失败则直接 return。
示例脚本(存为 acquire_lock.lua):
立即学习“go语言免费学习笔记(深入)”;
if redis.call("set", KEYS[1], ARGV[1], "EX", ARGV[2], "NX") then
return 1
else
return 0
end
Echo 中调用逻辑(在 StartScheduler() 里启动 goroutine):
- 用
redis.Client.Eval()执行上述 Lua,传入task_key(如"cron:check_expired_orders")、当前节点 ID、TTL(建议设为执行超时的 2 倍) - 返回 1 → 抢占成功 → 执行业务逻辑 → 最后
DEL task_key(或让 TTL 自动过期) - 返回 0 → 抢占失败 → sleep 后重试(别用固定间隔,加点 jitter 避免羊群效应)
如何让 Echo 的 HTTP 服务和定时任务共享配置与日志
关键不是“把定时任务塞进 Echo”,而是让两者共用初始化上下文。比如数据库连接池、zerolog.Logger、配置结构体。
推荐做法是定义一个全局 AppContext:
type AppContext struct {
DB *sql.DB
Redis *redis.Client
Logger zerolog.Logger
Config Config
}
然后在 main() 中初始化一次,分别传给 Echo router 和 scheduler goroutine:
- Echo 的 middleware 可通过
echo.Context.Set("appctx", ctx)注入,但定时任务 goroutine 不走 HTTP 生命周期,必须显式传参 - 别在 scheduler 里重新
log.Info().Msg("start")—— 用同一个ctx.Logger实例,确保 traceID 一致 - 如果用了
viper,确保Config结构体字段 tag 是mapstructure:"xxx",否则热重载可能失效
任务失败时怎么避免雪崩和重复补偿
分布式环境下,网络抖动、Redis 瞬断、节点重启都可能导致任务中途丢失。单纯靠锁抢占无法解决「已开始但未完成」的问题。
必须引入状态机 + 幂等 Key:
- 每次任务执行前,先用
INCR task_exec_seq:check_expired_orders获取单调递增序号,拼成幂等 Key:"exec:check_expired_orders:12345" - 执行前先
SETNX exec_key "started" EX 600,成功再继续;若已存在,说明上次未清理干净,查 DB 或 Redis 状态决定是否重试 - 执行完成后,用
HSET task_result:check_expired_orders 12345 '{"status":"success","ts":171...}'记录结果,供监控拉取 - 不要依赖
defer unlock()—— panic 或 OOM 时 defer 不一定执行,锁续期要用单独 goroutine +time.AfterFunc
最易忽略的一点:所有 Redis 操作必须设置 context.WithTimeout,否则节点假死会导致锁永远不释放。


















