问题定位:不是框架选型错,是执行模型不匹配我们的抓取服务在 Scrapy 上稳定运行了两年,日均调度量从 30 万涨到 3000 万。迁移的动因不是 Scrapy 不行,而是它的并发模型在当前负载下出现了结构性瓶颈。Scrapy 建立在 Twisted 之上,整套请求与回调跑在单个 reactor 线程里,靠协程让出 CPU 来实现并发。这要求每个回调必须是非阻塞的。一旦parse回调里出现 CPU 密集的同步代码,比如大文档的 XPath 批量抽取、深回溯正则、或者 JSON 反序列化后的字段规整,reactor 就会被这一个回调独占,事件循环无法推进,同进程内其他所有在途请求一起排队。更麻烦的是 GIL。Twisted 的线程池能把阻塞调用 offload 到线程,但 Python 的 GIL 让纯 CPU 解析无法在多核间真正并行,线程桥接反而引入锁竞争和死锁排查成本。我们曾因为一个 Spider 的正则在新页面结构上退化为 O(n²),单机能级延迟从 80ms 飙到 4s,整台机器抓取近乎停摆。Crawlo 的模型从根上避开了这一点:取数层(网络 IO)和处理层(CPU 解析)是两个独立的执行体,通过有界队列连接。网络协程只负责收发字节,解析 worker 只吃 CPU,两边用背压解耦。路径一:取数层改为有界并发的异步客户端取数层弃用 Scrapy Downloader,直接用 aiohttp。关键不在"异步"本身,而在并发上限和背压必须显式可控:TCPConnector(limit=...)约束对外连接数,Semaphore约束在途请求数,Queue(maxsize=...)在解析慢于抓取时反向压住生产者,避免内存被待处理 HTML 撑爆。代理地址作为构造参数透传,是后面接亿牛云的前提。解析不再与网络共享协程栈,reactor 被单点拖垮的问题消失。路径二:处理层下沉为无状态解析 worker处理层从 Spider 回调改为从队列消费消息、解析、回写。worker 完全不碰网络,因此定位性能问题只需要看单个 worker 的耗时分布,不需要在 Twisted 调用栈里翻找。迁移中改动量最大的一处是上下文传递。老 Spider 习惯把上游信息塞进response.meta,下游回调随手取用。worker 模型要求parse_fn是纯函数,输入只有 HTML 和显式声明的元数据,输出是规整后的 record。我们对 40 多个 Spider 做了一次 meta 字段审计,把隐式依赖全部改成函数签名显式传入。接入亿牛云隧道代理:把 IP 质量从业务里剥离目标站点上线行为风控后,出口 IP 的纯净度直接决定抓取成功率。我们没有自建代理池,而是接入亿牛云的动态隧道代理。选择隧道而非前向代理,原因在于隧道把 IP 轮换和健康度管理收敛到代理侧:业务每次请求都打到固定隧道入口,由亿牛云在后端按策略切换出口 IP 并自动剔除被封节点,代码里不需要维护 IP 池、不需要写健康检查。隧道代理的代价是单次请求多一跳,RTT 比直连高 20ms 到 50ms 左右。我们的场景对端到端延迟不敏感、对成功率敏感,这笔交换成立。若业务是强实时的在线查询,就要重新评估:要么换前向代理就近部署,要么把隧道节点和抓取目标放在同可用区。为了量化代理质量,我们在 fetcher 里加了一个轻量统计钩子,按域名记录成功率和 403/429 占比,直接喂给监控,而不是等封禁率涨上去才人工发现。
class ProxyStats:
def __init__(self):
self.ok = 0
self.blocked = 0
def record(self, status):
if status == 0 or status >= 400:
self.blocked += 1
else:
self.ok += 1
@property
def ban_rate(self):
total = self.ok + self.blocked
return self.blocked / total if total else 0.0