KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

03 · 流量防护:超时、限流、熔断与隔离 — keel 龙骨

这一章回答:当一个依赖变慢或挂掉时,怎么让你的服务不跟着死。

这一章回答:当一个依赖变慢或挂掉时,怎么让你的服务不跟着死。

单个组件故障演变成整体雪崩的路径通常是这样的:慢 → 连接被占满 → 排队 → 超时扩散 → 上游重试再放大 → 全链路不可用。 所以防护的核心是三件事:不要让调用无限等待(超时)、不要让请求无限涌入(限流)、不要在依赖已死时继续调用(熔断)。

一、超时:最基础也最常被忽略

没有超时的外部调用等于把系统的命运交给别人。每一处网络调用都必须有超时,包括:HTTP 客户端、数据库连接获取、Redis 命令、MQ 投递。

在协程环境里,超时还有一个特殊作用——给无法改造的同步或慢调用兜底:

result = await asyncio.wait_for(slow_operation(), timeout=3.0)

配套原则:

二、限流:控制进入系统的速率

常见两类算法:

算法 特点 适用
令牌桶 恒定速率生成令牌,允许一定突发 绝大多数业务限流(推荐默认)
漏桶 恒定速率处理,超出即丢弃/排队 需要严格平滑输出的场景

实现要点:

三、熔断:依赖已死就别再打了

熔断器是一个三态机器:

CLOSED(正常)──失败数达阈值──▶ OPEN(快速失败)
     ▲                              │
     │                        冷却时间到
     │                              ▼
     └──试探成功── HALF_OPEN(放行少量请求)

一个用于保护数据库/慢 SQL 的最小实现(协程安全):

class CircuitBreaker:
    def __init__(self, failure_threshold=5, reset_timeout=30):
        self.failure_threshold = failure_threshold   # 连续失败多少次熔断
        self.reset_timeout = reset_timeout           # 熔断后多久转入半开
        self.failure_count = 0
        self.state = "CLOSED"
        self.opened_at = 0
        self._lock = asyncio.Lock()                  # 协程场景必须用 asyncio.Lock

    async def call(self, func, *args, **kwargs):
        async with self._lock:
            if self.state == "OPEN":
                if time.time() - self.opened_at < self.reset_timeout:
                    raise ServiceUnavailable("circuit open")   # 快速失败,不去打下游
                self.state = "HALF_OPEN"
        try:
            result = await asyncio.wait_for(func(*args, **kwargs), timeout=3.0)
        except Exception:
            async with self._lock:
                self.failure_count += 1
                if self.failure_count >= self.failure_threshold:
                    self.state = "OPEN"
                    self.opened_at = time.time()
            raise
        async with self._lock:
            self.failure_count = 0
            self.state = "CLOSED"
        return result

参数怎么定(经验起点,之后按压测调整):

参数 建议值 说明
failure_threshold 5 ~ 10 太小容易误熔断,太大则保护来得太晚
reset_timeout 30 ~ 60 秒 要大于依赖的典型恢复时间
SQL / 依赖超时 3 ~ 5 秒 应略大于该操作的 P99 正常耗时

四、隔离:故障不该跨租户传染

五、四件套的协作关系

请求进来 → 限流(超额直接拒绝)
        → 超时包裹(到点必返)
        → 熔断(依赖已死则快速失败)
        → 降级(返回兜底数据 / 异步补偿)

注意顺序:先限流再熔断。熔断是保护下游,限流是保护自己,两者不能互相替代。


动手:可观察结果

产出 判断标准
一个熔断器实现 三态齐全,协程安全(用 asyncio.Lock),失败计数有并发保护
一次故障演练 让依赖返回错误,观察:第 N 次失败后进入 OPEN,之后请求在 1ms 内返回而非等待超时
恢复演示 依赖恢复后,冷却时间到会自动半开试探并回到 CLOSED
对比压测 无防护:错误率飙升、连接池耗尽;有防护:错误率可控、延迟平稳

完成标志:依赖完全不可用 30 秒,你的服务仍能对已托底的接口返回确定结果,且恢复后能自动愈合。

故障注入

注入方式 观察
移除所有超时配置 依赖变慢时上层排队情况,线程/连接是否被占满
把熔断阈值设为 1 偶发抖动是否被过度放大为长时间熔断
熔断后冷却时间设为 1 秒 是否反复半开试探,给刚恢复的下游造成二次冲击
用 threading.Lock 替代 asyncio.Lock 实现熔断器 协程场景下是否反而堵死事件循环
限流计数用普通 GET+SET 而非 Lua 原子脚本 高并发下是否突破阈值

自测题

  1. 为什么超时配置要逐层递减?反过来配置会发生什么?
  2. 熔断的三个状态之间转换的条件各是什么?半开状态存在的意义是什么?
  3. 为什么 asyncio 环境里熔断器的锁必须是 asyncio.Lock?
  4. 限流阈值该怎么确定?拍一个数字有什么风险?
  5. 熔断与降级的区别是什么?为什么说"熔断了但没有降级"等于没有熔断?

进入 keel 阅读