负载均衡与失败重试
专栏:new-api 源码拆解 · 第 7 / 12 篇:::info 学习目标 完成本篇后你能够:手推选路算法对一组渠道的选择结果;逐步追踪一次失败请求的渠道切换与熔断过程;配置 auto 组的跨组容灾。 前置:第 5 篇完成。预计时长:50 分钟。 :::
:::note 本章术语速查(新手建议先读)
- 负载均衡(Load Balancing):多个上游时决定”这次请求给谁”。
- 优先级(Priority):数字越大越先用;重试时自动降到下一档。
- 加权随机(Weighted Random):同优先级的渠道按 weight 比例分流。
- 熔断(Auto Ban):出错达到条件自动下线,防止持续浪费请求。
- 幂等(Idempotent):同一操作执行多次和执行一次效果相同——重试安全的前提。 :::
几十个渠道、同一模型多把 key、上游时不时抽风——把不可靠的上游集合抽象成一个可靠的端点,就是选路与重试层的全部工作。这套机制横跨两个模块:Distribute 中间件负责首次选路,controller/relay.go 的重试循环负责失败后的换路与熔断。
选路算法:三步走
model/channel_cache.go 的 GetRandomSatisfiedChannel(约 117-200 行):
- 过滤:按 group + model 从三级索引取候选渠道(索引来自内存缓存;未开缓存时直接查 abilities 表——Group×Model×ChannelId 的路由索引);模型名精确匹配不上时,用规范化名称(
RoutingMatchModelName)再试一轮; - 分档:候选渠道的 priority 去重后降序排列,retry 次数作为档位下标;
- 加权随机:同档内按 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
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 组动态路由的完整形态。
一次带重试的完整时序
与其他模块的联系
- ← Distribute(第 2 篇):首次选路与上下文写入;重试时
getChannel绕过中间件直接调CacheGetRandomSatisfiedChannel; - → 渠道(第 5 篇):priority/weight/auto_ban 全是渠道配置;熔断结果写回渠道状态;
- → 计费(第 8 篇):每次换渠道重试不重复预扣(BillingSession 与渠道无关),全部失败时统一退款;
- → 日志(第 9 篇):
use_channel字段记录完整重试轨迹,错误日志异步落库。
常见踩坑
- 配了 priority 但没生效——
RetryTimes=0(默认)时永远只在最高档;分档是重试驱动的; - 自动禁用误杀——上游偶发 5xx 被关键词命中,检查 auto_ban 与禁用关键词配置;
- 权重全一样还想要精确比例分流——weight 是加权随机不是配额保证,小流量下偏差明显;
- 重试放大写操作——重试只应用于幂等的模型推理请求,任务类(视频生成)的重试语义在 TaskAdaptor 层单独处理。
随堂练习(带验收标准)
- 配两个同模型渠道(priority 10 与 5),反复请求。验收:全部命中 priority 10;手动禁用 10 档后自动切到 5;
- 设
RetryTimes=3,给高优先级渠道配错误 key。验收:请求最终成功、日志use_channel显示切换轨迹、坏渠道被自动禁用; - 熔断边界实验:给渠道配 auto_ban,但上游返回的是 400 参数错误。验收:渠道不会被禁用(参数错误是调用方问题)——能解释为什么这个区分很重要;
- 读
service/channel_select.go的 auto 组切换注释,画出 retry 0..3 的分组切换示意图。