重试策略与故障恢复机制

深入分析 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)
    }
}

关键设计

  1. 独立循环:重试循环与触发循环完全分离,各自有独立的扫描间隔
  2. 批量限制:每次最多处理 100 个重试实例,防止单次扫描过长
  3. 无 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

小结

重试系统的设计要点:

  1. 集中式决策:Scheduler 统一控制重试,避免 Worker 侧重复执行
  2. 策略可配置:固定间隔、指数退避、错误码白名单,覆盖不同场景
  3. 窗口保护:RetryWindowSeconds 防止无限重试消耗资源
  4. 独立循环:重试循环与触发循环分离,互不干扰
  5. 状态机清晰:pending → running → failed → retry_waiting → running/failed

重试不是"再试一次"那么简单。一个好的重试系统需要回答:什么时候重试、重试多少次、间隔多久、什么错误值得重试。go-ai-scheduler 用不到 150 行代码回答了这些问题。