采集熔断器怎么写:上游抖动下的断路与自愈实践

上周凌晨三点,我被报警短信叫醒。监控显示搜索数据采集集群的 32 个 worker 进程全部被挂死,CPU 占用无限接近零,内存却死死卡在高位,待处理队列堆积了三万多个关键词。

排查日志才发现,凌晨骨干网有一段长达 8 分钟的偶发丢包。我们的抓取脚本里只写了老套的 retry(times=3)。当上游连接超时,32 个 worker 几乎在同一时刻疯狂触发重试,不仅没把数据采回来,反而耗尽了连接池所有可用 socket,把任务死锁在等待阶段。

重试是治标不治本的“赌徒策略”。在外部 HTTP 接口发生抖动时,继续硬抗请求只会加速系统崩溃。要让后台任务长期稳定跑下去,采集链路里必须有一套清醒的断路与自愈机制——也就是熔断器(Circuit Breaker)。


为什么普通重试在采集链路里是毒药?

写业务接口调外部 API,很多人习惯在异常捕获里塞一个简单的 for _ in range(3): retry()。这在低并发场景下偶尔能混过去,但在批量搜索采集中,它会引发三个连锁反应:

  1. 惊群雪崩:上游抖动或出现拥塞时,原本每秒发出的 20 个请求会因为重试瞬间放大到 60 个,加重下游排队和丢包。
  2. 连接池耗尽:HTTP 客户端在建立 TCP 连接超时的等待期内会霸占连接资源,后续新入队的采集任务直接在本地挂起。
  3. 成本损耗与垃圾流量:上游服务可能正在做短时维护,你疯狂重试打过去的残缺请求,可能还在计费信封里被扣减配额或产生垃圾日志。

成熟的做法是引入标准的三态状态机:

       [ Closed (闭合) ] 
         │            ▲
  错误率超标          探测成功
         ▼            │
       [ Open (断路) ] ──(冷却期满)──► [ Half-Open (半开) ]
                                            │
                                         探测失败
                                            ▼
                                      [ Open (断路) ]
  • Closed(闭合正常态):请求正常通过。滑动窗口内统计失败率,低于阈值则继续放行。
  • Open(断路熔断态):一旦失败率超标,直接掐断所有外发请求。此时不再向真实接口发送网络流量,而是直接返回兜底结果(Fallback)或把任务重排进延迟队列。
  • Half-Open(半开探测态):熔断冷却一段时间后,仅放行一到两笔轻量探测请求。若探测成功,状态机自动平滑自愈,切回 Closed;若依然报错,立即重置冷却倒计时,切回 Open。

熔断器工程实现:带时间滑窗的状态机

很多第三方断路器库依赖重型的 Redis 锁或者复杂的依赖注入,对一个独立的采集 worker 来说太重。下面是一个不依赖外部组件、支持线程安全的轻量级断路器实现。

状态机核心类

import time
import threading
from collections import deque
from enum import Enum

class BreakerState(Enum):
    CLOSED = "CLOSED"
    OPEN = "OPEN"
    HALF_OPEN = "HALF_OPEN"

class CircuitBreaker:
    def __init__(
        self,
        failure_threshold: float = 0.5,  # 失败率阈值:50%
        window_size: int = 20,           # 统计窗口样本容量
        recovery_timeout: float = 30.0   # 熔断开启后的冷却观察期(秒)
    ):
        self.failure_threshold = failure_threshold
        self.window_size = window_size
        self.recovery_timeout = recovery_timeout

        self.state = BreakerState.CLOSED
        self.window = deque(maxlen=window_size)
        self.last_state_change = time.time()
        self.lock = threading.Lock()

    def allow_request(self) -> bool:
        """检查当前是否允许发起真实请求"""
        with self.lock:
            now = time.time()
            if self.state == BreakerState.CLOSED:
                return True

            if self.state == BreakerState.OPEN:
                if now - self.last_state_change > self.recovery_timeout:
                    self.state = BreakerState.HALF_OPEN
                    self.last_state_change = now
                    return True
                return False

            if self.state == BreakerState.HALF_OPEN:
                # 半开状态只允许单次探活放行
                return True

        return False

    def record_success(self):
        """记录一次调用成功"""
        with self.lock:
            if self.state == BreakerState.HALF_OPEN:
                # 探活成功,自愈恢复
                self.state = BreakerState.CLOSED
                self.window.clear()
                self.last_state_change = time.time()
            elif self.state == BreakerState.CLOSED:
                self.window.append(True)

    def record_failure(self):
        """记录一次调用失败"""
        with self.lock:
            now = time.time()
            if self.state == BreakerState.HALF_OPEN:
                # 探活失败,重新熔断打回
                self.state = BreakerState.OPEN
                self.last_state_change = now
            elif self.state == BreakerState.CLOSED:
                self.window.append(False)
                # 仅在收集到足够样本时进行比例判断
                if len(self.window) >= (self.window_size // 2):
                    failures = self.window.count(False)
                    fail_rate = failures / len(self.window)
                    if fail_rate >= self.failure_threshold:
                        self.state = BreakerState.OPEN
                        self.last_state_change = now

接入真实采集端点:区分业务错误与网络异常

断路器设计最忌讳一刀切把所有异常都当成“服务不可用”。参数传错(400/422)属于客户端 bug,把它算进熔断指标会导致系统无意义熔断;只有连接超时、DNS 解析中断或上游服务异常(5xx),才应该触发断路统计。

这里接入搜索 API 进行验证。在调用时,按照官方接口规范进行包装,参考 SerpBase 的 /google/search 端点文档,统一通过 POST JSON 提交搜索参数,并在请求头附带 X-API-Key 鉴权。

生产级封装客户端

import json
import requests

class SerpFetcher:
    def __init__(self, api_key: str):
        self.api_key = api_key
        self.endpoint = "https://api.serpbase.dev/google/search"
        self.session = requests.Session()
        self.breaker = CircuitBreaker(
            failure_threshold=0.4,
            window_size=10,
            recovery_timeout=15.0
        )

    def search(self, query: str, gl: str = "us", hl: str = "en") -> dict:
        # 1. 熔断前检查
        if not self.breaker.allow_request():
            # 降级策略:打回本地队列,打上待重试标签,避免丢弃
            return {
                "fallback": True,
                "reason": "circuit_breaker_open",
                "query": query
            }

        headers = {
            "Content-Type": "application/json",
            "X-API-Key": self.api_key
        }
        payload = {
            "q": query,
            "gl": gl,
            "hl": hl
        }

        try:
            # 严控 HTTP 阶段超时时间
            resp = self.session.post(
                self.endpoint,
                headers=headers,
                json=payload,
                timeout=(3.0, 10.0)  # (connect_timeout, read_timeout)
            )

            # 区分不同状态码
            if resp.status_code == 200:
                data = resp.json()
                self.breaker.record_success()
                return {
                    "success": True,
                    "credits_charged": data.get("credits_charged", 0),
                    "request_id": data.get("request_id"),
                    "organic_count": len(data.get("organic", [])),
                    "items": data.get("organic", [])
                }
            elif 400 <= resp.status_code < 500:
                # 业务参数异常不计入网络熔断,但记录告警
                return {
                    "success": False,
                    "status_code": resp.status_code,
                    "reason": "client_param_error"
                }
            else:
                # 上游 5xx 服务端错误,计入熔断统计
                self.breaker.record_failure()
                return {"success": False, "status_code": resp.status_code}

        except (requests.exceptions.ConnectTimeout,
                requests.exceptions.ReadTimeout,
                requests.exceptions.ConnectionError):
            # 明确的网络层异常,计入熔断统计
            self.breaker.record_failure()
            return {
                "success": False,
                "reason": "upstream_unreachable"
            }

压力抖动实测对照

为了验证熔断器对整个系统吞吐与稳定性的保护效果,我在本地模拟了包含 100 笔连续采集任务的工作流。在执行到第 30 笔任务时,模拟人为注入 10 秒的网络阻断(通过本地代理丢包)。

实测数据对比如下:

指标项 纯重试方案(retry=3) 熔断自愈方案(CircuitBreaker) 差异说明
异常区间单请求平均阻塞 9,240 ms 12 ms 熔断生效后直接本地短路返回,无需握手等待
Worker 连接池峰值占用 100%(满载溢出) 8%(稳定受控) 短路期间释放所有可用 TCP 连接
100 笔任务总耗时 148.5 s 36.2 s 熔断降级任务被直接标记移入重排,未拖垮正常批次
无意义请求外发量 30 次(全部超时) 1 次(半开探测) 阻止了网络震荡期间盲目发送的垃圾重试
自愈恢复耗时 ~45 s(等待所有线程超时排空) < 2 s(半开探测单次即自愈) 冷却期后单次成功立即切换闭合态

可以看到,在断网注入期间,未加熔断的客户端死等 TCP 读取超时,一个 worker 卡住近 10 秒;而加上熔断器之后,仅在采样窗口跌破阈值的一瞬间(约前 3~4 次失败后),整个系统立即切为 Open 状态,后续数十次请求在 1 毫秒内即完成降级路由,给任务系统留出了极为干净的恢复空间。


配合平台配额与 QPS 升级策略的工程细节

在设计采集架构时,断路器的参数不能拍脑袋凭空瞎填,必须与你的上游配额相协调。

如果你使用的是 SerpBase,所有 key 和不同产品端点是共享整个账户的统一 QPS 限制的。根据官方的累计充值升级规则,账户可用 QPS 会根据历史充值额度阶梯式解锁:

  • 充值满 $3 解锁至 2 QPS
  • 充值满 $50 解锁至 5 QPS
  • 充值满 $200 解锁至 15 QPS
  • 充值满 $1,000 解锁至 50 QPS

如果你的账户目前在 5 QPS 这一档,但上游本地调度器突发打出了 20 QPS,你可能会收到频控相关的响应。如果直接把频控全部归结为“接口挂了”并一键熔断,整个采集任务就会陷入不必要的休眠。

因此,在工程落地时,我们做了两层隔离:

  1. 限速层与熔断层解耦:限速(Rate Limiter)负责在发包前削峰,确保请求节奏落在账户当前的 QPS 许可内;
  2. 熔断器仅收敛硬故障:断路器专门拦截网络不可达、连接超时以及确凿的服务端异常。

同时,把 SerpBase 响应信封里的 request_id 和 elapsed_ms 顺带写入本地降级日志。一旦遇到半开探测依然失败的情况,你可以直接拿着 request_id 去核对当时的网络延迟指标,不必大海捞针。


下一步行动

如果你现在的采集服务还在靠简单的 try...except 盲目重试,建议抽半天时间做以下改造:

  1. 把上面的 CircuitBreaker 类集成进采集客户端基类,给超时异常打上严格区分标签;
  2. 定义明确的 Fallback 动作:被熔断打回的查询不要直接扔掉,写进延迟重试队列(比如延后 60 秒再入队);
  3. 针对你的 QPS 配置冷却时长:并发量大的流水线,把 window_size 设为 20~30,recovery_timeout 设为 10~15 秒,既能迅速避险,也能在上游恢复的第一时间平滑拉起抓取。
posted @ 2026-10-13 09:44  蜘蛛人  阅读(7)  评论(0)    收藏  举报