负载均衡与失败重试

专栏:new-api 源码拆解 · 第 7 / 12 篇
new-api负载均衡重试

:::info 学习目标 完成本篇后你能够:手推选路算法对一组渠道的选择结果;逐步追踪一次失败请求的渠道切换与熔断过程;配置 auto 组的跨组容灾。 前置:第 5 篇完成。预计时长:50 分钟。 :::

:::note 本章术语速查(新手建议先读)

  • 负载均衡(Load Balancing):多个上游时决定”这次请求给谁”。
  • 优先级(Priority):数字越大越先用;重试时自动降到下一档。
  • 加权随机(Weighted Random):同优先级的渠道按 weight 比例分流。
  • 熔断(Auto Ban):出错达到条件自动下线,防止持续浪费请求。
  • 幂等(Idempotent):同一操作执行多次和执行一次效果相同——重试安全的前提。 :::

几十个渠道、同一模型多把 key、上游时不时抽风——把不可靠的上游集合抽象成一个可靠的端点,就是选路与重试层的全部工作。这套机制横跨两个模块:Distribute 中间件负责首次选路,controller/relay.go 的重试循环负责失败后的换路与熔断。

选路算法:三步走

model/channel_cache.goGetRandomSatisfiedChannel(约 117-200 行):

  1. 过滤:按 group + model 从三级索引取候选渠道(索引来自内存缓存;未开缓存时直接查 abilities 表——Group×Model×ChannelId 的路由索引);模型名精确匹配不上时,用规范化名称(RoutingMatchModelName)再试一轮;
  2. 分档:候选渠道的 priority 去重后降序排列,retry 次数作为档位下标
  3. 加权随机:同档内按 weight 加权随机,带平滑因子——全部权重为 0 时各渠道有效权重记 100(没人配权重就不饿死);平均权重低于 10 时平滑因子取 100(避免”一家独大”的权重悬殊)。
// model/channel_cache.go:159-168(节选)
var sortedUniquePriorities []int
...
    sortedUniquePriorities = append(sortedUniquePriorities, priority)
...
if retry >= len(uniquePriorities) {
    retry = len(uniquePriorities) - 1        // 超出档位 → 停留在最低档
}
targetPriority := int64(sortedUniquePriorities[retry])

这个设计的聪明之处:retry 次数天然映射到优先级降档。重试的语义是”换个更可靠的试试”,配置的语义是”priority 越高越优先”——两者零成本统一,不需要任何额外的降级策略配置。

重试循环:编排、熔断与终止

controller/relay.go 的主循环把选路、分发、熔断串起来:

// controller/relay.go:186-252(节选)
for ; retryParam.GetRetry() <= common.RetryTimes; retryParam.IncreaseRetry() {
    channel, channelErr := getChannel(c, relayInfo, retryParam)
    ...
    switch relayFormat {
    case types.RelayFormatClaude:  newAPIError = relay.ClaudeHelper(c, relayInfo)
    case types.RelayFormatGemini:  newAPIError = geminiRelayHandler(c, relayInfo)
    default:                       newAPIError = relayHandler(c, relayInfo)
    }
    if newAPIError == nil { relayInfo.LastError = nil; return }   // 成功即返回
    processChannelError(c, channelError, newAPIError, relayInfo)
    if !shouldRetry(c, newAPIError, common.RetryTimes-retryParam.GetRetry()) { break }
}

三个关键机制逐一展开:

shouldRetry(relay.go

):带 SkipRetry 标记的错误不重试(参数错误——换几个上游都一样错)、2xx 不重试、其余状态码是否重试由 operation_setting.ShouldRetryByStatusCode 配置。

processChannelError → 自动禁用(relay.go

):

// controller/relay.go:397-408(节选)
func processChannelError(c *gin.Context, channelError types.ChannelError,
    err *types.NewAPIError, relayInfo *relaycommon.RelayInfo) {
    logger.LogError(c, fmt.Sprintf("channel error (channel #%d, status code: %d): %s", ...))
    if service.ShouldDisableChannel(err) && channelError.AutoBan {
        gopool.Go(func() {
            service.DisableChannel(channelError, err.ErrorWithStatusCode())
        })
    }
    ... // 异步写错误日志 model.RecordErrorLog
}

ShouldDisableChannel(service/channel.go

)四重条件:全局开关 AutomaticDisableChannelEnabled + 渠道级 auto_ban 字段 + 状态码规则 + 关键词 AC 自动机匹配(上游返回体里出现”quota exceeded”之类关键词即认定渠道级故障)。禁用是异步的(gopool),置为 ChannelStatusAutoDisabled 并邮件通知管理员;多 key 渠道只禁出错的那把 key

RetryTimes 默认为 0(common/constants.go

)——重试是管理员主动开启的能力,默认行为最保守。

auto 分组的跨组重试

分组为 auto 时重试更进一步:组内 priority 逐档耗尽后切换到下一个有可用渠道的分组并重置重试计数service/channel_select.go:108-218,注释里给了 Retry=0..3 的完整分组切换示例)。跨组重试让”本组全挂”不再是服务中断——这是第 4 篇 auto 组动态路由的完整形态。

一次带重试的完整时序

图表(newapi-loadbalancing-retry.md)

与其他模块的联系

  • ← Distribute(第 2 篇):首次选路与上下文写入;重试时 getChannel 绕过中间件直接调 CacheGetRandomSatisfiedChannel
  • → 渠道(第 5 篇):priority/weight/auto_ban 全是渠道配置;熔断结果写回渠道状态;
  • → 计费(第 8 篇):每次换渠道重试不重复预扣(BillingSession 与渠道无关),全部失败时统一退款;
  • → 日志(第 9 篇)use_channel 字段记录完整重试轨迹,错误日志异步落库。

常见踩坑

  1. 配了 priority 但没生效——RetryTimes=0(默认)时永远只在最高档;分档是重试驱动的;
  2. 自动禁用误杀——上游偶发 5xx 被关键词命中,检查 auto_ban 与禁用关键词配置;
  3. 权重全一样还想要精确比例分流——weight 是加权随机不是配额保证,小流量下偏差明显;
  4. 重试放大写操作——重试只应用于幂等的模型推理请求,任务类(视频生成)的重试语义在 TaskAdaptor 层单独处理。

随堂练习(带验收标准)

  1. 配两个同模型渠道(priority 10 与 5),反复请求。验收:全部命中 priority 10;手动禁用 10 档后自动切到 5;
  2. RetryTimes=3,给高优先级渠道配错误 key。验收:请求最终成功、日志 use_channel 显示切换轨迹、坏渠道被自动禁用;
  3. 熔断边界实验:给渠道配 auto_ban,但上游返回的是 400 参数错误。验收:渠道不会被禁用(参数错误是调用方问题)——能解释为什么这个区分很重要;
  4. service/channel_select.go 的 auto 组切换注释,画出 retry 0..3 的分组切换示意图。

← 返回文章列表