重试策略与故障恢复机制
深入分析 go-ai-scheduler 的重试系统设计,理解固定间隔、指数退避、错误码匹配等策略,以及集中式重试如何避免重复执行。
为什么重试必须集中式
go-ai-scheduler 有一条硬约束:Worker 重试必须通过 Scheduler 集中处理。
如果让 Worker 自己重试,会出现什么问题?
问题场景:Worker-1 执行失败,尝试重试
Worker-1 ──重试──▶ 再次执行
│
│ 同时 Scheduler 认为任务失败,也分发给 Worker-2
▼
Worker-2 ──执行──▶ 同一任务被执行两次
集中式重试保证:重试决策权唯一,任何时刻一个实例只被一个 Worker 执行。
重试策略配置
任务定义时通过 Task 结构体配置重试参数:
type Task struct {
MaxRetry int // 最大重试次数
RetryPolicy string // "fixed_interval" / "exponential_backoff"
RetryOnErrors string // 逗号分隔的错误码白名单
RetryIntervalSeconds int // 固定间隔的秒数
RetryWindowSeconds int // 总重试窗口(0 = 无限)
}
策略一:固定间隔
执行失败 ──等待 30s──▶ 第1次重试 ──等待 30s──▶ 第2次重试 ──等待 30s──▶ 第3次重试
适合场景:错误是临时性的,预期很快恢复(如网络抖动、下游服务瞬时不可用)。
策略二:指数退避
执行失败 ──等待 5s──▶ 第1次重试 ──等待 10s──▶ 第2次重试 ──等待 20s──▶ 第3次重试
适合场景:错误可能是持续性的,需要给系统留出恢复时间(如数据库过载、API 限流)。
错误码白名单
RetryOnErrors: "ECONNREFUSED,ETIMEDOUT"
只有匹配白名单的错误才会触发重试。如果错误码不在白名单中(如业务逻辑错误),任务直接标记为 failed,不再重试。
重试循环实现
// retry/loop.go
type Loop struct {
tasks repo.TaskRepository
instances repo.TaskInstanceRepository
router *route.Router
dispatcher *dispatch.Client
interval time.Duration // 默认 3 秒
}
扫描与重试
func (l *Loop) scan(ctx context.Context) {
// 1. 获取待重试实例
instances, _ := l.instances.ListDueRetryInstances(ctx, time.Now(), 100)
for _, instance := range instances {
task, _ := l.tasks.GetTask(ctx, instance.TaskID)
// 2. 检查是否超过最大重试次数
if instance.RetryCount >= task.MaxRetry {
l.instances.UpdateInstanceStatus(ctx, instance.ID, "failed")
continue
}
// 3. 选择 Worker
worker, err := l.router.Pick(ctx, route.SelectOptions{...})
if err == route.ErrNoAvailableWorker {
return // 没有可用 Worker,等下一轮
}
// 4. 分发
err = l.dispatcher.Dispatch(ctx, worker, model.ExecuteTaskRequest{...})
if err != nil {
l.instances.UpdateInstanceStatus(ctx, instance.ID, "retry_waiting")
l.router.Release(ctx, worker)
continue
}
l.logger.Debug("retry instance dispatched",
"instance_id", instance.ID,
"worker_id", worker.ID,
"retry_count", instance.RetryCount)
}
}
关键设计
- 独立循环:重试循环与触发循环完全分离,各自有独立的扫描间隔
- 批量限制:每次最多处理 100 个重试实例,防止单次扫描过长
- 无 Worker 时退出:遇到
ErrNoAvailableWorker立即返回,等待 Worker 恢复
重试状态流转
Rendering diagram...
重试窗口
RetryWindowSeconds 控制总重试时间窗口:
// 伪代码:检查重试窗口
firstFailure := instance.FirstFailedAt
if task.RetryWindowSeconds > 0 && time.Since(firstFailure) > time.Duration(task.RetryWindowSeconds)*time.Second {
// 超过窗口,不再重试
l.instances.UpdateInstanceStatus(ctx, instance.ID, "failed")
continue
}
窗口机制防止"僵尸重试":一个任务失败后持续重试数小时,占用系统资源。
与触发循环的协作
触发循环 (1s 间隔) 重试循环 (3s 间隔)
│ │
├─ 扫描 Cron 到期任务 ├─ 扫描 retry_waiting 实例
├─ 创建 instance ├─ 检查重试条件
├─ 选择 Worker ├─ 选择 Worker
└─ 分发执行 └─ 分发执行
两个循环独立运行,互不阻塞。触发循环负责"新任务",重试循环负责"失败任务"。
故障恢复场景
场景一:Worker 崩溃
1. Worker-1 收到任务,开始执行
2. Worker-1 崩溃,未上报结果
3. Scheduler 心跳检测发现 Worker-1 offline
4. 任务实例保持 running 状态
5. 超时后(TimeoutSeconds),Scheduler 将实例标记为 failed
6. 重试循环接管,重新分发到其他 Worker
场景二:Scheduler Leader 切换
1. Leader (Scheduler-1) 持有 GET_LOCK,正在运行重试循环
2. Scheduler-1 崩溃,MySQL 连接断开,锁自动释放
3. Scheduler-2 获取 GET_LOCK,成为新 Leader
4. 新 Leader 的 Warm 过程加载所有 retry_waiting 实例
5. 重试循环继续运行,未完成任务得到处理
场景三:下游服务长时间不可用
1. 任务调用下游 API,返回 503
2. 匹配 RetryOnErrors 白名单,进入 retry_waiting
3. 固定间隔/指数退避重试
4. 下游服务恢复,重试成功
5. 或者超过 MaxRetry / RetryWindow,标记为 failed
小结
重试系统的设计要点:
- 集中式决策:Scheduler 统一控制重试,避免 Worker 侧重复执行
- 策略可配置:固定间隔、指数退避、错误码白名单,覆盖不同场景
- 窗口保护:RetryWindowSeconds 防止无限重试消耗资源
- 独立循环:重试循环与触发循环分离,互不干扰
- 状态机清晰:pending → running → failed → retry_waiting → running/failed
重试不是"再试一次"那么简单。一个好的重试系统需要回答:什么时候重试、重试多少次、间隔多久、什么错误值得重试。go-ai-scheduler 用不到 150 行代码回答了这些问题。