事故:一次流量洪峰,把下游接口拖死了三小时
去年做双十一前的一次预热活动,我们发了一条站内推送,五分钟内涌进来大概平时的二十倍流量。首页本身扛住了——Nginx 和 FPM 都很稳,CPU 不到四成。但页面上有个「猜你喜欢」模块,是调用的一个内部推荐服务,那个服务平时响应 30 毫秒,那会儿变成了 8 秒超时。于是每一个 PHP 请求都卡在那个 8 秒上,FPM 进程池在九十秒内就被占满,整站从「有点慢」变成「全部 504」。事后复盘得出的结论很朴素,但当时没一个人说出来:下游已经挂了,我们还在不停地给它发请求。
这就是熔断要解决的问题。它不是让你变快,而是让一个已经坏掉的东西别再继续拖累其他还活着的东西。这篇文章从这次事故开始,把熔断、降级、超时、重试这几个经常被混为一谈的概念拆开,给出可直接落地的阈值算法和一段 PHP 实现,最后讲清楚在什么规模下它其实不值得做。
四个概念必须先分清
我见过很多团队把「加个 try-catch 返回默认值」叫熔断,把「超时设短一点」叫降级。名字混着用,代码就写不对。下面这个表是我给团队定的口径:
| 机制 | 回答的问题 | 触发条件 | 恢复方式 |
|---|---|---|---|
| 超时 | 这一个请求最多等多久 | 单次请求 | 无状态,每次都重新判断 |
| 重试 | 失败了要不要再试一次 | 单次失败,且失败可重试 | 立即重试或退避重试 |
| 熔断 | 下游已经大面积失败了,还要不要发请求 | 一段时间窗口内的失败比例 | 半开状态试探,成功才恢复 |
| 降级 | 拿不到真实结果时返回什么 | 熔断打开、超时、异常 | 与熔断状态联动 |
关键区别在于判断粒度:超时和重试是「单个请求」的判断,熔断是「一段时间的统计」的判断。这就是为什么任何一个单独的超时设置都替代不了熔断——你不可能通过让每次超时都变短来解决「下游整体挂了还在被狂打」的问题。
熔断器的三个状态与一个易错点
标准的熔断器有三个状态:
- 关闭(Closed):正常放行请求,同时统计窗口内的成功与失败次数。
- 打开(Open):直接拒绝,不发请求,立刻返回降级结果。打开状态会维持一个冷却时间。
- 半开(Half-Open):冷却时间到了之后,只放极少量请求过去试探。这些请求全部成功才回到关闭,只要有一个失败立刻回到打开,并且重新开始冷却计时。
最容易错的地方是冷却时间与半开状态的探测数量。我见过配置成人一放到半开就全量放行,等于没有熔断——下游刚缓过来一口气又被瞬间打满,然后再挂,形成周期性雪崩。正确做法是半开状态只放 1~3 个请求,并且这几个请求要串行、带短超时。
第二个易错点是统计窗口用固定窗口还是滑动窗口。固定窗口在窗口边界会出现「前 59 秒失败率 0%、最后 1 秒全部失败、窗口一翻失败率立刻归零」的跳变,熔断永远触发不了。滑动窗口(比如 10 个 1 秒的桶)更准,代价是内存与实现复杂度。个人站规模,用 10 个 1 秒桶已经足够。
// 极简滑动窗口:10 个 1 秒桶,记录成功/失败
class SlidingWindow {
private array $buckets = []; // [second => ['ok' => n, 'fail' => n]]
private int $span;
public function __construct(int $spanSeconds = 10) {
$this->span = $spanSeconds;
}
public function record(bool $success): void {
$sec = time();
if (!isset($this->buckets[$sec])) {
$this->buckets[$sec] = ['ok' => 0, 'fail' => 0];
}
$success ? $this->buckets[$sec]['ok']++ : $this->buckets[$sec]['fail']++;
$cutoff = $sec - $this->span;
foreach ($this->buckets as $k => $_) {
if ($k < $cutoff) {
unset($this->buckets[$k]);
}
}
}
public function stats(): array {
$ok = 0; $fail = 0;
foreach ($this->buckets as $b) {
$ok += $b['ok'];
$fail += $b['fail'];
}
return ['ok' => $ok, 'fail' => $fail, 'total' => $ok + $fail];
}
}
阈值怎么定:三个数字与一条硬规则
熔断参数我一个都不主张拍脑袋,它需要三个数字:
- 失败率阈值:一般 50%。太低会把偶发抖动当故障(下游偶尔慢一下很常见),太高则熔断来不及。比它更重要的是后面那个「最小请求数」。
- 最小请求数:窗口内请求少于这个数时,无论失败率多高都不熔断。因为 3 个请求里错 2 个是 66% 失败率,但那只是运气问题。个人站建议 20。
- 冷却时间:从打开到进入半开的时间。这个值要参考下游的恢复特性:数据库过载通常几秒就缓过来,整机房故障可能十分钟。我一般设 30 秒,因为它足够让一个被打满的连接池恢复,又不至于太久影响业务。
还有一条硬规则比三个参数都重要:熔断只针对「可跳过的非核心依赖」。登录校验、订单写入、支付回调这些依赖,熔断了也没有合理的降级值(你不可能给用户返回「登录成功,用户 ID 未知」),这类依赖要做的不是熔断而是限流和排队准备。熔断的适用对象是推荐、排行榜、相关文章、在线人数、评论数这类「没有它也能看」的东西。
降级返回什么:四种策略与它们各自的坑
熔断打开之后返回什么,比什么时候熔断更难设计。我实践下来有四种策略:
| 策略 | 做法 | 适用 | 坑 |
|---|---|---|---|
| 返回空集合 | 返回空数组,页面显示「暂无推荐」 | 列表类模块 | 「暂无数据」与「服务挂了」视觉上无法区分,问题会长期潜伏 |
| 返回静态默认值 | 返回预置的一批兜底内容 | 排行榜、热门标签 | 默认值长期不变,用户会觉得站点「死气沉沉」 |
| 返回过期缓存 | 返回上一次成功结果,标注可能不是最新 | 对时效要求不高的展示 | 必须给过期数据设上限,否则会返回几天前的数据 |
| 隐藏模块 | 整块不渲染 | 装饰性模块 | 布局会跳动,可能造成 CLS 恶化 |
我最终采用的是过期缓存优先、静态默认值兜底:模块平时把成功结果写一份到 APCu 或 Redis,带 24 小时的软过期;熔断打开时读这份缓存,如果连缓存都没有,就用一个配置里的静态数组。这样用户几乎感受不到异常,而运维侧能看到熔断计数器——用户无感不等于我们可以无感,监控必须能看到熔断被打开过。
另一个细节:降级结果要不要缓存。答案是不要给降级的空结果设长 TTL。否则下游恢复了,缓存里还是空的,你会误判「熔断没恢复」。降级结果最多缓存 10 秒,或者干脆不缓存。
PHP 落地:一个够用的熔断器
下面这段代码是站点上实际用的版本,状态存在 APCu 里,多台机器各自独立计数(个人站不需要分布式一致性)。核心是「先判断状态再发请求」,以及半开状态的并发保护。
class CircuitBreaker {
private string $name;
private int $failRate; // 百分比,如 50
private int $minRequests; // 如 20
private int $cooldown; // 秒,如 30
public function __construct(string $name, int $failRate = 50,
int $minRequests = 20, int $cooldown = 30) {
$this->name = $name;
$this->failRate = $failRate;
$this->minRequests = $minRequests;
$this->cooldown = $cooldown;
}
private function key(string $suffix): string {
return 'cb:' . $this->name . ':' . $suffix;
}
public function allow(): bool {
$state = apcu_fetch($this->key('state'), $ok);
if ($state !== 'open') {
return true;
}
$openedAt = (int) apcu_fetch($this->key('opened_at'));
if (time() - $openedAt < $this->cooldown) {
return false;
}
// 半开:只有抢到探测权的那一个请求可以出去
if (apcu_add($this->key('probe'), 1, 5)) {
apcu_store($this->key('state'), 'half');
return true;
}
return false;
}
public function record(bool $success): void {
$w = apcu_fetch($this->key('win'));
if (!is_array($w) || !isset($w['sec'])) {
$w = ['sec' => time(), 'ok' => 0, 'fail' => 0];
}
// 简化:一个窗口内累计,窗口到期重置
if (time() - $w['sec'] >= 10) {
$w = ['sec' => time(), 'ok' => 0, 'fail' => 0];
}
$success ? $w['ok']++ : $w['fail']++;
apcu_store($this->key('win'), $w, 30);
$state = apcu_fetch($this->key('state'));
if ($state === 'half') {
// 半开状态下任何一次失败立刻打回打开
apcu_store($this->key('state'), $success ? 'closed' : 'open', 3600);
if (!$success) {
apcu_store($this->key('opened_at'), time(), 3600);
}
apcu_delete($this->key('probe'));
return;
}
$total = $w['ok'] + $w['fail'];
if ($total >= $this->minRequests
&& $w['fail'] * 100 / $total >= $this->failRate) {
apcu_store($this->key('state'), 'open', 3600);
apcu_store($this->key('opened_at'), time(), 3600);
}
}
}
调用方式:
$cb = new CircuitBreaker('recommend');
if (!$cb->allow()) {
$data = $fallbackFromCache(); // 过期缓存
return $data ?: $staticDefault; // 再兜一层静态值
}
try {
$data = $client->get('/recommend', ['timeout' => 0.5]);
$cb->record(true);
} catch (Exception $e) {
$cb->record(false);
$data = $fallbackFromCache() ?: $staticDefault;
}
这段代码有两点需要说明。第一,apcu_add($key, 1, 5) 抢探测权是关键,它保证半开状态下只放一个请求出去,其他请求继续被拒。第二,各机器的计数不共享,在 4 台机器的集群里实际阈值会变成 4 倍——这在个人站完全可接受,但如果你在多机上跑关键业务,需要把状态放到 Redis,用 INCR 加 EXPIRE 做窗口。
超时、重试与熔断的配合关系
熔断不是孤立的,它需要另外两件事配合才有效果:
- 超时必须短于熔断窗口。如果下游超时设 30 秒、熔断窗口 10 秒,那么在窗口内一个请求都还没结速完,熔断根本没机会统计到失败。经验值:超时 1 秒以内,熔断窗口 10 秒。
- 重试必须谨慎。重试会把下游的压力放大 2~3 倍,在熔断场景里是反作用的。原则是:只对幂等请求(GET)重试一次,且只在连接失败时重试,遇到超时不重试(超时说明对方已经慢到不行了,再打一次只会更慢)。
# Nginx 侧:只对 GET/HEAD 做上游重试,POST 不重试
map $request_method $retry_upstream {
default 0;
GET 1;
HEAD 1;
}
proxy_next_upstream error timeout $retry_upstream;
顺带说一句,proxy_next_upstream 加了 timeout 之后,一个慢请求会被重试,导致「单次请求耗时翻倍」。这也是为什么很多人反映「加了重试之后更慢了」——重试不是免费的。
什么时候不值得做熔断
这是最后一节,也是我最想强调的。熔断是一套有状态、需要调参、需要监控的机制,它不便宜。下面这几种情况,我建议直接跳过:
- 单机个人站,下游就是本机 MySQL。数据库挂了,整站也就没意义了,熔断在这里只是把一个错误换成另一个错误。真正该做的是连接池、慢查询治理和读写分离。
- 流量极小(日 PV 一万以内)。窗口内凑不够最小请求数,熔断永远不会触发,只增加代码复杂度和调试成本。
- 没有降级业务设计。如果拿不到数据就只能报错,那熔断打开之后还是报错,只是更快地报错。这种情况下,优化超时和排队更实际。
反过来说,如果站点有多个外部依赖(地图 API、支付、短信、第三方登录、推荐服务),并且其中任何一个挂了都会拖垮整站,那熔断是性价比最高的一项加固。我把它归类为「一次投入,长期有效」的那类工作:写完一次,后面几年都不用再动,但省下的可能是几十次线上故障。
最后:留给自己的三条检查单
- 每个外部依赖都要有超时,且超时值小于熔断窗口。没设超时的 HTTP 客户端默认可能是无限等待——这是最常见的事故源头。
- 每个熔断器都要有监控指标,至少记录「当前状态」和「过去 24 小时被打开过几次」。状态是 open 却没人知道,等于没做。
- 每个降级分支都要被真实触发过一次。用改 hosts 或加开关的方式演练一次,否则你永远不知道降级代码里的那个变量名是不是写错了。