熔断与降级:下游挂了的自保设计

事故:一次流量洪峰,把下游接口拖死了三小时

去年做双十一前的一次预热活动,我们发了一条站内推送,五分钟内涌进来大概平时的二十倍流量。首页本身扛住了——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];
    }
}

阈值怎么定:三个数字与一条硬规则

熔断参数我一个都不主张拍脑袋,它需要三个数字:

  1. 失败率阈值:一般 50%。太低会把偶发抖动当故障(下游偶尔慢一下很常见),太高则熔断来不及。比它更重要的是后面那个「最小请求数」。
  2. 最小请求数:窗口内请求少于这个数时,无论失败率多高都不熔断。因为 3 个请求里错 2 个是 66% 失败率,但那只是运气问题。个人站建议 20。
  3. 冷却时间:从打开到进入半开的时间。这个值要参考下游的恢复特性:数据库过载通常几秒就缓过来,整机房故障可能十分钟。我一般设 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、支付、短信、第三方登录、推荐服务),并且其中任何一个挂了都会拖垮整站,那熔断是性价比最高的一项加固。我把它归类为「一次投入,长期有效」的那类工作:写完一次,后面几年都不用再动,但省下的可能是几十次线上故障。

最后:留给自己的三条检查单

  1. 每个外部依赖都要有超时,且超时值小于熔断窗口。没设超时的 HTTP 客户端默认可能是无限等待——这是最常见的事故源头。
  2. 每个熔断器都要有监控指标,至少记录「当前状态」和「过去 24 小时被打开过几次」。状态是 open 却没人知道,等于没做。
  3. 每个降级分支都要被真实触发过一次。用改 hosts 或加开关的方式演练一次,否则你永远不知道降级代码里的那个变量名是不是写错了。
Last modification:October 26th, 2026 at 07:25 pm

Leave a Comment