干爬虫这行,最折磨人的从来不是写解析、调并发,而是爬虫“死”了你不知道。今天的采集成功率还是99%,明天一觉醒来发现数据全断在两个小时前——源站悄悄把接口加了一道人机校验,或者某个页面改版,解析规则整片失效。这种“需要人盯着才能活”的爬虫,本质上还停留在手工运维阶段。
这个系列写到这里,前面几篇都在解决“怎么爬得多、爬得稳”,这篇我想换个角度:怎么让爬虫自己发现问题、自己缓一缓。所谓“可观测与自愈”,就是把爬虫从“出事等发现”变成“出事自恢复”。我会用模拟项目X的实战经验,把这套能力怎么设计、怎么埋点、怎么控制节奏讲透,适合正在维护长期采集任务的开发者参考。
1. 先想清楚一件事:爬虫的故障不是死一次,是慢慢变成废物的
以前我刚维护采集任务的时候,对故障的理解特别简单粗暴:崩了就重启,醒了接着爬。后来发现爬虫大多数故障根本不是“崩溃”,而是一种渐进式恶化——请求还能发出去,页面也能拿到,但采集结果越来越不对劲。这种状态比崩溃可怕得多,因为系统日志全绿,监控面板也没有告警,实际数据却已经不能用。
1.1 爬虫最常见的四种“慢性死亡”
第一种是IP被限流。源站不会直接封你IP,而是开始随机返回429或者403,时好时坏。如果只看整天的平均值,成功率可能还有95%以上,但如果把数据切成10分钟一个窗口,能看到失败率像心电监护一样忽高忽低。这种问题最坑的地方在于:它不会报错,只会在后台慢慢拉低你的数据质量。
第二种是登录态静默失效。Cookie或者Token过期的那一瞬间,爬虫并不会立刻出错,而是会持续采集到一堆空列表、跳转页面或者登录引导页。如果不做字段完整性校验,这批脏数据就直接进库了。
第三种是页面结构改版。改版通常不是一次性完成的,很多站点会做AB实验,一部分请求返回新版页面,另一部分返回旧版。这时候你的解析规则处于“时好时坏”的薛定谔状态,一天下来,数据字段缺失率可能从0涨到5%,再到30%,等你发现的时候,历史数据已经脏了一大片。
第四种是请求特征被识别。有时候不是封IP,而是请求头、TLS指纹或者行为节奏被盯上了。表现就是某个接口突然开始针对性地返回假数据,或者偶尔跳一个验证页面。这类问题无法靠单纯的“重试”解决,必须切换策略。
1.2 为什么“监控+告警”不等于“可观测+自愈”
很多团队做的事只是监控告警:配一个定时任务,每隔几分钟查一下成功率,低于阈值就发报警消息。这比没有强,但本质上还是“出事之后通知人,人再决定怎么办”。而可观测与自愈强调的是:系统把自己当前的运行状态量化成指标,然后由一个决策引擎根据这些指标自动调整行为。
我把这套机制理解成巡航导弹的制导逻辑——发射之后不是一直在预定弹道上飞,而是通过传感器持续感知目标偏差,不断修正飞行路径。爬虫的自愈不需要设定一条理想曲线,它只需要让系统始终朝向“恢复健康采集”这个方向运动。
这里面最关键的一个认知是:自愈不是无限重试。恰恰相反,自愈的核心能力之一,是知道什么时候该停、该慢、该换——也就是标题里说的“自己缓一缓”。重试是把资源反复砸向同一个失败的点,而缓一缓是把节奏降下来,给对端留出恢复空间,也给自己的策略库留出切换时间。
2. 可观测层怎么搭:先把爬虫的状态变成数据,再谈自动决策
自愈的前提是“感知”。一个连当前采集成功率高不高、失败集中在哪个环节都不知道的爬虫,谈自愈就是空中楼阁。这章节我会把模拟项目X从“只打印一堆日志”升级到“每个核心环节都有数字可看”的过程完整拆开,讲清楚每个指标为什么值得埋、怎么埋不心疼、怎么算才准确。
2.1 四层指标体系:流量、解析、业务、资源
第一层是流量层,统计请求量、响应状态码分布、重试率、单次请求耗时。这层指标回答的是“我发出去的请求,有多少被正常接住了”。我在模拟项目X里最常用的状态码分组是:2xx、3xx、4xx、5xx、以及“请求异常”。这里有个关键点:4xx一定要细分,403和404的含义完全不同,403通常指向反爬动作,而404可能是页面地址变了。
第二层是解析层,统计解析成功数、字段缺失率、解析异常类型分布。这层指标回答的是“拿到页面之后,数据有没有被正确提取出来”。字段缺失率是个非常敏感的信号,我建议按字段维度单独统计:比如标题缺失率、价格缺失率、SKU缺失率,这样才能快速定位是哪个字段对应的选择器失效了。
第三层是业务层,统计入库成功数、去重率、覆盖完成率。这层回答的是“分给这条爬虫的业务目标有没有在接近”。前两层可能看起来正常,但业务层会暴露“一直在采集但没产出有用数据”的问题——比如所有条目都在去重环节被过滤掉了,说明源站数据根本没更新。
第四层是资源层,统计队列积压量、线程池活跃线程数、内存占用。这层回答的是“爬虫自己还能不能扛得住”。一个非常容易踩的坑是:源站恢复之后,之前堆积的请求瞬间全部解冻,并发直接打满,引起二次限流。资源层的指标用来限制这种“恢复性雪崩”。
提示:这四层指标不需要一开始就全部铺完。我建议第一版只做流量层和解析层,跑一周拿到基线数据之后,再决定业务层和资源层的监控力度。指标不是越多越好,每个指标都要能在决策时派上用场,否则就是白白增加系统开销。
2.2 从“print日志流”升级为结构化事件流
写爬虫的人最初都爱用print来观察运行状态,但是一个要具备自愈能力的爬虫,日志必须是机器可读的。我现在的方案是每产生一次关键事件,就输出一行JSON格式的结构化日志,同时追加到事件流。
举个例子,模拟项目X里的状态码异常记录长这样:
import json import time def log_event(event_type, spider_name, **fields): record = { "ts": time.time(), "event": event_type, "spider": spider_name, **fields } print(json.dumps(record, ensure_ascii=False))调用时只需要一行:
log_event("http_status", "product_list", url=url, status=429, retry_count=2)这里我把“日志级别”那种抽象概念弱化了,聚焦在“事件类型”上。日志级别只保留一个周期性的统计报告,而那些紧急事件全部走独立的事件流通道,方便后续接入告警或者自愈决策引擎。
事件类型我维护了一个统一字典,比如block_detected(检测到封锁)、parse_failed(解析失败)、login_expired(登录态失效)、rate_limited(被限速)、strategy_switched(切换了策略)。这样做的最大好处是:决策引擎不需要去理解自然语言日志,它只需要按照事件类型和携带的指标值来触发逻辑。
2.3 滑动窗口健康度:一个比“平均成功率”好用十倍的计算方法
绝大多数爬虫框架自带统计功能,但那些统计大多是从启动到现在的累计数值。这个数值的毛病在于太钝——一个跑了48小时的爬虫,单小时失败率就算高达100%,也会被前47小时的正常数据稀释成“看起来还有97%”。
我采用的是滑动窗口健康度。简单说,就是只取最近N分钟的数据来计算当前的健康状态。模拟项目X里用的默认配置是10分钟窗口,每30秒计算一次。
from collections import deque import time class SlidingWindowHealth: def __init__(self, window_seconds=600, tick_seconds=30): self.window_seconds = window_seconds self.tick_seconds = tick_seconds self.buckets = deque() def add_sample(self, success, fail): now = int(time.time() // self.tick_seconds) * self.tick_seconds if self.buckets and self.buckets[-1][0] == now: s, f = self.buckets[-1] self.buckets[-1] = (now, s + success, f + fail) else: self.buckets.append((now, success, fail)) # 丢弃过期桶 cutoff = now - self.window_seconds while self.buckets and self.buckets[0][0] < cutoff: self.buckets.popleft() def success_rate(self): total_s = sum(b[1] for b in self.buckets) total_f = sum(b[2] for b in self.buckets) if total_s + total_f == 0: return 1.0 return total_s / (total_s + total_f)这里有个细节值得单独提一下:桶的粒度。我之前用过1分钟一个桶,窗口10秒刷新一次,后来发现数据噪声极大,因为短时间内的请求波动本来就是正常的。调整成30秒一个桶、10分钟窗口之后,健康度的曲线平稳了很多,误判频率明显下降。
健康度计算出来之后,不管数值是多少,都需要把它暴露出来。我这边的做法是:每30秒把当前所有指标快照追加到一张spider_health_snapshot表里,同时本地保留一份最近24小时的滚动文件。这样自愈决策引擎能够拿到过去24小时全部指标,而不只是当前值。
3. 自愈引擎:让爬虫学会“自己缓一缓”的三种控制手段
可观测层解决的是“眼里有数”,自愈层解决的是“手上有动作”。我做自愈引擎时,没有想着一上来就搞复杂的人工智能决策,而是从三个最基本的控制手段出发:动态退避、熔断降级、策略切换。这三个手段按风险级别从低到高排列,组合起来就能覆盖绝大多数爬虫故障场景。
3.1 自适应退避:不是盲目重试,而是有节奏地撤退
重试是爬虫对抗里最简单也最容易被滥用的一招。很多开发者遇到请求失败,第一反应就是retry(3),但重试背后有个致命逻辑问题:如果服务器是因为你请求太频繁而拒绝你,那么立刻重试等于在同一个伤口上连续捅刀。
自适应退避的核心思路是:重试间隔随失败次数指数增长,并且加入随机抖动,避免所有爬虫实例在同一时刻发起重试。之所以要随机抖动,是因为多实例场景下,如果大家都按固定的1秒、2秒、4秒退避,那么系统恢复的那一刻,所有线程会同时醒来,形成请求洪峰。
import random import time def adaptive_backoff(attempt, base_seconds=1.0, cap_seconds=120.0): if attempt <= 0: return 0 exp = min(base_seconds * (2 ** (attempt - 1)), cap_seconds) jitter = random.uniform(0, exp * 0.3) return exp + jitter模拟项目X里的调用方式是这样的:第一次失败,等待约1.3秒;第二次失败,约2.6秒;第三次失败,约5.2秒。如果失败到第7次,间隔已经封顶在120秒左右。这里有个经验值:封顶值不要设得太小。我见过有人把封顶设为10秒,结果在源站已经明确拒绝的情况下,仍然保持每10秒骚扰一次的高频节奏,反而加剧了封禁强度。
3.2 熔断器:连续失败达到阈值,直接切断请求通道
退避是单次请求层面的控制,熔断是请求通道层面的控制。断路器模式借鉴的是电路保护思想:当连续失败次数达到阈值,断路器从“关闭”状态切换到“打开”状态,这时候所有请求不再真正发出,直接快速失败;经过一个冷却期之后,断路器进入“半开”状态,放少量试探请求进去,如果试探成功,就恢复关闭状态,如果失败,就再次打开。
class CircuitBreaker: def __init__(self, fail_threshold=5, cooldown_seconds=60): self.fail_threshold = fail_threshold self.cooldown_seconds = cooldown_seconds self.fail_count = 0 self.state = "closed" # closed / open / half_open self.opened_at = 0 def allow_request(self): now = time.time() if self.state == "open": if now - self.opened_at >= self.cooldown_seconds: self.state = "half_open" return True return False if self.state == "half_open": return True return True def record_success(self): self.fail_count = 0 self.state = "closed" def record_failure(self): self.fail_count += 1 if self.fail_count >= self.fail_threshold: self.state = "open" self.opened_at = time.time()这里我想强调半开状态的设计。很多人做熔断器时只做了“关闭/打开”两个状态,结果冷却期一过,所有请求同时涌入,再次打爆。半开状态的意义在于:用极低比例的试探流量去测试对端是否恢复,相当于人在过独木桥时先伸一只脚探探虚实。
3.3 分级自愈策略:先让它慢下来,不行再换路
有了退避和熔断之后,还需要一个总控逻辑来决定“当前该执行哪套动作”。我维护了一张自愈策略表,按以下级别逐级升级:
| 级别 | 触发条件 | 自动动作 | 说明 |
|---|---|---|---|
| L1 降速 | 健康度低于90%,但高于70% | 并发数减半,单请求间隔加倍 | 让出空间,稳一稳 |
| L2 退避 | 健康度低于70%,或熔断器打开 | 暂停当前队列消费,进入退避等待 | 停止进攻,保存资源 |
| L3 切换 | 连续两轮L2后仍无恢复 | 切换代理出口、切换请求头模板、切换解析规则 | 换一条路走 |
| L4 人工 | L3动作后仍持续异常 | 触发告警,停止任务 | 靠人决策 |
这套分级策略的关键在于“升级容易降级快”。升级不能太激进,同样的异常要连续两个周期都能复现,才进入下一级;降级要果断,只要指标恢复,马上回到正常采集模式,不能因为“怕复发”而一直留在低速状态,导致采集进度拖慢。
3.4 动态限速:让爬虫像一个有经验的老手一样调整节奏
限速听起来简单——固定间隔请求一轮不就行了?但实际问题在于:源站的承受能力和反爬策略是动态变化的。固定间隔太短容易被限流,太长又浪费带宽。我在模拟项目X里做了一个“带反馈的自动调速器”,思路和恒温器的控制逻辑很像:连续成功N次,就在允许范围内小幅提高请求频率;连续失败M次,就大幅降低频率。
class AdaptiveRateController: def __init__(self, min_interval=0.5, max_interval=8.0, speedup_after=5, slow_down_after=2): self.interval = 2.0 self.min_interval = min_interval self.max_interval = max_interval self.speedup_after = speedup_after self.slow_down_after = slow_down_after self.consecutive_success = 0 self.consecutive_fail = 0 def on_success(self): self.consecutive_fail = 0 self.consecutive_success += 1 if self.consecutive_success >= self.speedup_after: self.interval = max(self.min_interval, self.interval * 0.8) self.consecutive_success = 0 def on_failure(self): self.consecutive_success = 0 self.consecutive_fail += 1 if self.consecutive_fail >= self.slow_down_after: self.interval = min(self.max_interval, self.interval * 2.0) self.consecutive_fail = 0这个控制器和一个PID控制器不同,它没有比例项和积分项,完全是经验式的。好处是参数少、行为直观、容易调。坏处是在某些特殊波形下会震荡——连续成功几轮就提速,提速后立刻失败几轮又降速,来回摆。解决震荡的办法是给interval的变化加上一个滞后阈值:比如提速时乘以0.8,降速时乘以2.0,让两个方向的响应速度不对称,这样系统天然倾向于保守。
4. 实战记录:把“可观测+自愈”完整装进模拟项目X
前面说的都是组件,这章节我把它们拼装成一个完整系统,展示某电商平台商品列表页全天采集任务的实施过程。这个任务要求每个自然日完成一次全量覆盖,总量约8万条目,之前是固定每秒2个请求,靠人工盯告警。
4.1 埋点接入:不重构代码,只加装饰器
很多人的顾虑是“加监控是不是意味着要把爬虫重写一遍”。我的经验是:完全不需要。模拟项目X的改造只花了小半天,因为埋点全部通过装饰器和中间件完成,业务解析函数一行没动。
def tracked_request(func): def wrapper(*args, **kwargs): start = time.time() try: result = func(*args, **kwargs) HealthReporter.add_sample(success=1, fail=0) RateController.on_success() return result except RequestBlocked as e: HealthReporter.add_sample(success=0, fail=1) RateController.on_failure() CircuitBreaker.record_failure() log_event("block_detected", "product_list", reason=str(e)) raise except ParseError as e: HealthReporter.add_sample(success=0, fail=1) log_event("parse_failed", "product_list", reason=str(e), url=kwargs.get("url")) raise return wrapper这个装饰器的巧妙之处在于:请求成功还是失败不需要每个调用方各自上报,统一在出口处记录。解析失败和请求被阻断要分开统计,因为它们的自愈动作完全不同。请求被阻断需要退避和断电,而解析失败需要切换解析规则或者告警。
4.2 一次真实的自愈过程回放
改造后的第二天,模拟项目X就经受了一次实战。下面是时间线复盘:
14:00,健康度开始从98%缓慢下滑,滑动窗口内出现了零星的429状态码。此时L1级降速触发,默认并发数从12降到6,请求间隔放大一倍。这个阶段大约持续了10分钟,健康度稳定在91%左右。
14:12,429出现的频率突然增加,同时有几个IP出口被明确返回403。健康度跌破70%,熔断器连续记录失败达到阈值,状态从关闭变为打开。此时L2级退避生效,队列消费被暂停,整个爬虫进入“只读指标、不发请求”的状态。这个暂停持续了大约6分钟。
14:18,熔断器冷却期结束,进入半开状态,放行了3个试探请求。这3个请求全部成功,熔断器关闭。因为失败隔离期已经清除了大部分积压请求,爬虫没有一上来就全力冲刺,而是在L1状态下运行了20分钟后,才由自适应限速器逐步把请求间隔从8秒提到3秒。
14:25,健康度恢复到95%以上。整个故障从开始到结束,没有任何人工干预,唯一的产出是一次“自愈事件”通知,记录了什么时间触发了什么级别的动作。
这个案例里面最值得注意的细节是:如果没有可观测层,14:00到14:12这段时间我们是完全看不见的。等到14:12大批量失败出现,人工介入最快也要5分钟,而且介入后大概率会选择清空队列重启爬虫。重启后的第一个动作是什么?还是全力去请求源站。这其实是很多爬虫越搞越容易封的深层原因——故障后的恢复过程充满了试探性攻击。
4.3 改造前后的硬数据对比
我把改造前一周和改造后一周的关键指标做了对比:
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 日均完成率 | 92.5% | 99.3% |
| 封禁触发次数 | 5次 | 1次 |
| 人工介入次数 | 4次 | 0次 |
| 脏数据条数(按日) | 约1200 | 约80 |
| 平均请求间隔波动 | 固定2秒,无感知 | 动态0.5~8秒 |
印象最深的是封禁触发次数从5次降到1次。原因并不玄学——自愈系统在健康度下滑的初期就主动降速了,从根本上减少了对源站的瞬时压力。反观人工运维的惯性做法,往往是看到失败率升高,第一反应是“是不是并发不够?加线程!”,结果适得其反。
5. 自愈系统自身的坑:维护不当,它可能比爬虫本身更容易制造事故
任何自动化系统都会引入新的故障模式,自愈系统也不例外。很多团队第一次做完自愈功能,满怀期待上线,结果第二天数据全空,查来查去发现是自愈逻辑自己把任务给“缓”没了。下面是我踩过的几个深坑和对应的解法。
5.1 误判自愈:把正常波谷当成系统故障
很多采集任务的请求曲线天生就不是一条直线。比如每天凌晨源站做数据备份,响应变慢、成功率略降;或者某些目录页本来就只有少量商品,采集条目少是正常的。如果自愈引擎只看指标阈值,很容易把这些正常波动当成故障,触发降速甚至熔断,然后一整天都恢复不过来。
我采用了两层保护:第一,健康度计算只统计“预期内的请求”,比如明确是列表页的请求才算,其他杂项排除;第二,所有阈值判断必须有“连续两个窗口都触发”才执行动作,单窗口波动不做响应。另外,我专门做了波谷时段保护——比如凌晨2点到5点只记录指标,不触发L2及以上的自动动作,只发低优先级告警。
5.2 指标口径不一致:自己人跟自己人打架
这个坑在团队协作时特别明显:爬虫框架自带的统计算一次成功率,我的滑动窗口算一次,业务看板又算一次,三次数值对不上。原因通常是“重试请求到底算成功还是失败”和“解析出空列表算不算成功”这两个口径没有拉齐。
我的解决思路是:在埋点入口统一“原始请求结果”和“业务有效结果”两层口径。原始请求结果是网络层事实,不管业务是什么,200就记成功,429就记失败。业务有效结果则是解析层事实,字段齐全才算成功,字段缺失算失败。两层口径分别统计、分别设阈值,决策引擎只看原始请求结果,业务报表只看业务有效结果,彻底不混淆。
另一个口径问题是重试次数:一次请求重试了5次,最后一次成功,算5次失败加1次成功,还是算1次成功?我的建议是,指标统计必须按“尝试次数”记账,但决策引擎里按“用户感知的请求次数”来设阈值。理由很简单:重试次数是成本信号,用户感知的请求次数才是风险信号。
5.3 自愈掩盖真实故障:系统一直“很稳”,但数据一直是坏的
这是自愈系统最隐蔽的危害——它把真实故障悄悄消化掉了,导致问题没有被发现、也没有被修复。比如解析规则已经失效一个月了,但爬虫每次都在L1降速之后“稳定运行”,业务指标的数据偏差却没人看见。等到月底对账才发现,整个月的数据都不能用。
我的对策是:自愈动作必须留痕。每次熔断打开、策略切换、代理调整都要记录到自愈事件表,并且每周生成一次自愈复盘报告,看看本周发生了多少次自愈、分别是什么原因、有没有连续触发同一类动作。如果看到“同一个页面反复触发策略切换”,基本可以断定那不是自愈能解决的问题,而是解析逻辑本身需要人工修了。
另外我给自己设了一个“护栏指标”:业务层的入库成功率。如果业务层健康度持续低于某个值,不管流量层和解析层的自愈动作有多成功,都要升到L4强制触发人工介入。自愈的权限要有边界——它可以自动调整请求节奏和策略,但不能自动“决定数据标准”,数据质量问题必须由人确认。
5.4 自愈动作与网络出口策略错配
模拟项目X早期配置过“失败后自动切换出口节点”的策略。想法很美好,但落地后出现过一次大事故:某个出口被封之后,自愈系统自动切到了另一个地区的出口,结果所有请求都通过了,但返回的列表页内容变成了该地区的空模板,解析成功率从100%断崖式跌到10%。
这个问题的本质是:自愈动作不能只看“请求是否被接受了”,还要看“返回的内容是否符合业务预期”。自那以后,我为所有策略切换动作都加了“前置校验+灰度验证”:切换策略后先在测试列表页上跑5个请求,若解析成功率达到60%以上才继续切换,否则立即回滚。这也再次印证了一句话:自愈系统的每一步自动动作,都应该有两层验证——第一层是技术层验证,第二层是业务层验证。
6. 从“能自愈”到“会自愈”:给这套系统再上一档
基础的自愈是“规则驱动”:看指标、比阈值、做动作。但爬虫面对的环境变化太快,源站的反爬策略升级、页面结构的悄然改版、业务数据分布的漂移,这些都很难用固定规则提前覆盖。所以我把当前这个版本称为“会自愈的低阶版本”,它距离真正的智能自愈还有一个差的距离。
我目前在做的一件事是“基线画像”:用过去30天的指标数据,为每个爬虫任务建立正常行为区间。不只是成功率,还包括每小时请求数的分布规律、页面解析耗时的波动范围、单页面条目数的概率分布。一旦当前状态偏离基线,自愈系统能够给出更精准的异常判断——比如同样是解析失败率上升到30%,如果恰好是页面改版周的第一天,那更可能是改版;如果是在活动大促期间,那更可能是源站流量超载。同样是“缓一缓”,缓的方式和时长应该是不同的。
我也在试着把“自愈事件”本身当作训练数据。每次人工介入时,记录下当时看到的指标快照、触发的自愈动作、最终恢复情况。积累三个月之后,用这批历史数据去校准自愈决策阈值,让系统越来越像“一个懂这个源站脾气的老运维”。
最后说一个真正的体会:自愈系统最大的价值不是“自动”,而是“让故障可预期”。自动只是手段,可预期才是目的——爬虫不再是一个黑盒,它的每一个行为节拍都能被解读。这才是长期稳定采集的根基。