跳转到内容

熔断器模式与 Provider 故障转移

熔断器模式与 Provider 故障转移

学习目标

  • 理解 CC Switch 熔断器的具体实现结构(状态、计数器、配置热更新)
  • 掌握 CC Switch 中故障转移队列的管理方式
  • 了解熔断器状态在代理运行时的热更新机制
  • 识别 HalfOpen 探测名额并发竞争的风险与防护

前置知识

下文聚焦 CC Switch 的具体代码实现。


项目实践

熔断器实现结构

CC Switch 的熔断器实现在 src-tauri/src/proxy/circuit_breaker.rs 中,核心结构如下:

CircuitBreaker {
state: RwLock<CircuitState>, // Closed / Open / HalfOpen
consecutive_failures: AtomicU32, // 连续失败计数
consecutive_successes: AtomicU32, // 半开状态成功计数
total_requests: AtomicU32, // 总请求数(错误率计算)
failed_requests: AtomicU32, // 失败请求数
last_opened_at: RwLock<Option<Instant>>, // 上次打开时间
config: RwLock<CircuitBreakerConfig>, // 支持热更新
half_open_requests: AtomicU32, // 半开探测名额限流
}

关键设计决策

  1. 计数器使用 AtomicU32:状态转换使用 RwLock,计数器使用原子操作。原因是计数器频繁更新,用锁会导致性能瓶颈,而原子操作无锁且线程安全。

  2. AllowResult 返回值allow_request() 不仅返回是否允许,还返回 used_half_open_permit——标记本次请求是否占用了 HalfOpen 探测名额。调用方在请求结束后必须把该值传回 record_success/record_failure,用于正确释放探测名额。

  3. HalfOpen 严格限流allow_half_open_probe() 每次只允许 1 个探测请求通过。超过限额的请求会回退计数并被拒绝。

状态转换的并发安全

HalfOpen 转换过程中最容易被并发调用破坏。CC Switch 的防护逻辑:

transition_to_half_open() {
1. 获取 state 写锁
2. 检查当前状态是否为 Open(防御性检查)
3. 如果不是 Open,直接返回(不重置计数器)
4. 切换状态为 HalfOpen
5. 重置 consecutive_successes(但保留 half_open_requests)
}

为什么不在转换时重置 half_open_requests:如果外部在 HalfOpen 转换过程中又调用了 transition_to_half_open,而此函数重置了 half_open_requests,会导致正在进行的探测请求释放名额时覆盖掉新占用的名额,使限流失效。

CC Switch 的做法是:transition_to_half_open 不重置 half_open_requests,只在从 Open 首次转换到 HalfOpen 时通过 allow_half_open_probe() 自然设置。

双触发条件

CC Switch 的熔断器打开有两个独立条件(record_failure 中):

// 条件1: 连续失败 >= 阈值
if failures >= config.failure_threshold {
transition_to_open();
}
// 条件2: 错误率 >= 阈值(且有足够样本)
else if total >= config.min_requests {
let error_rate = failed / total;
if error_rate >= config.error_rate_threshold {
transition_to_open();
}
}

这确保了两种故障模式都能被捕获:

  • 突发故障:连续失败 4 次立即触发(条件1)
  • 慢性故障:10 次请求中 6 次失败(60%),即使不连续也触发(条件2)

故障转移队列

故障转移队列存储在数据库的 failover_queue 相关表中,通过命令层管理:

  • get_failover_queue — 获取当前队列
  • add_to_failover_queue — 添加备用 Provider
  • remove_from_failover_queue — 移除备用 Provider
  • get_available_providers_for_failover — 获取可加入队列的候选 Provider

工作原理

  1. 当某个 Provider 的熔断器打开时,代理自动从故障转移队列中取下一个健康状态为 Closed 的 Provider
  2. 如果队列中所有 Provider 都不健康,返回错误给客户端
  3. 原 Provider 的熔断器进入 HalfOpen 并恢复后,可以重新成为候选

配置热更新

代理运行中可以更新熔断器配置:

update_circuit_breaker_configs(new_config) {
for each provider's circuit_breaker:
circuit_breaker.update_config(new_config) // 只更新 RwLock<Config>
// 不重置状态或计数器
}

update_config 方法只替换 config 字段,不影响当前的状态、计数器或打开时间。这意味着可以在不中断任何正在进行中的请求的情况下调整熔断器行为。

健康状态数据库持久化

熔断器内存状态在应用重启后丢失。CC Switch 将健康状态(成功/失败计数、最后检测时间)持久化到数据库:

  • provider_health 表:存储每个 Provider 的健康评分
  • stream_check_logs 表:存储流式检测的详细日志
  • 启动时加载上次健康状态,决定是否预先打开某些熔断器

问题与规避

陷阱 1:HalfOpen 探测名额泄漏

问题:如果 allow_request() 返回了 used_half_open_permit: true,但请求在执行过程中崩溃,record_success/record_failure 永远不会被调用,探测名额永久占用,HalfOpen 状态卡死。

CC Switch 的解决方案:提供 release_half_open_permit() 方法,使用 CAS(Compare-And-Swap)原子操作安全地释放名额。整流器等场景即使不将请求结果计入健康度,也必须调用此方法释放名额。

陷阱 2:代理停止后熔断器状态丢失

问题:代理停止时,内存中的熔断器状态全部丢失。下次启动时所有 Provider 从 Closed 状态开始,可能在供应商尚未恢复时就大量发送请求。

CC Switch 的解决方案:通过数据库中的 provider_health 表和 stream_check_logs 表恢复健康状态。启动时的 restore_proxy_state_on_startup 函数检查上次代理状态,如果之前是接管模式,自动恢复接管并加载历史健康数据。

陷阱 3:故障转移队列中的 Provider 也已故障

问题:如果所有备用 Provider 同时不可用(如全部依赖同一个上游 CDN),故障转移队列无意义。

CC Switch 的解决方案

  • 健康检测模块独立评估每个 Provider,不假设”一个 Provider 故障 = 其他 Provider 健康”
  • 用户可以在 UI 中看到每个 Provider 的实时健康状态
  • 支持流式健康检查(实际发送一个极小的生成请求),比简单的 HTTP ping 更可靠

设计取舍

每个 Provider 独立熔断器 vs 全局熔断器

维度独立熔断器全局熔断器
精度精确控制每个 Provider一刀切
复杂度O(N) 个熔断器实例1 个实例
场景适应多供应商场景最佳单供应商场景够用

CC Switch 选择每个 Provider 独立熔断器,因为它的核心场景就是管理多个供应商,某个供应商故障不应影响其他供应商。

内存状态 vs 持久化状态

熔断器的实时状态(当前是 Closed 还是 Open)存储在内存中,而非数据库中。原因是:

  • 熔断器需要高频更新(每次请求后),SQLite 写入太慢
  • 应用重启后从 Closed 开始是安全的默认行为

但对于健康评分这种低频更新的指标,CC Switch 选择持久化到数据库,因为:

  • 评分计算涉及多个历史数据点的聚合
  • 重启后需要上次的评分作为初始值

参考来源