两周完成爬虫技术栈升级:Scrapy 迁移 Crawlo 的路径与取舍

问题定位:不是框架选型错,是执行模型不匹配

我们的抓取服务在 Scrapy 上稳定运行了两年,日均调度量从 30 万涨到 3000 万。迁移的动因不是 Scrapy 不行,而是它的并发模型在当前负载下出现了结构性瓶颈。

Scrapy 建立在 Twisted 之上,整套请求与回调跑在单个 reactor 线程里,靠协程让出 CPU 来实现并发。这要求每个回调必须是非阻塞的。一旦 parse 回调里出现 CPU 密集的同步代码,比如大文档的 XPath 批量抽取、深回溯正则、或者 JSON 反序列化后的字段规整,reactor 就会被这一个回调独占,事件循环无法推进,同进程内其他所有在途请求一起排队。

python 复制代码
# 反模式:同步重解析阻塞 reactor
class ProductSpider(scrapy.Spider):
    name = "product"

    def parse(self, response):
        # heavy_extract 是 CPU 密集同步调用,会独占 reactor
        for item in heavy_extract(response.text):
            yield item

更麻烦的是 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 撑爆。

python 复制代码
import asyncio
import aiohttp

class Fetcher:
    def __init__(self, proxy_url, concurrency=200, per_host=20):
        self.proxy_url = proxy_url
        self.sem = asyncio.Semaphore(concurrency)
        self.host_limit = per_host

    async def __aenter__(self):
        self.conn = aiohttp.TCPConnector(
            limit=0,                       # 由 Semaphore 统一管控
            enable_cleanup_closed=True,
            ssl=False,
        )
        self.session = aiohttp.ClientSession(
            connector=self.conn,
            timeout=aiohttp.ClientTimeout(total=20, connect=5),
        )
        return self

    async def fetch(self, url):
        async with self.sem:
            try:
                async with self.session.get(
                    url, proxy=self.proxy_url, ssl=False
                ) as resp:
                    resp.raise_for_status()
                    return url, await resp.text(), resp.status
            except aiohttp.ClientResponseError as e:
                return url, None, e.status
            except (aiohttp.ClientError, asyncio.TimeoutError):
                return url, None, 0        # 0 表示网络层失败,需立即重试

代理地址作为构造参数透传,是后面接亿牛云的前提。解析不再与网络共享协程栈,reactor 被单点拖垮的问题消失。

路径二:处理层下沉为无状态解析 worker

处理层从 Spider 回调改为从队列消费消息、解析、回写。worker 完全不碰网络,因此定位性能问题只需要看单个 worker 的耗时分布,不需要在 Twisted 调用栈里翻找。

python 复制代码
import asyncio
from typing import Callable

async def parse_worker(
    queue: asyncio.Queue,
    parse_fn: Callable[[str], dict],
    out,
):
    while True:
        url, html = await queue.get()
        try:
            if html is None:
                continue
            record = parse_fn(html)        # 纯 CPU,无 IO
            out.write(record)
        finally:
            queue.task_done()

async def pipeline(urls, parse_fn, out, proxy_url, workers=8):
    queue: asyncio.Queue = asyncio.Queue(maxsize=200)  # 背压边界
    async with Fetcher(proxy_url) as fetcher:
        consumers = [
            asyncio.create_task(parse_worker(queue, parse_fn, out))
            for _ in range(workers)
        ]
        producers = [
            asyncio.create_task(_produce(fetcher, urls[i::workers], queue))
            for i in range(workers)
        ]
        await asyncio.gather(*producers)
        await queue.join()
        for c in consumers:
            c.cancel()

迁移中改动量最大的一处是上下文传递。老 Spider 习惯把上游信息塞进 response.meta,下游回调随手取用。worker 模型要求 parse_fn 是纯函数,输入只有 HTML 和显式声明的元数据,输出是规整后的 record。我们对 40 多个 Spider 做了一次 meta 字段审计,把隐式依赖全部改成函数签名显式传入。

接入亿牛云隧道代理:把 IP 质量从业务里剥离

目标站点上线行为风控后,出口 IP 的纯净度直接决定抓取成功率。我们没有自建代理池,而是接入亿牛云的动态隧道代理。选择隧道而非前向代理,原因在于隧道把 IP 轮换和健康度管理收敛到代理侧:业务每次请求都打到固定隧道入口,由亿牛云在后端按策略切换出口 IP 并自动剔除被封节点,代码里不需要维护 IP 池、不需要写健康检查。

python 复制代码
import os

# 亿牛云隧道代理:用户名承载订单与鉴权,密码为动态令牌
# host / port 在亿牛云控制台开通隧道后获取
PROXY_URL = "http://{user}:{pwd}@{host}:{port}".format(
    user=os.environ["YINIU_USER"],
    pwd=os.environ["YINIU_PWD"],
    host=os.environ["YINIU_HOST"],
    port=os.environ["YINIU_PORT"],
)

隧道代理的代价是单次请求多一跳,RTT 比直连高 20ms 到 50ms 左右。我们的场景对端到端延迟不敏感、对成功率敏感,这笔交换成立。若业务是强实时的在线查询,就要重新评估:要么换前向代理就近部署,要么把隧道节点和抓取目标放在同可用区。

为了量化代理质量,我们在 fetcher 里加了一个轻量统计钩子,按域名记录成功率和 403/429 占比,直接喂给监控,而不是等封禁率涨上去才人工发现。

python 复制代码
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

路径三:重试与去重下沉到队列层

重试逻辑从 Spider 剥离,改为按状态码决定策略。0 是网络层失败,应当短时指数退避立即重试;429503 是服务端限流,退避更长;2xx/3xx 正常消费;4xx(非 429)通常是目标结构问题,不应盲目重试,否则只会增加被封概率。

python 复制代码
import random

BACKOFF = {0: 2, 429: 30, 500: 10, 503: 20}
MAX_RETRY = {0: 5, 429: 3, 503: 3}

async def schedule_retry(url, status, attempt, queue, seen):
    if status not in BACKOFF:
        return
    if attempt >= MAX_RETRY.get(status, 0):
        return
    base = BACKOFF[status] * (2 ** attempt)      # 指数退避
    jitter = random.uniform(0, base * 0.3)        # 抖动避免惊群
    await asyncio.sleep(base + jitter)
    await queue.put((url, None, attempt + 1))

去重从 Scrapy 的 RFPDupeFilter(基于内存 set,进程重启即丢,且常驻几 GB)换成 Redis 上的布隆过滤器,跨进程共享、内存占用降到几十 MB,误判率按业务容忍度设为 0.1%。生产用 Redis 版 pybloom_liveRedisBloomFilter 即可,本地调试可用 ScalableBloomFilter 替代。

python 复制代码
from pybloom_live import ScalableBloomFilter

seen = ScalableBloomFilter(initial_capacity=10_000_000, error_rate=0.001)

def should_crawl(url):
    if url in seen:
        return False
    seen.add(url)
    return True

两周排期与回退策略

第一周搭骨架并做单域名试点:把一个高流量域名的解析函数原样搬进 worker,抓取用 Fetcher 重写,重点验证解耦后的吞吐与稳定性。试点期同时跑新旧两条链路、打同样流量做对照,确认 p90 延迟和成功率达标再推进。

第二周按域名灰度:10% 流量观察一小时,无异常升到 50%,再全量。旧 Scrapy 链路全程保留作回退,切换开关放在配置中心,异常时秒级回切。全量后旧链路继续双跑两天,对照指标无偏差才下线。

取舍清单与结果

最终的三笔账:

  • 保留:Scrapy 的 Item Pipeline 思路,字段校验挪到 out.write 前的规整阶段。
  • 丢弃:Twisted 线程桥接、堆在 middleware 里的代理切换与重试逻辑。
  • 新增:亿牛云隧道代理适配、状态码感知的退避重试、Redis 布隆去重、按域名的代理质量统计。

代价是 Crawlo 生态比 Scrapy 小,部分中间件要自己写,但这笔成本一次性付清,换来的是后续可按需扩展 worker 数、独立调优抓取与解析。

上线后日均调度量不变,机器数从 12 台降到 8 台,解析慢导致的队列积压基本消失;代理封禁率因隧道自动换 IP 下降约六成,目标站点 403 显著减少。两周交付的是主干替换,监控告警细化和 worker 弹性伸缩是后续迭代项。

相关推荐
Python+991 小时前
Java 枚举类(Enum)详解:从基础到高级应用
java·开发语言·python
dunge20262 小时前
2026年7月最新ChatGPT Plus / Pro 与 Codex:当 AI Agent 最新5.6版本来袭,必须理解事务、幂等与补偿
开发语言·人工智能·python
深蓝电商API4 小时前
用爬虫做量化交易:另类数据采集与因子挖掘
爬虫
用户8356290780514 小时前
使用 Python 在 Excel 中添加和自定义文本框
后端·python
小心我捶你啊4 小时前
数据采集和Web解锁不是一回事,从用途到规则区分
前端·爬虫·网络协议
总裁余(余登武)4 小时前
python多个py文件打包【解决exe不能移动运行bug】
python
kisloy5 小时前
【python零基础教程第24讲】代码规范与质量管控
开发语言·python
xywww1685 小时前
AWS 账号权限怎么分:根用户和 IAM 用户区别及日常使用建议
大数据·开发语言·人工智能·python·gpt·云计算·aws
许彰午7 小时前
100_Python面试常见问题汇总
java·python·面试