微服务依赖中,熔断器通常有三种状态:关闭、打开、半开。当连续失败达到阈值,熔断器进入打开状态,直接拒绝请求;经过冷却时间后进入半开状态,只放行少量试探请求来判断下游是否恢复。试探请求的成败直接决定熔断器回到关闭还是重新打开,但不少实现只关注放行数量,却忽略了对这些请求进行明确标记。这会导致业务代码、重试机制和日志系统无法区分普通请求与试探请求,统计结果出现偏差。在Ruby服务中,可以借助请求上下文和中间件在入口处完成标记,避免侵入业务代码。

一、半开状态下标记试探请求的必要性
熔断器的核心逻辑是防止故障扩散。打开状态直接拒绝所有请求,给下游恢复时间;半开状态则像一道窄门,只允许有限的请求通过。如果这些试探请求没有被标记,业务代码可能会对它们执行与普通请求完全相同的处理,包括额外的远程调用、写操作或事务提交。一旦下游仍然异常,试探请求失败会触发重试或错误告警,而业务方很难判断这些失败究竟来自真实流量还是半开探测,导致监控曲线和告警规则被污染。
标记的第二个作用是防止并发重复试探。半开状态通常限制同时通过的试探请求数量,例如最多允许三个。如果标记只存在于熔断器内部计数,请求进入业务层后,重试机制可能再次发起调用,使得实际到达下游的试探请求超过预期。给请求上下文写入标记后,业务代码可以根据标记跳过重试、关闭额外日志或者降低处理优先级,让试探请求以最小代价完成探测。
从实现位置看,标记可以放在线程局部变量、请求环境对象或请求头中。线程局部变量在同步多线程模型下简单有效,但Ruby应用若使用事件循环或纤程,线程局部变量容易串号。请求头传递适合跨服务场景,但会改变业务协议。比较稳妥的做法是在服务入口的中间件中,把标记写入env或请求上下文对象,并在整个调用链中共享。
二、Ruby中标记试探请求的实现方案
Ruby的Web服务大多基于Rack协议,请求信息统一保存在env哈希中。我们可以在Rack中间件里完成熔断判断与标记写入。中间件位于请求处理链的最前端,比业务代码先执行,因此标记能够在控制器层、服务层甚至异步任务中读取。对于非Rack场景,比如后台任务调用外部API,可以使用Thread.current配合互斥锁管理请求上下文,但需要特别注意清理,避免内存泄漏和线程复用带来的残留。
一个基础的熔断器对象需要维护状态、失败计数和半开试探计数。状态为:closed时允许所有请求,状态为:open时拒绝请求,状态为:half_open时仅允许有限数量的试探请求。判断逻辑需要加锁,因为多个线程可能同时读取并修改半开计数。下面的代码展示了一个简单的线程安全熔断器,并在放行试探请求时返回可写入上下文的标记。
class CircuitBreaker
HALF_OPEN_LIMIT = 3
def initialize
@state = :closed
@failure_count = 0
@half_open_trials = 0
@mutex = Mutex.new
end
def allow_request?
@mutex.synchronize do
case @state
when :closed
true
when :open
false
when :half_open
if @half_open_trials < HALF_OPEN_LIMIT
@half_open_trials += 1
return { allowed: true, trial: true }
end
{ allowed: false, trial: false }
end
end
end
def record_success
@mutex.synchronize do
@failure_count = 0
@half_open_trials = 0
@state = :closed
end
end
def record_failure
@mutex.synchronize do
@failure_count += 1
if @state == :half_open || @failure_count >= 5
@state = :open
@half_open_trials = 0
@opened_at = Time.now
end
end
end
end
上面的代码里,allow_request?返回一个包含allowed和trial的结构。调用方如果发现trial为真,就把标记写入请求上下文。对于Rack应用,通常把标记放入env['circuit_breaker.trial'],这样后续所有基于env的中间件和控制器都能读取。该标记只在半开放行的请求中存在,关闭状态下的普通请求不会携带,从而实现精确区分。
另一种做法是直接把标记写入Thread.current,例如Thread.current[:circuit_breaker_trial] = true。这种方式在同步阻塞模型下足够轻量,但需要确保请求处理完毕后清理。否则线程被连接池复用后,下一个请求可能错误地读取到上一个请求的标记。如果应用使用了async、eventmachine或falcon等异步服务器,线程局部变量也可能跨请求污染,因此更推荐使用env传递。
三、Rack中间件集成与标记传递
把上述熔断器接入Rack应用时,可以编写一个专门的中间件。它在请求进入时调用allow_request?,如果请求被拒绝,直接返回503 Service Unavailable;如果被放行且属于试探请求,将标记写入env。请求处理完成后,中间件根据响应状态或捕获的异常调用record_success或record_failure,更新熔断器状态。这样熔断逻辑对业务代码完全透明。
class CircuitBreakerMiddleware
def initialize(app, breaker)
@app = app
@breaker = breaker
end
def call(env)
decision = @breaker.allow_request?
return [503, { 'Content-Type' => 'text/plain' }, ['Service Unavailable']] unless decision[:allowed]
env['circuit_breaker.trial'] = decision[:trial]
begin
status, headers, body = @app.call(env)
if status >= 500
@breaker.record_failure
else
@breaker.record_success
end
[status, headers, body]
rescue StandardError
@breaker.record_failure
raise
end
end
end
在上述实现中,env['circuit_breaker.trial']可以继续向业务层传递。例如控制器可以读取该值来决定是否跳过某些非关键逻辑,服务层也可以据此关闭重试或调整超时时间。需要强调的是,标记应当是只读的,业务代码不应该修改它。如果业务代码错误地删除了标记,熔断统计虽然不受影响,但日志和监控会失去对试探请求的追踪能力。可以通过冻结或约定命名空间来降低误用风险。
试探请求成功或失败后,熔断器需要及时更新状态。如果中间件只根据HTTP状态码判断成败,可能漏掉超时、连接拒绝等异常。上面代码中通过rescue StandardError捕获异常并记录失败,能够覆盖大部分网络错误。对于异步任务或后台作业,调用方需要手动调用record_success和record_failure,因为没有Rack响应状态可依赖。
四、避免标记失效与并发误区
标记失效最常见的原因是上下文传递链断裂。例如中间件把标记写入env,但后续使用了新的线程或纤程处理请求,新上下文没有继承env。在Ruby中,Thread.new不会自动继承Thread.current的局部变量,需要使用Thread.current.thread_variable_get配合显式传递,或者使用request_store这类库把请求上下文绑定到当前执行单元。
并发场景下,半开试探请求的数量控制必须依赖互斥锁。如果只用普通变量计数,两个线程可能同时读到旧值并同时放行,导致半开窗口被击穿。上面的CircuitBreaker类使用Mutex保护状态和计数,但锁的粒度要尽量小,避免在持有锁时调用外部服务。如果熔断器判断逻辑耗时较长,可以改为先加锁读取状态,再根据状态进行放行决策,最后加锁更新计数,但要注意状态切换的原子性。
另一个容易忽略的误区是试探请求的成功判定标准过宽。半开状态下只要返回了状态码200就认为下游恢复,但如果返回的是缓存数据或者降级响应,下游真实依赖可能仍然不可用。此时标记可以帮助业务层针对试探请求执行更严格的健康检查,例如要求试探请求必须访问真实后端并返回预期的业务字段。否则熔断器可能过早关闭,导致后续普通请求再次大面积失败。
生产环境中,建议为试探请求标记建立独立的指标。例如在监控系统中统计每单位时间内的试探请求数量、成功率和耗时分布。这样即使熔断器状态切换频繁,也能通过标记数据快速定位是哪些接口或依赖在反复触发半开探测,从而优化冷却时间、失败阈值等参数。
五、完整接入示例与调优建议
假设有一个Rack应用需要调用下游支付服务,我们可以实例化CircuitBreaker对象,并在config.ru中挂载中间件。初始化时可以配置失败阈值、冷却时间和半开放行上限。如果支付服务使用独立的HTTP客户端,可以在客户端适配器中读取env['circuit_breaker.trial'],当标记为真时关闭自动重试,并把超时时间从默认的5秒缩短到2秒,以加速半开探测的反馈。
对于非Rack的Ruby服务,比如Sidekiq后台任务,可以在任务入口创建一个请求上下文对象,手动调用熔断器并传递标记。任务执行结束后在ensure块中清理上下文,防止内存泄漏。这种方式虽然比中间件复杂,但能覆盖所有类型的网络依赖,包括数据库、消息队列和第三方HTTP API。
熔断器的参数调优需要结合标记数据。半开放行数量过小会拖慢恢复速度,过大会增加故障期间的流量冲击。冷却时间过短会让下游在未完全恢复时频繁接收探测请求。通过标记统计试探请求的真实成功率,可以更科学地设定这些参数。例如当试探成功率持续高于90%时,可以适当提高半开放行上限;当成功率低于50%时,应延长冷却时间并保持打开状态。