线上排查网络服务依赖故障时,经常出现一种尴尬情况:熔断器明明已经触发过状态切换,但事后翻日志只能看到业务报错和超时记录,看不到熔断器是在哪个时刻从关闭变成打开,也不知道半开探测到底有没有放行过请求。缺失状态转换日志会让故障复盘变成猜测,尤其当多个服务同时降级时,很难还原依赖雪崩的传播顺序。记录熔断器状态转换的详细日志,本质上就是给状态机加上可观测性。下面给出一个不依赖特定gem的Ruby实现,核心思路是用统一的状态枚举和转换事件对象,在每次状态变化时输出一行结构化JSON日志。

一、先固化状态和转换事件
熔断器通常有三个核心状态:关闭、打开、半开。关闭状态下请求正常通过,连续失败达到阈值后切到打开;打开状态拒绝请求,等待冷却时间结束后进入半开;半开状态只放行少量探测请求,成功则回到关闭,失败则重新打开。状态本身用字符串容易拼错,用符号或常量会清晰很多。建议在代码里统一维护。State模块可以避免散落各处写:closed、:open这样的魔法值。
除了状态枚举,还需要一个转换事件结构来承载日志字段。这个结构不用太复杂,只要能描述一次状态变化即可。字段至少包括服务名、旧状态、新状态、触发原因、连续失败次数和时间戳。使用Ruby的Struct或Data都很方便,初始化时采用关键字参数还能提高可读性。
module CircuitState CLOSED = :closed OPEN = :open HALF_OPEN = :half_open end StateTransition = Struct.new( :service_name, :from_state, :to_state, :reason, :failure_count, :occurred_at, keyword_init: true )
这段定义把状态转换从一个隐式的赋值动作变成显式事件。之后每次@state = new_state前后,都能通过StateTransition把变化前后的上下文组织起来。如果有些系统还需要记录上游IP、请求方法或错误类型,可以继续在结构上扩展,但核心字段保持精简即可,避免日志过大。
二、在熔断器类里设置统一日志出口
有了状态和事件结构,下一步是把状态机封装成一个类,把所有状态变更收敛到同一个transition_to方法中。这样可以保证无论从哪个路径触发转换,日志格式、字段顺序、输出级别都一致。下面的类实现了关闭、打开、半开之间的基本流转,并通过Logger输出JSON行。代码里刻意把allow_request?和success、failure分开,是为了把探测请求的放行和控制逻辑交给外层调用者处理,熔断器本身只关注状态变化。
require 'logger'
require 'json'
class CircuitBreaker
attr_reader :state, :failure_count, :last_failure_reason
def initialize(service_name:, failure_threshold: 5, retry_timeout: 30, logger: Logger.new($stdout))
@service_name = service_name
@failure_threshold = failure_threshold
@retry_timeout = retry_timeout
@logger = logger
@state = CircuitState::CLOSED
@failure_count = 0
@last_failure_reason = nil
@opened_at = nil
@half_open_in_flight = false
end
def success
case @state
when CircuitState::CLOSED
@failure_count = 0
when CircuitState::HALF_OPEN
transition_to(CircuitState::CLOSED, reason: 'half_open_probe_succeeded')
@failure_count = 0
when CircuitState::OPEN
# 打开状态下成功回调通常被忽略
end
end
def failure(reason)
@failure_count += 1
@last_failure_reason = reason
if @state == CircuitState::CLOSED && @failure_count >= @failure_threshold
transition_to(CircuitState::OPEN, reason: reason)
elsif @state == CircuitState::HALF_OPEN
transition_to(CircuitState::OPEN, reason: "half_open_probe_failed: #{reason}")
end
end
def allow_request?
case @state
when CircuitState::CLOSED
true
when CircuitState::HALF_OPEN
!@half_open_in_flight
when CircuitState::OPEN
if @opened_at && Time.now - @opened_at >= @retry_timeout
transition_to(CircuitState::HALF_OPEN, reason: 'retry_timeout_reached')
true
else
false
end
end
end
def mark_probe_started
@half_open_in_flight = true
end
def mark_probe_finished
@half_open_in_flight = false
end
private
def transition_to(new_state, reason:)
old_state = @state
@state = new_state
@opened_at = Time.now if new_state == CircuitState::OPEN
log_transition(old_state, new_state, reason)
end
def log_transition(old_state, new_state, reason)
payload = {
service: @service_name,
from: old_state,
to: new_state,
reason: reason,
failure_count: @failure_count,
occurred_at: Time.now.utc.iso8601(3)
}
@logger.info(JSON.generate(payload))
end
end
注意transition_to先保存旧状态,再更新新状态,最后调用log_transition。这样做的好处是,日志里from和to分别表示转换前和转换后的状态,排查时一眼能看出方向。如果有多个线程并发访问,状态读写需要加锁,但示例先保持单线程或由调用方用互斥保护。
调用方式也很直接。创建实例后,业务代码在依赖调用成功时执行breaker.success,失败时执行breaker.failure并传入异常信息。请求发出前先调用breaker.allow_request?判断是否放行。半开状态需要由调用方维护mark_probe_started和mark_probe_finished,避免同时发出多个探测请求。这样日志里就能完整看到状态机每次变化背后的业务动作。
三、设计日志字段和级别,方便事后检索
结构化日志的价值在于能被日志系统解析和检索。用JSON行输出后,service字段可以按服务聚合,from和to可以筛选出所有打开事件或恢复事件,failure_count则能看出熔断触发前累计了多少次失败。occurred_at使用UTC毫秒级时间戳,跨机房对比时不会因时区混淆。实际线上环境还可以在负载均衡层或网关层补充request_id、trace_id,关联具体请求链路。
日志级别也需要区分。closed到open通常是故障信号,建议用warn或error级别;open到half_open可以视为恢复尝试,用info;half_open到closed表示完全恢复,用info;而half_open重新回到open意味着探测失败,继续熔断,也建议用warn。示例中统一用了info,生产环境可以按这个规则在log_transition里根据状态对选择级别。错误原因字段不要记录完整的响应体,只记录异常类名、错误码或精简后的消息,避免敏感数据和超大日志。
上下文信息可以通过Thread.current传递。例如在Rack请求入口处设置Thread.current[:request_id],在log_transition中写入request_id: Thread.current[:request_id]。这样熔断日志和访问日志、业务日志就能串起来。更进阶的做法是把日志写入独立的circuit_breaker.log文件,按天滚动,避免和业务日志混在一起。
四、半开探测的日志要单独标记
半开状态最容易产生误导。一个服务进入半开后,外层可能只放行一个请求作为探测。如果这个探测请求恰好因为业务参数问题失败,熔断器会重新打开,但这次重新打开并不代表服务整体不可用。因此,半开探测的开始、成功和失败都应该留下独立日志,而不是只记录状态转换。
可以在mark_probe_started里输出一条debug级别日志,记录probe_state: started和当前时间戳。探测请求完成后,如果成功自然触发half_open_probe_succeeded转换;如果失败则触发half_open_probe_failed转换,并且failure方法里可以额外带上probe: true这样的标记,告诉查看日志的人这是探测请求导致的,不是正常流量大量失败。这样能有效区分“服务确实没恢复”和“探测请求本身有误”。
半开阶段还有一个细节:如果探测请求长时间没有返回,half_open_in_flight会一直为true,后续请求都会被拒绝。这种情况下建议设置探测超时,并在超时回调中把@half_open_in_flight重置为false,同时记录一条probe_timeout日志。否则状态机可能卡在半开,表面上没有状态转换,但流量已经被半开限制拖垮。
五、接入现有服务依赖治理层
这套日志方案并不要求替换已经使用的熔断器gem。很多项目里已经有semian、circuitbox等成熟实现,但它们的状态转换日志可能不够详细,或者格式不符合内部日志规范。此时可以在这类库外层包一层代理,在调用run、execute等方法前后捕获异常和结果,再根据库暴露的状态变化信息补写日志。如果库没有提供状态回调,也可以通过猴补丁或继承方式拿到内部状态。
自研轻量熔断器时,上面给出的CircuitBreaker类可以直接复制进项目,按照团队规范调整字段和级别。建议一开始就明确日志的输出目的地和保留策略,比如统一输出到stdout交给日志采集器,或者写入指定文件。对于核心依赖,状态转换日志至少保留十四天以上,方便跨天对比流量波动和熔断触发频率。条件允许的话,还可以基于这些日志做简单的告警:连续两次closed到open转换间隔小于五分钟,说明依赖极不稳定,需要立即通知负责人。