bigywater.tech
Python · 108

熔断器:Python 服务如何避免级联故障

下游服务变慢或挂掉时,上游继续硬调,只会让自己的线程一个个被耗尽,最后连本来健康的功能也一起挂——这就是级联故障。熔断器的思路是:失败太多就暂时「断开」,直接拒绝请求,等一会儿再放一个探针看看好了没有。这篇自己实现一个,让它在真实线程池和真实计时下工作,不光看好处,也把它的代价量出来。

150 → 86
50 请求/秒下,故障期间真正打到下游的调用次数(无熔断器 → 有熔断器)
155 ms → 0.1 ms
故障期间一个失败请求的中位延迟:等下游超时 vs 直接快速失败
+0.54 s
下游 1.5 s 已恢复,熔断器到 2.04 s 才恢复放行——熔断的代价

做的事:一个三状态的熔断器

熔断器(核心) class Breaker: def call(self, f): with self.lock: if self.state == "open": if now - self.opened_at >= self.reset_timeout and not self.probing: self._set("half_open"); self.probing = True # 冷却到了: 放一个探针 else: raise Open() # 否则直接拒绝 elif self.state == "half_open" and self.probing: raise Open() try: r = f() except Exception: # 失败: half_open → open;closed 累计到阈值 → open ... # 成功: half_open → closed;closed 里失败计数清零

参数:连续失败 5 次就打开、冷却 0.5 s。下游模型:0.5–1.5 s 是故障期,每次调用要等 0.15 s 才抛出超时错误;其余时间 5 ms 内成功。请求由 8 个工作线程的线程池处理。

验证一:状态转换

实测 · 本机执行输出
  状态转换日志(相对开始的秒数):
     0.743 s  closed    → open
     1.381 s  open      → half_open
     1.531 s  half_open → open
     2.041 s  open      → half_open
     2.047 s  half_open → closed

完整的循环都出现了:closed → open(故障开始约 0.2 s 后累计 5 次失败)→ half_open(冷却期满,放行探针)→ 因为下游还没恢复,探针失败又回到 open → 再冷却 → half_open → 探针成功 → closed。注意时间点:下游 1.5 s 就恢复了,但第一次探针发生在 1.38 s(还在故障期,失败),第二次要到 2.04 s——熔断器不会立刻发现恢复。

验证二:有无熔断器的对照(50 请求/秒)

实测 · 本机执行输出
  无熔断器: 真正打到下游 150 次 | 成功 100 | 下游报错  50 | 快速失败   0 | 故障期内请求的延迟 中位   155.0 ms 最大   155.1 ms | 故障恢复后 1 秒内请求的延迟 中位     6.3 ms 最大     6.5 ms | 全部处理完 2.99 s
  有熔断器: 真正打到下游  86 次 | 成功  73 | 下游报错  13 | 快速失败  64 | 故障期内请求的延迟 中位     0.1 ms 最大   203.8 ms | 故障恢复后 1 秒内请求的延迟 中位     0.2 ms 最大     6.3 ms | 全部处理完 2.99 s

故障期间,无熔断器时每个请求都老老实实等 0.15 s 下游超时才失败(中位 155 ms),150 次调用全部打到下游;有熔断器时 64 个请求被快速失败(中位仅 0.1 ms),下游只收到 86 次。但要看代价:在这个速率下线程池(8 个)并没有被耗尽——50 请求/秒 × 0.15 s ≈ 7.5 个并发,刚好够——所以无熔断器组并没有出现级联,只是每个失败请求慢了一些。熔断器反而在下游已经恢复后的那 0.5 秒里,让本可以成功的 25 个请求快速失败(见下面的窗口表)。

验证三:窗口内的结果

每 0.5 秒
实测 · 本机执行输出
  0.0–0.5 s   有熔断器 {'ok': 25}   无熔断器 {'ok': 25}
  0.5–1.0 s   有熔断器 {'fail': 12, 'fast_fail': 13}   无熔断器 {'fail': 25}
  1.0–1.5 s   有熔断器 {'fast_fail': 24, 'fail': 1}   无熔断器 {'fail': 25}
  1.5–2.0 s   有熔断器 {'fast_fail': 25}   无熔断器 {'ok': 25}
  2.0–2.5 s   有熔断器 {'fast_fail': 2, 'ok': 23}   无熔断器 {'ok': 25}
  2.5–3.0 s   有熔断器 {'ok': 25}   无熔断器 {'ok': 25}

「有熔断器」那一列在 1.5–2.0 s 全部是 fast_fail,而「无熔断器」已经在 1.5 s 恢复了全部成功。这是熔断器固有的取舍:用一段「本可以成功的失败」换取故障期间对下游和自己资源的保护。冷却时间(reset_timeout)越短,这个损失越小,探针失败的次数越多。

验证四:请求速率超过线程池能力时——级联

实测 · 本机执行输出
  无熔断器: 打到下游 300 次 | 成功 244 | 下游报错  56 | 快速失败   0 | 故障期请求延迟 中位   373.2 ms 最大   601.5 ms | 恢复后 1 秒内延迟 中位     6.3 ms 最大   149.1 ms | 全部处理完 3.00 s
  有熔断器: 打到下游 163 次 | 成功 150 | 下游报错  13 | 快速失败 137 | 故障期请求延迟 中位     0.1 ms 最大   228.7 ms | 恢复后 1 秒内延迟 中位     2.7 ms 最大     6.4 ms | 全部处理完 3.00 s
  故障结束(1.5 s)之后, 「无熔断器」那一组里还在排队的请求从什么时候才被处理完:
    无熔断器: 故障期间提交的最后一个请求 1.65 s 才处理完; 其中 1.5 s 之后才处理完的请求 202 个
    有熔断器: 故障期间提交的最后一个请求 1.49 s 才处理完; 其中 1.5 s 之后才处理完的请求 150 个

把速率提高到 100 请求/秒(故障期每次调用占用线程 0.15 s,需要约 15 个并发,而线程池只有 8 个)。无熔断器:请求在上游排队,故障期请求的延迟中位 373 ms、最大 601 ms;故障结束之后队列里积压的请求还要被逐个处理,恢复后的第一秒里最大延迟仍有 149 ms。有熔断器:失败请求直接被快速拒绝,中位延迟 0.1 ms,打到下游的调用只有 163 次(无熔断器 300 次),恢复后最大延迟 6.4 ms——上游的资源没有被故障的下游拖住。熔断器的价值在这里才真正体现:保护调用方自己。

分步演示:熔断器的一生,和它的代价

状态与时间点取自上面的真实日志
熔断器
当前状态 / 时间窗

    本文用到的工具与边界

    • 本机 macOS 27 / Python 3.14.7,自己实现的熔断器(threading + time),线程池 concurrent.futures.ThreadPoolExecutor(8 个线程);时间取自 time.perf_counter,线程调度有毫秒级抖动,数字每次运行会略有不同
    • 没有验证:按失败率(而非连续次数)判断的熔断器、探针并发数大于 1、与重试组合使用(见第 107 篇)、真实网络超时与连接池的行为、成熟库(如 pybreaker)的实现差异
    京ICP备2026067502号-1京公网安备11010502063487号