采集熔断器怎么写:上游抖动下的断路与自愈实践
上周凌晨三点,我被报警短信叫醒。监控显示搜索数据采集集群的 32 个 worker 进程全部被挂死,CPU 占用无限接近零,内存却死死卡在高位,待处理队列堆积了三万多个关键词。
排查日志才发现,凌晨骨干网有一段长达 8 分钟的偶发丢包。我们的抓取脚本里只写了老套的 retry(times=3)。当上游连接超时,32 个 worker 几乎在同一时刻疯狂触发重试,不仅没把数据采回来,反而耗尽了连接池所有可用 socket,把任务死锁在等待阶段。
重试是治标不治本的“赌徒策略”。在外部 HTTP 接口发生抖动时,继续硬抗请求只会加速系统崩溃。要让后台任务长期稳定跑下去,采集链路里必须有一套清醒的断路与自愈机制——也就是熔断器(Circuit Breaker)。
为什么普通重试在采集链路里是毒药?
写业务接口调外部 API,很多人习惯在异常捕获里塞一个简单的 for _ in range(3): retry()。这在低并发场景下偶尔能混过去,但在批量搜索采集中,它会引发三个连锁反应:
- 惊群雪崩:上游抖动或出现拥塞时,原本每秒发出的 20 个请求会因为重试瞬间放大到 60 个,加重下游排队和丢包。
- 连接池耗尽:HTTP 客户端在建立 TCP 连接超时的等待期内会霸占连接资源,后续新入队的采集任务直接在本地挂起。
- 成本损耗与垃圾流量:上游服务可能正在做短时维护,你疯狂重试打过去的残缺请求,可能还在计费信封里被扣减配额或产生垃圾日志。
成熟的做法是引入标准的三态状态机:
[ 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,你可能会收到频控相关的响应。如果直接把频控全部归结为“接口挂了”并一键熔断,整个采集任务就会陷入不必要的休眠。
因此,在工程落地时,我们做了两层隔离:
- 限速层与熔断层解耦:限速(Rate Limiter)负责在发包前削峰,确保请求节奏落在账户当前的 QPS 许可内;
- 熔断器仅收敛硬故障:断路器专门拦截网络不可达、连接超时以及确凿的服务端异常。
同时,把 SerpBase 响应信封里的 request_id 和 elapsed_ms 顺带写入本地降级日志。一旦遇到半开探测依然失败的情况,你可以直接拿着 request_id 去核对当时的网络延迟指标,不必大海捞针。
下一步行动
如果你现在的采集服务还在靠简单的 try...except 盲目重试,建议抽半天时间做以下改造:
- 把上面的
CircuitBreaker类集成进采集客户端基类,给超时异常打上严格区分标签; - 定义明确的 Fallback 动作:被熔断打回的查询不要直接扔掉,写进延迟重试队列(比如延后 60 秒再入队);
- 针对你的 QPS 配置冷却时长:并发量大的流水线,把
window_size设为 20~30,recovery_timeout设为 10~15 秒,既能迅速避险,也能在上游恢复的第一时间平滑拉起抓取。

浙公网安备 33010602011771号