当上游接口开始大面积超时,下游服务的线程池、连接池会被慢慢拖垮,这就是典型的雪崩效应。熔断器的作用就是在故障累积到一定程度时主动切断调用,让系统获得喘息的机会。很多Ruby开发者用过circuitbox这类现成gem,但对状态转换的内部逻辑和超时判定往往一知半解,导致恢复阶段频繁出现“熔断了却一直不恢复”或“还没恢复就放进大量流量”的问题。这篇文章从零实现一个带超时机制的状态机,把每个跳转条件讲透。

熔断器的三种状态与转换条件
熔断器本质上是一个有限状态机,包含Closed(关闭)、Open(打开)、Half-Open(半开)三种状态。Closed状态下请求正常放行,但每次失败都会被记录,一旦在统计窗口内失败次数达到阈值,状态切换到Open。Open状态下所有请求直接被拒绝,不再访问下游服务,这个“拒绝期”就是冷却超时(cooldown timeout)。
冷却时间一到,熔断器进入Half-Open状态。此时会放行少量探测请求,如果探测成功,说明下游已恢复,状态回到Closed并清空计数器;如果探测失败,则重新回到Open,冷却时间重新计时。这三个状态的循环构成了熔断器的完整生命周期。需要注意的是,Half-Open是一个极不稳定的过渡态,实现时必须限制并发探测数,否则一瞬间放进去的请求可能再次把刚恢复的服务打挂。
用Ruby实现带超时判定的状态机核心
实现状态转换的关键在于用时间戳代替定时器。与其让一个后台线程不断轮询,不如记录“何时进入Open状态”,每次请求到来时用当前时间与之比较,超过冷却时间即自动迁移到Half-Open。这种方式无锁开销小,也避免了进程fork后定时器失效的问题。
class CircuitBreaker
STATES = [:closed, :open, :half_open].freeze
attr_reader :state, :failure_count
def initialize(failure_threshold: 5, cooldown: 30, probe_limit: 3)
@failure_threshold = failure_threshold
@cooldown = cooldown # Open 状态冷却秒数
@probe_limit = probe_limit # Half-Open 最大并发探测数
@failure_count = 0
@opened_at = nil
@probing = 0
@mutex = Mutex.new
@state = :closed
end
def allow_request?
@mutex.synchronize do
case @state
when :closed then true
when :open
if Time.now - @opened_at >= @cooldown
transition_to(:half_open)
true
else
false
end
when :half_open
@probing < @probe_limit
end
end
end
def record_success
@mutex.synchronize do
if @state == :half_open
@probing -= 1
transition_to(:closed) # 探测成功,恢复服务
else
@failure_count = 0
end
end
end
def record_failure
@mutex.synchronize do
if @state == :half_open
transition_to(:open) # 探测失败,重新熔断
else
@failure_count += 1
transition_to(:open) if @failure_count >= @failure_threshold
end
end
end
private
def transition_to(new_state)
@state = new_state
case new_state
when :open
@opened_at = Time.now
@probing = 0
when :closed
@failure_count = 0
@opened_at = nil
end
end
end
上面这段代码有几个细节值得注意。allow_request?在Open状态被调用时顺便完成状态迁移,这是惰性求值的思想:没有请求时不做任何事,有请求时才检查冷却是否到期。探测并发数用@probing控制,进入Half-Open时第一次调用会放行,后续调用需要探测名额未被占满。所有状态读写都包在Mutex里,因为Ruby的多线程(Thread)环境下,两个线程可能同时读到failure_count == 4然后各自加一,导致计数漂移。
请求超时与失败判定的结合
熔断器只管状态,不管请求怎么发。真正的失败判定要结合请求超时,用Ruby标准库的Timeout模块可以快速实现,但它靠异常打断线程,在生产环境并不优雅。更推荐的做法是给HTTP客户端设置套接字层的读写超时,比如Net::HTTP的open_timeout和read_timeout,让超时在网络层自然发生。
require 'net/http'
require 'uri'
class GuardedClient
def initialize(breaker)
@breaker = breaker
end
def get(url)
raise 'CircuitOpen' unless @breaker.allow_request?
uri = URI(url)
http = Net::HTTP.new(uri.host, uri.port)
http.open_timeout = 2 # 连接超时
http.read_timeout = 3 # 读取超时
response = http.get(uri.request_uri)
@breaker.record_success
response
rescue Net::OpenTimeout, Net::ReadTimeout => e
@breaker.record_failure
raise e
rescue StandardError => e
# 连接拒绝等错误同样计入失败
@breaker.record_failure
raise e
end
end
这里有一个容易被忽略的问题:哪些异常应该算失败?404这类业务错误通常不该计入熔断统计,否则一次批量删除不存在资源的操作就可能触发误熔断。建议只把超时、连接拒绝、5xx响应视为失败,4xx按业务处理。另外record_success在Half-Open状态只递减探测数而不直接关闭熔断,如果要求“连续N次探测成功才恢复”,可以在此基础上加一个成功计数器,把恢复策略从激进改为保守。
超时方案的选型与常见坑
时间戳方案并非唯一选择。另一种常见做法是用Concurrent::TimerTask或Thread.new { sleep cooldown }在到期后主动把状态改成Half-Open。主动方案的优点是状态变化即时可见,监控面板能立刻反映;缺点是每个熔断器都挂着一个线程,如果你为上百个下游接口各自维护熔断器,线程开销会相当可观,而且在Puma多进程部署下,每个进程都有一份独立状态,定时器的一致性很难保证。
还有一个高频踩坑点:冷却时间的选取。太短的话,下游还在崩溃边缘就被反复探测,等于熔断形同虚设;太长又会拉长整体恢复时间。工程上常见的做法是指数退避,第一次熔断冷却10秒,第二次20秒,第三次40秒,封顶比如5分钟。实现上只需在transition_to(:open)时记录一个@open_count,冷却时长取[10 * 2 ** @open_count, 300].min即可。同时建议暴露state和failure_count给监控指标,配合Grafana观察熔断触发频率,一旦发现频繁Open,说明该认真排查下游容量了。
总结一下,一个健壮的Ruby熔断器需要做到四点:状态转换原子化、冷却超时惰性判定、探测请求限流、失败分类精确。把这套状态机封装成中间件挂在HTTP客户端层,就能以极低的成本为服务依赖加上一层保护,避免局部故障演变成全局瘫痪。