问题定位:不是框架选型错,是执行模型不匹配
我们的抓取服务在 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 是网络层失败,应当短时指数退避立即重试;429 和 503 是服务端限流,退避更长;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_live 的 RedisBloomFilter 即可,本地调试可用 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 弹性伸缩是后续迭代项。