Go 熔断器实现:推理服务下游不可用时快速失败 — 方向3 Go 熔断器实现推理服务下游不可用时快速失败 — 方向3一、超时等待消耗的是整个推理链路的吞吐量推理服务的调用链路通常不短API 网关 → 推理调度器 → 模型服务 → 向量数据库 → Token 计数服务。链路上每一个环节的延迟都会传导到最终响应时间。当一个下游服务开始变慢——不是直接挂了而是从 50ms 的响应时间漂移到 5000ms 甚至超时——整个推理链路的吞吐量会断崖式下跌。这是因为每一个等待中的协程都占用着连接、内存与调度槽位。上游如果不做快速失败大量请求堆积的结果就是全链路雪崩。在 Go 里用context.WithTimeout设一个超时是最基本的防御手段。但这只是兜底。对于一个正在变慢的下游每个请求都等满超时时间再失败等于在浪费上游的资源。正确的策略是检测到下游持续失败后直接熔断——连请求都不发。推理链路里这个需求更加紧迫。模型服务的 SLO 往往在 200-500ms 之间。如果某台 GPU 节点因为显存碎片化导致推理延迟飙到 3 秒以上在那台节点上积压的请求会像多米诺骨牌一样逐个超时最终把所有可用连接耗尽。二、滑动窗口熔断的状态机熔断器不是简单的连续失败 N 次就打开。推理场景下偶尔的 GPU OOM 或模型加载抖动是正常现象不应该触发熔断。真正需要熔断的是持续性故障。这个三状态模型的关键点在于Half-Open状态的存在。熔断不是永久的。冷却时间一到熔断器会放行少量探测请求确认下游是否恢复。如果探测请求也失败就继续熔断。如果探测请求成功关闭熔断恢复正常。这里有一个推理场景特有的考量探测请求使用什么数据如果探测请求发的是一个Hello World级别的 dummy prompt下游响应正常但实际用户发的是 4000 token 的推理请求很可能再度触发 GPU OOM。因此探测请求必须使用接近生产流量特征的真实请求。三、带错误率统计的熔断器实现下面是生产级的 Go 熔断器实现核心在于基于滑动窗口的错误率统计而非简单的连续失败计数。// CircuitBreaker 基于滑动窗口错误率的三态熔断器 type CircuitBreaker struct { mu sync.RWMutex state State // 当前状态 window *SlidingWindow // 滑动窗口按秒级桶统计 config Config openedAt time.Time // 进入Open状态的时间 halfOpenMax int // HalfOpen状态允许的探测请求数 halfOpenCnt atomic.Int32 // 当前HalfOpen已发出的探测请求数 } type State int const ( StateClosed State iota StateOpen StateHalfOpen ) type Config struct { WindowSize time.Duration // 滑动窗口长度建议30s-120s BucketCount int // 窗口内桶数量如30个1s桶 ErrorThreshold float64 // 错误率阈值如0.5表示50%错误率即熔断 MinSampleSize int64 // 最小样本数避免少量请求误触发 CooldownDuration time.Duration // Open到HalfOpen的冷却时间 } // SlidingWindow 秒级桶滑动窗口 type SlidingWindow struct { mu sync.Mutex buckets []bucket size time.Duration } type bucket struct { timestamp int64 // Unix秒 total int64 failures int64 } func NewCircuitBreaker(cfg Config) *CircuitBreaker { buckets : make([]bucket, cfg.BucketCount) return CircuitBreaker{ state: StateClosed, window: SlidingWindow{buckets: buckets, size: cfg.WindowSize}, config: cfg, } } // Call 执行受熔断器保护的调用 func (cb *CircuitBreaker) Call(ctx context.Context, fn func() error) error { state : cb.beforeRequest() switch state { case StateOpen: return ErrCircuitOpen case StateHalfOpen: if cb.halfOpenCnt.Add(1) int32(cb.config.MinSampleSize) { cb.halfOpenCnt.Add(-1) return ErrCircuitOpen // 探测请求已超额拒绝 } defer cb.halfOpenCnt.Add(-1) } err : fn() cb.afterRequest(err nil) return err } func (cb *CircuitBreaker) beforeRequest() State { cb.mu.RLock() state : cb.state cb.mu.RUnlock() if state StateOpen { cb.mu.Lock() // 检查冷却时间是否已到若已到则进入HalfOpen if cb.state StateOpen time.Since(cb.openedAt) cb.config.CooldownDuration { cb.state StateHalfOpen cb.halfOpenCnt.Store(0) } state cb.state cb.mu.Unlock() } return state } func (cb *CircuitBreaker) afterRequest(success bool) { cb.window.record(success) cb.mu.Lock() defer cb.mu.Unlock() if cb.state ! StateClosed cb.state ! StateHalfOpen { return } total, failures : cb.window.stats() // 样本量不足时不触发状态变更 if total cb.config.MinSampleSize { return } errRate : float64(failures) / float64(total) if cb.state StateClosed errRate cb.config.ErrorThreshold { // 错误率达标打开熔断 cb.state StateOpen cb.openedAt time.Now() } else if cb.state StateHalfOpen errRate cb.config.ErrorThreshold { // 探测请求成功关闭熔断 cb.state StateClosed } else if cb.state StateHalfOpen errRate cb.config.ErrorThreshold { // HalfOpen又失败继续保持Open cb.state StateOpen cb.openedAt time.Now() // 重置冷却时间 } }错误率统计使用滑动窗口而非固定窗口避免了窗口边界重置导致的统计盲区。统计时不仅看错误率还要求达到最小样本数——防止只有 2 个请求、1 个失败的场景误触发熔断。四、快速失败不是万能药熔断器的边界条件值得认真对待。误判风险如果错误率阈值设得太低如 20%正常波动就会被误判为故障导致大量请求被拒绝。推理场景建议的阈值是 50%——宁可多等几次重试也不要在有 50% 成功率时就开始拒绝请求。级联熔断的副作用上游熔断了下游下游的流量下降会被误读为负载降低从而触发下游的自动缩容。等上游的熔断冷却时间一过流量重新涌入下游可能已经被缩到零了。熔断和自动扩缩容的节奏必须协同设计。探测请求的雪崩如果 Half-Open 状态放出的探测请求恰好是一批大 prompt可能再次触发 GPU OOM导致熔断永不开。必须限制探测请求的 token 长度上限。适用边界适合下游服务有明显的慢故障特征延迟漂移、偶发超时而非彻底不可达。适合调用量大到足以支撑滑动窗口的统计意义每秒 ≥ 10 次调用。不适合低频调用的场景每分钟只有几次样本量不足以支撑错误率判断。不适合下游是幂等性无法保证的服务。快速失败后如果重试可能产生重复副作用。五、总结熔断器的目标不是避免失败——在分布式系统里失败是常态。它的目标是在失败不可避免时尽可能快地失败尽可能少地浪费资源。实现要点用滑动窗口做错误率统计而非连续失败计数用最小样本量防止误判用冷却时间 Half-Open 探测实现自恢复。同时在推理场景下探测请求必须贴近真实流量否则恢复判断毫无意义。基础设施不需要漂亮话需要的是当下游真正开始变慢时你的熔断器比用户的超时先到达。