熔断器决定的是一个新的调用,是否还应该到达看起来不健康的依赖。它可以立即拒绝,避免继续为很可能失败的请求投入资源,但不会修复依赖、取消已经运行的调用,也不能证明所有被拒工作本来都会失败。它是否有用,取决于真正实现的失败信号、作用域和恢复政策,而不只是有没有三个状态名称。

明确政策,而不只是画出状态

关闭表示允许调用通过,不代表每次调用都会成功;断开表示按照熔断政策拒绝新调用;半开则允许有限的试探工作,先取得一些恢复证据,再决定是否重新放行普通流量。不能把状态名字直接读成依赖的客观健康证明。

检测规则也不一定是滑动窗口内的失败百分比。Fowler 的示例统计失败,并在成功后重置;其他实现可能使用时间窗口、失败比例、慢调用阈值或最小样本数。应该明确当前使用的规则,而不是借用一张状态图,就默认阈值和统计方法已经确定。

下面的实验按完成顺序统计连续两次已分类失败,使用五秒冷却期,并且只允许一个半开探测。一次成功会把连续失败数清零。这些数字方便验证状态转移,不是生产配置建议;实际门槛需要结合故障形态与业务容忍度决定。

分类失败,并限定影响范围

调用方参数错误,不一定表示依赖已经宕机。需要确定哪些异常、状态码和慢结果应该影响熔断判断。本地直接拒绝的请求,也不同于已经到达依赖后失败的请求;如果把二者混在同一个失败统计里,熔断器可能不断用自己的拒绝强化拒绝状态。

状态应覆盖你真正想保护的故障范围。某个分片或操作不可用,不一定意味着其他独立提供方也应该被阻止。反过来,如果有成千上万个进程各自维护熔断器,每个都允许一个探测,总探测量仍可能很大。“只放一个半开请求”可能只是每实例一个,不是全局一个。

超时仍然不可缺少。一个永远不结束的调用,会占住连接或工作线程,却可能始终不产生足以触发熔断的完成结果。准入决定不能替代截止期限、并发上限和资源清理;等待结果所消耗的资源,也应进入实际验收范围。

恢复是一场受控试探

冷却时间结束后,应原子地占用探测名额。如果多个线程都先看见“现在可以试一下”,然后各自放行,所谓单探测就变成了一批突发请求。实验使用锁保护很短的状态修改,不会在依赖操作实际运行期间一直持有这把锁。

已经在途的旧调用,其结果如何处理也需要明确。这里进入新的断开阶段时会增加本地代次,旧许可对应的结果不能关闭这个较新的断开状态。代次只是模型内部的记账,不是分布式 fencing token,也没有能力撤销已经发生的远程工作。

探测失败会开始另一轮冷却,探测成功则关闭这个实验里的熔断器。但是,一次成功不证明依赖已经能够承接所有返回流量。如果需要逐步恢复,应该验证真实的流量准入政策,而不是认为只要存在“半开”状态,就必然实现了平滑放量。

坏掉的样子

阈值过于激进,会拒绝原本可以成功的请求;冷却过长,又会在依赖恢复后继续阻止调用。范围过大容易掩盖局部健康,而重试循环如果不停捕获断开异常,即使没有继续请求下游,也仍可能浪费本地资源,使故障路径消耗超出预期。

应分别观察转发调用、已分类结果、本地拒绝、状态变化和探测结果。测试持续失败、偶尔成功、并发争抢探测以及迟到的完成消息。一个计数器单测通过,并不证明真实依赖会安全恢复;需要沿着请求、资源占用和实际恢复流量继续检查。

快速验证

保存为 breaker_check.py,用 Python 3.10 以上版本执行 python3 breaker_check.py。实验已在 Python 3.14.4 上运行,只使用标准库。代码模拟结果,并注入表示单调时间的数值,不真实等待,也不访问服务;半开名额则由八个真实线程竞争,检查准入决定是否一起完成。

from concurrent.futures import ThreadPoolExecutor
from threading import Barrier, Lock


class OpenCircuit(Exception):
    pass


class Breaker:
    # Explicit fixture policy: two consecutive classified failures, five-second cooldown.
    def __init__(self):
        self.state, self.failures, self.until = "closed", 0, 0
        self.epoch, self.probe_busy = 0, False
        self.lock = Lock()

    def acquire(self, now):
        with self.lock:
            if self.state == "open":
                if now < self.until:
                    raise OpenCircuit
                self.state = "half-open"
            if self.state == "half-open":
                if self.probe_busy:
                    raise OpenCircuit
                self.probe_busy = True
                return self.epoch, True
            return self.epoch, False

    def complete(self, permit, succeeded, now):
        epoch, probe = permit
        with self.lock:
            if epoch != self.epoch:
                return  # This outcome belongs to an earlier breaker generation.
            if probe:
                self.probe_busy = False
                self.epoch += 1
                self.failures = 0
                self.state = "closed" if succeeded else "open"
                if not succeeded:
                    self.until = now + 5
            elif self.state == "closed":
                self.failures = 0 if succeeded else self.failures + 1
                if self.failures >= 2:
                    self.state, self.until = "open", now + 5
                    self.epoch += 1


breaker = Breaker()
other_dependency = Breaker()
forwarded = []


def invoke(succeeded, now):
    permit = breaker.acquire(now)
    forwarded.append(now)  # A modeled dependency invocation, not a network call.
    breaker.complete(permit, succeeded, now)


invoke(False, 0)
assert breaker.failures == 1
invoke(True, 1)
assert breaker.failures == 0
late = breaker.acquire(1)
invoke(False, 2)
invoke(False, 3)
assert breaker.state == "open" and breaker.until == 8
other_permit = other_dependency.acquire(4)
other_dependency.complete(other_permit, True, 4)
assert other_dependency.state == "closed"
before = len(forwarded)
try:
    invoke(True, 7.99)
except OpenCircuit:
    pass
else:
    raise AssertionError("open circuit must reject before dispatch")
assert len(forwarded) == before
breaker.complete(late, True, 7.99)
assert breaker.state == "open"
print("success resets consecutive failures; two later failures open until time 8")
print("open calls are not dispatched; an older in-flight success does not close a newer open generation")

gate = Barrier(8)


def probe_attempt(_):
    gate.wait(timeout=5)
    try:
        return breaker.acquire(8)
    except OpenCircuit:
        return None


with ThreadPoolExecutor(max_workers=8) as pool:
    permits = list(pool.map(probe_attempt, range(8)))
admitted = [permit for permit in permits if permit is not None]
assert len(admitted) == 1 and admitted[0][1] is True
assert breaker.state == "half-open" and breaker.probe_busy
breaker.complete(admitted[0], False, 8)
assert breaker.state == "open" and breaker.until == 13
print("eight simultaneous half-open candidates receive one probe permit and seven refusals")
print("failed probe opens a new cooldown ending at time 13")

try:
    breaker.acquire(12.99)
except OpenCircuit:
    pass
else:
    raise AssertionError("second cooldown must still apply")
probe = breaker.acquire(13)
breaker.complete(probe, True, 13)
assert breaker.state == "closed" and not breaker.probe_busy
invoke(True, 14)
assert other_dependency.state == "closed" and other_dependency.failures == 0
print("a successful later probe closes this breaker; another dependency has separate state")

开始时的一次成功会清零失败数;后面两次失败让熔断器断开到时间八。在这之前发起调用,不会增加模拟下游调用记录。先前获准、后来才完成的成功结果,也不能关闭新一代的断开状态;另一依赖早已存在的熔断器仍然可以使用,没有被一并影响。

到时间八,八个同时到来的候选请求中只有一个获得许可,其余七个被拒绝。这个探测失败后,又开始一轮到时间十三结束的冷却。之后的探测成功,会让当前熔断器关闭,并恢复普通调用准入。断言检查的是这些明确的状态和许可变化。

模型要求每个获准调用都恰好报告一次已经分类的完成结果。真实传输超时、取消、自动错误分类、跨进程探测协调、库集成和恢复吞吐量没有在本例中测试。适合时应复用成熟实现,并依据实际工作负载验证它的精确契约,而不是将这个小模型当作完整生产熔断库。

延伸阅读

来源