首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >我把 12 个 Scrapy 爬虫项目迁到 Crawlo,踩了这些坑

我把 12 个 Scrapy 爬虫项目迁到 Crawlo,踩了这些坑

原创
作者头像
小白学大数据
发布2026-08-11 17:05:29
发布2026-08-11 17:05:29
1640
举报

折腾了两周,把线上 12 个 Scrapy 爬虫项目全部迁移到 Crawlo。期间踩了 8 个大坑,有些坑文档里一个字都没提。这篇文章就是一份完整的迁移复盘,希望能帮准备做同样事情的人少走弯路。一、为什么要迁移?先说背景。我们团队维护着 12 个线上爬虫项目,覆盖电商比价、新闻聚合、房产数据、招聘信息等场景。最早全部基于 Scrapy 搭建,运行了两年多,整体稳定。但随着业务量增长,三个问题越来越刺眼:1. Twisted 异步模型的维护成本Scrapy 底层依赖 Twisted,而团队的技术栈全面转向 asyncio。每次在 Scrapy 里集成 async 客户端(比如 httpx、playwright)都要走 asyncio.from_thread 或 deferToThread 做桥接,代码丑且容易出 bug。2. 分布式扩展不够灵活我们用 scrapy-redis 做分布式调度,但它的去重策略和调度逻辑绑定太紧,想做自定义的优先级调度(比如按数据时效性排优先级)得大改源码。3. 新人上手成本高Scrapy 的信号(signals)、加载器(ItemLoader)、Twisted Deferred 这套东西,对新同事来说学习曲线陡。团队想要一个更贴近原生 Python async 写法的框架。调研了一圈,选了 Crawlo。原因很简单:原生 asyncio、API 设计更现代、对 Playwright 原生支持、调度器可插拔。二、迁移前的评估迁移 12 个项目不是小事,动手之前先做了系统评估。2.1 代码量盘点

项目

Spider 数量

Pipeline 数量

中间件数量

代码行数

电商比价

8

3

5

~5200

新闻聚合

5

2

3

~3100

房产数据

6

4

4

~4800

招聘信息

4

2

3

~2600

其余 8 个项目

22

12

18

~15000

合计

45

23

33

~30700

3 万行代码、45 个 Spider,说多不多说少不少。关键是中间件和 Pipeline 的适配工作量最大。2.2 迁移策略选了「渐进式迁移」而非「一把梭」:先迁一个最小项目验证流程跑通封装通用适配层,减少逐项目修改并行运行新旧系统两周,对比数据一致性逐项目切换流量,每次只切一个事实证明这个策略救了命——第一个项目就踩了 3 个大坑。三、踩坑实录下面按踩坑的时间顺序讲,每个坑都附上代码和解决方案。

坑 1:异步模型差异——yield Request 还是 await?严重程度:⭐⭐⭐⭐⭐ 影响范围:所有 SpiderScrapy 的 Spider 用 yield 吐出 Request 和 Item,框架负责调度。迁移到 Crawlo 后,第一个直觉写法是这样的:

代码语言:txt
复制
# Scrapy 写法
class MySpider(scrapy.Spider):
    name = "example"

    def start_requests(self):
        urls = ["https://example.com/page/1", "https://example.com/page/2"]
        for url in urls:
            yield scrapy.Request(url, callback=self.parse)

    def parse(self, response):
        for item in response.css(".item"):
            yield {"title": item.css("h2::text").get()}

直接搬到 Crawlo,写法上看起来差不多,但 Crawlo 的 parse 方法是 async 的:

代码语言:txt
复制
# Crawlo 写法(第一版,有坑)
class MySpider(CrawloSpider):
    name = "example"

    async def start_requests(self):
        urls = ["https://example.com/page/1", "https://example.com/page/2"]
        for url in urls:
            yield Request(url, callback=self.parse)  # ⚠️ 这里有问题

    async def parse(self, response):
        for item in response.css(".item"):
            yield {"title": item.css("h2::text").get()}

看起来没问题对吧?运行后直接报错:

根因:Crawlo 的 start_requests 是 async 方法,里面 yield 会变成 async generator。但 Crawlo 的调度器在 2.0 版本之前 不支持 async generator 作为 start_requests 的返回值,只支持返回 list[Request] 或普通 generator。解决方案:改用 return 返回列表,或者用普通 generator(不加 async):

代码语言:txt
复制
# Crawlo 写法(第一版,有坑)
class MySpider(CrawloSpider):
    name = "example"

    async def start_requests(self):
        urls = ["https://example.com/page/1", "https://example.com/page/2"]
        for url in urls:
            yield Request(url, callback=self.parse)  # ⚠️ 这里有问题

    async def parse(self, response):
        for item in response.css(".item"):
            yield {"title": item.css("h2::text").get()}

教训:Crawlo 的 async 方法设计是「parse 内部可以 await,但 start_requests 和 parse 的 yield 语义不完全一致」。迁移时必须逐个检查每个方法的 async 标记。

坑 2:中间件机制不兼容——process_request 签名变了严重程度:⭐⭐⭐⭐⭐ 影响范围:33 个中间件这是工作量最大的一个坑。Scrapy 和 Crawlo 的中间件 API 长得像但 签名不一样。Scrapy 的下载器中间件:

代码语言:txt
复制
# Scrapy 中间件
class CustomHeaderMiddleware:
    def process_request(self, request, spider):
        request.headers["X-Custom"] = "my-value"
        return None  # 返回 None 继续流程

    def process_response(self, request, response, spider):
        return response

Crawlo 的中间件:

代码语言:txt
复制
# Crawlo 中间件
class CustomHeaderMiddleware:
    async def process_request(self, request, spider):
        request.headers["X-Custom"] = "my-value"
        # 不返回值,或返回 None

    async def process_response(self, request, response, spider):
        return response

差异点:

对比项

Scrapy

Crawlo

方法签名

同步

async

process_request 返回值

返回 None/Request/Response

返回 None/Request/Response(一致)

异常处理

通过 process_exception

通过 process_exception(一致)

中间件执行顺序

DOWNLOADER_MIDDLEWARES 数字越小越先执行 request

数字越大越先执行 request

最后那行加粗的就是大坑。我们有一个代理中间件 priority=100,UA 中间件 priority=200。在 Scrapy 里,UA 先执行再代理;迁到 Crawlo 后,执行顺序反了——代理先执行,导致代理 IP 已经写入 request 后,UA 中间件又改了 headers 触发了风控。解决方案:写了个全局的 priority 映射函数,批量反转:

代码语言:txt
复制
# 迁移辅助脚本:反转中间件优先级
def convert_middleware_priority(scrapy_settings: dict) -> dict:
    """将 Scrapy 中间件优先级转换为 Crawlo 优先级"""
    crawlo_settings = {}
    for key, value in scrapy_settings.items():
        if isinstance(value, dict):
            crawlo_settings[key] = {
                k: -v if isinstance(v, int) else v  # 取负值反转顺序
                for k, v in value.items()
            }
        else:
            crawlo_settings[key] = value
    return crawlo_settings

教训:中间件顺序在框架文档里一定要逐行确认,不要假设「两个框架差不多」。

坑 3:Item Pipeline 的 open_spider 时机不同严重程度:⭐⭐⭐⭐ 影响范围:23 个 Pipeline我们的数据写入 Pipeline 里,open_spider 负责初始化数据库连接池,close_spider 负责关闭。Scrapy 里这个生命周期很明确:

代码语言:txt
复制
# Scrapy Pipeline
class MySQLPipeline:
    def open_spider(self, spider):
        self.pool = aiomysql.create_pool(host="127.0.0.1", ...)

    def process_item(self, item, spider):
        # 写入逻辑
        return item

    def close_spider(self, spider):
        self.pool.close()

迁到 Crawlo 后,open_spider 也支持,但 时机不一样:Scrapy:open_spider 在 Spider 启动前同步调用,此时引擎已就绪Crawlo:open_spider 在 async 上下文中调用,但 在第一个 Request 发出之前,而非引擎完全就绪之后这个差异导致我们一个依赖「引擎状态」的初始化逻辑(从调度器读取待爬数量做容量预估)直接拿到空值。解决方案:把依赖引擎状态的逻辑挪到 start_requests 内部:

代码语言:txt
复制
# Crawlo Pipeline(修正版)
class MySQLPipeline:
    async def open_spider(self, spider):
        # 只做不依赖引擎状态的初始化
        self.pool = await aiomysql.create_pool(host="127.0.0.1", ...)

    async def process_item(self, item, spider):
        async with self.pool.acquire() as conn:
            await conn.execute("INSERT INTO ...", ...)
        return item

    async def close_spider(self, spider):
        self.pool.close()
        await self.pool.wait_closed()

另一个隐蔽问题:process_item 在 Scrapy 里是同步的,在 Crawlo 里是 async 的。我们用 aiomysql 替换了原来的 pymysql,整个 Pipeline 全部重写。教训:Pipeline 迁移不仅要改签名,还要把同步数据库驱动全部换成异步驱动(pymysql → aiomysql、psycopg2 → asyncpg)。这个工作量占迁移总量的 30%。

坑 4:请求去重策略变化导致重复采集严重程度:⭐⭐⭐⭐ 影响范围:所有项目迁移后第一天跑全量,数据量比 Scrapy 多了 15%。排查后发现是 请求去重逻辑不一样。Scrapy 默认用 RFPDupeFilter,基于 URL 的 SHA1 指纹去重。Crawlo 也有去重,但默认策略是 URL + method 的组合指纹,而且 query parameter 的排序方式不同。举个例子:

实际上 Crawlo 内部用 urllib.parse.parse_qs 解析后做排序,但有个边界 case:空值参数。?a=&b=2 和 ?b=2 在 parse_qs 后结果不同(一个有 {'a': ['']},一个没有),导致同一个页面被采集两次。解决方案:自定义去重过滤器,统一 URL 规范化:

代码语言:txt
复制
# Crawlo 自定义去重过滤器
from crawlo.dupefilter import BaseDupeFilter
from urllib.parse import urlparse, parse_qsl, urlencode, urlunparse

class NormalizedURLDupeFilter(BaseDupeFilter):
    """规范化 URL 后去重,处理参数排序和空值问题"""

    def __init__(self):
        self.seen = set()

    def request_fingerprint(self, request):
        url = request.url
        parsed = urlparse(url)
        # 解析 query 参数,过滤空值,排序
        params = [(k, v) for k, v in parse_qsl(parsed.query) if v]
        params.sort()
        normalized_query = urlencode(params)
        normalized_url = urlunparse((
            parsed.scheme, parsed.netloc, parsed.path,
            parsed.params, normalized_query, ""  # 丢弃 fragment
        ))
        return f"{request.method}:{normalized_url}"

    def is_duplicate(self, request):
        fp = self.request_fingerprint(request)
        if fp in self.seen:
            return True
        self.seen.add(fp)
        return False

教训:迁移后一定要做数据量对比。如果发现数据量异常波动,第一个查去重逻辑。

坑 5:分布式调度的 Redis 数据结构不兼容严重程度:⭐⭐⭐⭐ 影响范围:6 个分布式项目6 个需要分布式调度的项目,Scrapy 用的是 scrapy-redis,Crawlo 有自己的分布式调度器。问题出在 Redis 里的队列数据结构不兼容。scrapy-redis 用 Redis List 存请求队列,Crawlo 用 Redis Sorted Set(支持优先级)。迁移时 Redis 里还积压着 Scrapy 时代未消费的请求,Crawlo 读不了。更严重的是:两个框架的 Request 序列化格式不同。Scrapy 用 pickle,Crawlo 用 JSON + 自定义编码器。积压的请求直接丢失。解决方案:写了一个迁移脚本,把 Scrapy 积压的请求转换为 Crawlo 格式:

代码语言:txt
复制
# 请求队列迁移脚本
import pickle
import json
import redis

r = redis.Redis(host="127.0.0.1", port=6379, db=0)

def migrate_scrapy_queue_to_crawlo(scrapy_queue: str, crawlo_queue: str):
    """将 scrapy-redis 的 List 队列迁移到 Crawlo 的 Sorted Set"""
    count = 0
    while True:
        # 从 Scrapy 队列右侧弹出
        raw = r.rpop(scrapy_queue)
        if raw is None:
            break

        try:
            # Scrapy 的 Request 是 pickle 序列化的
            scrapy_request = pickle.loads(raw)
            # 转换为 Crawlo 格式的 JSON
            crawlo_request = {
                "url": scrapy_request.url,
                "method": scrapy_request.method,
                "headers": dict(scrapy_request.headers),
                "body": scrapy_request.body.decode() if scrapy_request.body else "",
                "meta": scrapy_request.meta,
                "priority": getattr(scrapy_request, "priority", 0),
            }
            # 写入 Crawlo 的 Sorted Set(score = priority)
            r.zadd(crawlo_queue, {json.dumps(crawlo_request): crawlo_request["priority"]})
            count += 1
        except Exception as e:
            print(f"迁移失败: {e}, URL: {scrapy_request.url if 'scrapy_request' in dir() else 'unknown'}")

    print(f"迁移完成,共 {count} 个请求")

教训:分布式迁移前,先把旧队列消费干净再切换。如果做不到,必须准备序列化迁移脚本。

坑 6:response.css() 选择器行为不一致严重程度:⭐⭐⭐ 影响范围:所有 SpiderCrawlo 的 Response 对象内置了 css() 和 xpath() 方法,API 和 Scrapy 几乎一样。但有一个细微差异:文本节点的提取。

代码语言:txt
复制
# HTML: <div class="title">  Hello World  </div>
# Scrapy
response.css(".title::text").get()  # 返回 "  Hello World  "(保留空格)
response.css(".title::text").getall()  # 返回 ["  Hello World  "]

# Crawlo
response.css(".title::text").get()  # 返回 "Hello World"(自动 strip!)
response.css(".title::text").getall()  # 返回 ["Hello World"]

Crawlo 默认对文本节点做了 strip() 处理。这个行为在大部分场景下没问题,但我们的房产数据项目里,有些字段需要保留前导空格(用来区分标题层级)。而且 ::text 在嵌套标签场景下的行为也有差异:

这个差异太隐蔽了,导致新闻聚合项目里正文提取全部出错,段落之间没有分隔。解决方案:用 xpath() 替代 css(),并显式控制文本提取行为:

同时写了个兼容层函数:

教训:选择器 API 「看起来一样」不代表「行为一样」。迁移后必须对每个字段做抽样校验。

坑 7:代理中间件 + 重试逻辑的冲突严重程度:⭐⭐⭐⭐ 影响范围:5 个需要代理的项目我们的 Scrapy 项目里,代理切换和重试是两个独立中间件。代理中间件给每个 Request 加代理 IP,重试中间件在遇到 403/503 时重新换一个代理重试。迁到 Crawlo 后,代理中间件迁移顺利,但重试时 代理没有切换——还是用同一个被封的 IP 重试,导致连续 403。根因是 Crawlo 的重试机制:默认重试是在 下载器层面 做的,重试时不会重新走 process_request 中间件链。也就是说,重试的 Request 绕过了代理中间件。

代码语言:txt
复制
mport httpx
from crawlo.exceptions import IgnoreRequest


class YiniuProxyPool:
    """亿牛云代理池:封装隧道代理与动态提取两种模式。

    亿牛云(ip3366.cn)的隧道代理按请求自动换出口 IP,
    特别适合高并发下的换源重试场景。

    Args:
        username: 亿牛云账号用户名
        password: 亿牛云授权密码
        tunnel_host: 隧道代理域名,默认 tunnel.ip3366.cn
        tunnel_port: 隧道代理端口,默认 39080
        dynamic_api: 动态短效代理提取接口
    """

    def __init__(
        self,
        username: str,
        password: str,
        tunnel_host: str = "tunnel.ip3366.cn",
        tunnel_port: int = 39080,
        dynamic_api: str = "http://http.tiqu.ip3366.cn:30376/api/getip",
    ) -> None:
        self._auth = f"{username}:{password}"
        self._tunnel = f"http://{self._auth}@{tunnel_host}:{tunnel_port}"
        self._dynamic_api = dynamic_api

    def get_tunnel_proxy(self) -> str:
        """隧道代理入口:亿牛云每次请求自动轮换出口 IP,重试即换源。"""
        return self._tunnel

    async def get_fresh_proxy(self) -> str:
        """动态代理:实时从亿牛云 API 提取一个最新可用 IP。"""
        async with httpx.AsyncClient(timeout=5) as client:
            resp = await client.get(
                self._dynamic_api,
                params={"username": self._auth.split(":")[0],
                        "password": self._auth.split(":")[1],
                        "count": 1, "type": 1},
            )
            ip, port = resp.text.strip().split(":")
            return f"http://{self._auth}@{ip}:{port}"


class SmartRetryMiddleware:
    """自定义重试中间件:重试时切换亿牛云代理。

    隧道代理每次请求自动换出口 IP,因此重试时直接复用隧道入口
    即可实现「换 IP 重试」,无需额外维护 IP 池状态。

    Args:
        proxy_pool: YiniuProxyPool 实例
    """

    RETRY_CODES = {403, 429, 502, 503, 504}
    MAX_RETRY = 3

    def __init__(self, proxy_pool: YiniuProxyPool) -> None:
        self.proxy_pool = proxy_pool

    async def process_response(self, request, response, spider):
        if response.status not in self.RETRY_CODES:
            return response

        retry_count = request.meta.get("retry_count", 0)
        if retry_count >= self.MAX_RETRY:
            spider.logger.warning(f"超过最大重试次数: {request.url}")
            raise IgnoreRequest(request)

        # 构造新的重试请求,并直接换上亿牛云隧道代理(后端自动换 IP)
        retry_request = request.copy()
        retry_request.meta["retry_count"] = retry_count + 1
        retry_request.meta["proxy"] = self.proxy_pool.get_tunnel_proxy()
        retry_request.dont_filter = True  # 避免被去重过滤

        spider.logger.info(
            f"重试 {retry_count + 1}/{self.MAX_RETRY}: {request.url} "
            f"(status={response.status}, 已切换亿牛云代理)"
        )
        return retry_request

解决方案:禁用 Crawlo 内置重试,自己写一个带代理切换的重试中间件:

然后在代理中间件里检查 need_new_proxy 标记:

代码语言:txt
复制
class ProxyMiddleware:
    async def process_request(self, request, spider):
        if request.meta.get("need_new_proxy"):
            # 强制获取新代理,不用缓存
            proxy = await self.proxy_pool.get_fresh()
        else:
            proxy = await self.proxy_pool.get()
        request.meta["proxy"] = proxy

教训:重试机制在框架内部的位置决定了它能不能和中间件配合。迁移时必须搞清楚重试发生在「中间件链之前」还是「之后」。

坑 8:日志格式和监控指标的全面不兼容严重程度:⭐⭐⭐ 影响范围:运维体系Scrapy 的 scrapy.stats 会吐出一堆统计指标(response_received_count、item_scraped_count、elapsed_time 等),我们的 Grafana 看板全靠这些指标做告警。Crawlo 也有 stats,但 指标名称和粒度完全不同:

Scrapy 指标

Crawlo 对应

差异说明

response_received_count

crawler_response_count

名称不同

item_scraped_count

crawler_item_count

名称不同

elapsed_time_seconds

crawler_duration_seconds

名称不同,且 Scrapy 是 int,Crawlo 是 float

httperror/response_ignored_count

❌ 无直接对应

Crawlo 不单独统计 HTTP 错误忽略数

retry/count

crawler_retry_total

Crawlo 用 Prometheus 命名风格

解决方案:写了一个 stats 适配层,把 Crawlo 的指标映射到 Scrapy 的命名:

代码语言:txt
复制
# stats 适配层
class ScrapyCompatibleStats:
    """将 Crawlo stats 映射为 Scrapy stats 命名,兼容现有监控"""

    MAPPING = {
        "crawler_response_count": "response_received_count",
        "crawler_item_count": "item_scraped_count",
        "crawler_duration_seconds": "elapsed_time_seconds",
        "crawler_retry_total": "retry/count",
        "crawler_error_count": "log_count/ERROR",
    }

    def __init__(self, crawlo_stats):
        self._stats = crawlo_stats

    def get_stats(self) -> dict:
        raw = self._stats.get_stats()
        mapped = {}
        for key, value in raw.items():
            scrakey = self.MAPPING.get(key, key)
            mapped[scrakey] = int(value) if isinstance(value, float) and scrakey.endswith("_count") else value
        return mapped

此外,Scrapy 的日志格式是 [scrapy.core.engine] INFO: Spider opened,Crawlo 是 crawler.engine - INFO - Spider opened。我们用 Logstash 的 grok 规则做日志解析,格式变了导致解析全部失败。解决方案:在 Crawlo 的日志配置里自定义格式,对齐 Scrapy:

代码语言:txt
复制
# logging 配置
LOGGING = {
    "version": 1,
    "formatters": {
        "scrapy_compatible": {
            "format": "[%(name)s] %(levelname)s: %(message)s",
        }
    },
    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
            "formatter": "scrapy_compatible",
        }
    },
    "root": {
        "handlers": ["console"],
        "level": "INFO",
    }
}

教训:框架迁移不只是代码迁移,日志、监控、告警这些「非功能性」配套一定要同步迁移,否则线上可观测性会断档。

四、迁移后的性能对比踩完所有坑后,跑了两周的对比数据:

指标

Scrapy(迁移前)

Crawlo(迁移后)

变化

平均 QPS

320

410

+28%

P99 响应延迟

2.1s

1.6s

-24%

内存占用(单实例)

480MB

350MB

-27%

CPU 使用率

65%

52%

-20%

爬取成功率

94.2%

95.8%

+1.6pp

Redis 内存占用

1.2GB

0.8GB

-33%

性能提升主要来自:asyncio 比 Twisted 的事件循环开销更低,尤其在大量 IO 并发场景Crawlo 的连接池管理更高效,默认用 httpx 的连接复用,减少了 TCP 握手Sorted Set 调度比 List 调度更省内存,因为去重和优先级合并在一个数据结构里成功率提升 1.6pp 主要是坑 7 的代理重试逻辑优化带来的——换 IP 重试比同 IP 重试的成功率高太多。五、总结与建议迁移决策建议适合迁移的场景:团队技术栈已全面转向 asyncio需要 Playwright 做动态页面渲染(Crawlo 原生支持,Scrapy 要装 scrapy-playwright 插件)对分布式调度有自定义需求新项目为主,历史包袱小不建议迁移的场景:Scrapy 项目运行稳定、性能满足需求 → 没必要折腾大量依赖 Scrapy 生态插件(scrapy-splash、scrapy-crawl-once 等)→ Crawlo 生态还不够丰富团队对 asyncio 不熟悉 → 学习成本不低迁移实操建议先迁最小项目:选一个 Spider 少、Pipeline 简单的项目验证全流程,别上来就搞最大的。写适配层:中间件、Pipeline、stats 这三块的差异最大,封装适配层能省 50% 工作量。并行运行 + 数据对比:新旧系统同时跑,每天对比数据量和数据质量,至少跑两周。选择低峰期切换:线上切换选在流量低谷,出问题能快速回滚。监控先行:先把监控指标和日志格式对齐,再切流量。否则出了问题你都不知道。迁移时间线参考我们的 12 个项目用了两周(10 个工作日):

阶段

天数

工作内容

调研 + 试迁移

2 天

选定框架,迁移 1 个最小项目,踩坑

适配层开发

2 天

封装中间件/Pipeline/stats 适配层

批量迁移

4 天

逐个迁移剩余 11 个项目

并行验证

1 天

新旧系统并行跑,对比数据

线上切换

1 天

逐项目切流量,处理线上问题

如果只迁 1-2 个项目,3 天就够了。12 个项目的规模,两周是个合理预期。

最后说句实话:迁移的收益是实打实的——QPS 提升 28%,内存降 27%,异步代码可维护性大幅提升。但过程比预想的痛苦,8 个坑里有 4 个是文档没写的。如果你也在考虑同样的迁移,希望这篇文章能帮你少踩几个。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档