KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
03 · 流量防护:超时、限流、熔断与隔离 — keel 龙骨
这一章回答:当一个依赖变慢或挂掉时,怎么让你的服务不跟着死。
这一章回答:当一个依赖变慢或挂掉时,怎么让你的服务不跟着死。
单个组件故障演变成整体雪崩的路径通常是这样的:慢 → 连接被占满 → 排队 → 超时扩散 → 上游重试再放大 → 全链路不可用。 所以防护的核心是三件事:不要让调用无限等待(超时)、不要让请求无限涌入(限流)、不要在依赖已死时继续调用(熔断)。
一、超时:最基础也最常被忽略
没有超时的外部调用等于把系统的命运交给别人。每一处网络调用都必须有超时,包括:HTTP 客户端、数据库连接获取、Redis 命令、MQ 投递。
在协程环境里,超时还有一个特殊作用——给无法改造的同步或慢调用兜底:
result = await asyncio.wait_for(slow_operation(), timeout=3.0)
配套原则:
- 超时时间应逐层递减(网关 > 服务 > 依赖),否则上游还在等,下游已经放弃,反而产生更多垃圾请求;
- 超时必须区分可重试与不可重试(写操作的重试要幂等,见第 06 章);
- 超时之后要有明确的降级返回值或错误码,不能是 500 一团模糊。
二、限流:控制进入系统的速率
常见两类算法:
| 算法 | 特点 | 适用 |
|---|---|---|
| 令牌桶 | 恒定速率生成令牌,允许一定突发 | 绝大多数业务限流(推荐默认) |
| 漏桶 | 恒定速率处理,超出即丢弃/排队 | 需要严格平滑输出的场景 |
实现要点:
- 计数与令牌生成必须在 Redis 里原子执行(Lua 脚本),否则分布式下形同虚设;
- 限流维度可按用户、接口、租户分别设置;
- 返回值用 429 并带
Retry-After,让客户端知道何时重试; - 阈值不是拍脑袋定的——用压测测出系统容量,取其 70%~80% 作为限流阈值。
三、熔断:依赖已死就别再打了
熔断器是一个三态机器:
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 原子脚本 |
高并发下是否突破阈值 |
自测题
- 为什么超时配置要逐层递减?反过来配置会发生什么?
- 熔断的三个状态之间转换的条件各是什么?半开状态存在的意义是什么?
- 为什么 asyncio 环境里熔断器的锁必须是
asyncio.Lock? - 限流阈值该怎么确定?拍一个数字有什么风险?
- 熔断与降级的区别是什么?为什么说"熔断了但没有降级"等于没有熔断?