拆解 Scrapy 的执行引擎,一个 16 年没换骨架的爬虫框架
- Scrapy 2010 年立项,到 2026 年 7 月达 62990 Star、11773 Fork,十六年仍在按月出 release
- 大多数用户只用到表层:scrapy startproject、写 parse、yield item,不了解底层引擎架构
- 核心骨架十六年未变:Twisted 异步驱动、Downloader 中间件、Spider 中间件、Item Pipeline 四层架构
- 长寿秘诀:稳定的核心抽象、灵活的扩展机制、活跃的社区维护、向后兼容性承诺
2026 年 7 月 7 日,Scrapy 发了 2.17.0。一个 2010 年立项、到今天 62990 Star、11773 Fork 的 Python 项目,十六年了还在按月出 release。
你大概率用过它,或者至少在简历上写过它。但说实话,大多数人用 Scrapy 就是 scrapy startproject、写个 parse、yield item,然后等着数据落盘。至于它内部那套引擎到底怎么转的,为什么这么设计,基本没人深究。
我最近因为一个反爬的问题把源码翻了一遍,发现这个老框架的骨架其实比想象中精巧,也比想象中固执。有些设计放到今天看是历史包袱,有些设计则依然是同类工具抄不来的护城河。
这篇文章不教你怎么用 Scrapy,文档写得比我好。我想拆的是它的执行引擎,看一个十六岁的爬虫框架靠什么撑到现在。
引擎的骨架,三段式事件循环
Scrapy 的核心在 scrapy/core/engine.py 的 ExecutionEngine 类。一句话概括它干的事,就是把「调度器吐请求、下载器拿响应、爬虫解析响应、再吐新请求」这件事跑成一个不停转的循环。
真正驱动这个循环的是 _start_scheduled_request 方法。你打开 engine.py 往中间翻,会看到这么一段逻辑。
def _start_scheduled_request(self) -> bool:
request = self._slot.scheduler.next_request()
if request is None:
self.signals.send_catch_log(signals.scheduler_empty)
return False
d: Deferred[Response | Request] = self._download(request)
d.addBoth(self._handle_downloader_output, request)
...
d2 = d.addBoth(partial(self._remove_request, request=request))
d2.addBoth(lambda _: slot.nextcall.schedule())
return True
这里有个细节值得停下来看。请求从调度器拿出来后,立刻被塞进下载器,下载完成回调 _handle_downloader_output,处理完再触发 nextcall.schedule() 把下一轮调度排上。整个链路是用 Twisted 的 Deferred 串起来的,不是同步阻塞,也不是简单的 asyncio.gather。
为什么用 Deferred 而不是 asyncio?说真的,这是 Scrapy 被吐槽最多的地方,也是它最没法改的地方。Twisted 是 Scrapy 立项那年的 Python 异步事实标准,asyncio 要到 3.4 才有,3.7 才成熟。Scrapy 的整个下载器、TLS 处理、HTTP/2 协议层全建在 Twisted 上,迁移成本高到没人敢动。
但你能看到它在妥协。2.14 版本开始,ExecutionEngine 有了 start_async / stop_async 这些原生协程方法,老的同步版 start 被标了 ScrapyDeprecationWarning。源码里到处是 deferred_from_coro 和 maybe_deferred_to_future 这种「两种世界互相翻译」的胶水代码。这是个正在迁移、又没迁完的中间态,你用的时候最好心里有数。
引擎里还有个不起眼但关键的东西,叫 _Slot,还有个常量 _SLOT_HEARTBEAT_INTERVAL = 5.0。即使调度器说「我现在没请求给你」,引擎也会每 5 秒起一次心跳,再尝试拉请求。这是为了应对那种「调度器其实有请求,只是这一轮没给出来」的场景,比如延迟队列。这种细节,文档里不会告诉你。
调度器的双队列,内存优先于磁盘
调度器在 scrapy/core/scheduler.py,默认实现就是 Scheduler 类。它最值得说的设计是「双优先队列」。
打开 enqueue_request 方法,你会看到请求先尝试进磁盘队列,失败了才进内存队列。
def enqueue_request(self, request: Request) -> bool:
if not request.dont_filter and self.df.request_seen(request):
self.df.log(request, self.spider)
return False
dqok = self._dqpush(request)
if dqok:
self.stats.inc_value("scheduler/enqueued/disk")
else:
self._mqpush(request)
self.stats.inc_value("scheduler/enqueued/memory")
self.stats.inc_value("scheduler/enqueued")
return True
而取请求的 next_request 是反过来的,先从内存队列 pop,内存空了才去磁盘队列。
def next_request(self) -> Request | None:
request: Request | None = self.mqs.pop()
if request is not None:
self.stats.inc_value("scheduler/dequeued/memory")
else:
request = self._dqpop()
if request is not None:
self.stats.inc_value("scheduler/dequeued/disk")
...
return request
为什么要分两套?因为断点续爬。当你设了 JOBDIR,Scrapy 会把请求序列化到磁盘上的 requests.queue/ 目录,还写一个 active.json 记录优先级状态。爬到一半 Ctrl+C,下次重启能从断点接上。但磁盘 I/O 慢,所以热数据走内存,冷数据落盘,内存队列永远优先消费。这是个很务实的工程取舍。
不过这个设计有个代价,就是请求必须能被序列化。一旦你的 Request 对象带了不能 pickle 的东西(比如一个 lambda 回调),_dqpush 会抛 ValueError,请求就被悄悄塞回内存队列,还顺手记一笔 scheduler/unserializable 的统计。你以为断点续爬保住了所有请求,其实那些不可序列化的根本没进盘。这个坑,SCHEDULER_DEBUG 设成 True 才能看到日志。
说真的,翻 issue 区你会发现,调度器是 Scrapy 用户怨念最深的地方。#5015 这个 PR,要做的是「per-request delay」,就是让单个请求能指定「N 秒后再处理」,听起来天经地义的需求。这个功能最早可以追溯到 2014 年的 #802,十年了,PR 挂在那,一直没合并。你想想看,一个十年没解决的核心调度需求,要么是架构上确实难加,要么是维护者优先级不在那儿。我看下来两者都有,但后者成分更多一点。
请求指纹,为什么默认不认 header
去重是爬虫的命门。你 yield 出一百个看起来不同的 URL,可能其实指向同一个资源,不去重就重复请求,浪费带宽还容易被封。
Scrapy 的去重在 scrapy/dupefilters.py 的 RFPDupeFilter,核心是调 scrapy/utils/request.py 里的 fingerprint 函数。这个函数怎么算指纹,直接决定了哪些请求会被当成重复。
fingerprint_data = {
"method": to_unicode(request.method),
"url": url, # canonicalize_url 之后
"body": (request.body or b"").hex(),
"headers": headers, # 默认空
}
fingerprint_json = json.dumps(fingerprint_data, sort_keys=True)
cache[cache_key] = hashlib.sha1(fingerprint_json.encode()).digest()
注意 headers 这个字段,默认是空的。也就是说,默认情况下,两个请求只要 method、规范化后的 url、body 一样,就被判为重复,header 完全不看。
这是故意的。注释里解释得很清楚,很多网站用 cookie 存 session id,如果指纹把 header 算进去,同一个页面带着不同 session cookie 请求,就会被当成两个不同请求,去重就失效了。所以默认排除 header,只认 url + method + body。
但这套逻辑有个反直觉的后果。如果你在 header 里塞了分页参数、签名 token、或者鉴权信息,默认去重会把它们全忽略,导致你以为的「不同请求」被当成重复请求丢掉。解决办法是显式传 include_headers,告诉指纹器哪些 header 要算进去。这个参数,不看源码你大概率不知道。
还有个细节,指纹用的是 SHA1。源码里特意标了 # noqa: S324,意思是「我知道 SHA1 有安全问题,但这里不是为了密码学安全,只是为了去重,别报警」。这是合理的,指纹不需要抗碰撞,需要的是稳定和快。但你要是做安全审计看到这行,别急着报漏洞。
默认配置里的坑,一个 403 的故事
讲个真事。issue #4951 里有个用户,爬 Craigslist 的租房列表,一直吃 403。他试了加 User-Agent、加代理、加 header 轮换,全没用。最后用 requests 库裸奔一个 GET,同样的 IP、同样的 header,反而 200。
他较真地起了个 nc -l 8080 抓两边的原始请求,对比下来发现,Scrapy 比 requests 多发了一个 Accept-Language: en。
这个 en 哪来的?翻 scrapy/settings/default_settings.py,第 265 行,白纸黑字写着。
DEFAULT_REQUEST_HEADERS = {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en",
}
Scrapy 默认会给每个请求加上 Accept-Language: en。这个值,一个正经浏览器不会只发 en,Craigslist 这种有反爬的站,看到这个特征就知道你不是浏览器,直接 403。
这个默认值存在了多少年?我没去考证 commit 历史,但至少 issue 是 2021 年提的,到现在 2.17.0 还是这个默认。你说这是 bug 还是 feature?我觉得是「历史遗留没敢动」,动了可能影响一堆老爬虫的兼容性。但对新用户来说,这就是个隐性坑,你不翻 default_settings.py 根不知道默认请求头长这样。
类似的默认还有不少。ROBOTSTXT_OBEY 早期默认是 False,后来改成 True,这个改得对,但改得晚。DOWNLOAD_TIMEOUT = 180,也就是 3 分钟,一个请求卡 3 分钟才超时,对于现代反爬场景这个值偏大,你写爬虫不调小的话,一批慢请求能把并发槽全占满。
AutoThrottle,一个被低估的自适应限速
说完坑,说个我觉得设计得漂亮的东西。
很多人用 Scrapy 限速就是设个 DOWNLOAD_DELAY,写死一个固定间隔。但固定间隔有个问题,你不知道目标站能承受多少,设大了慢,设小了被封。
Scrapy 有个 AutoThrottle 扩展,在 scrapy/extensions/throttle.py,默认不开。它的思路特别简单,看每个请求的 download_latency(就是实际下载耗时),然后动态调整下载延迟。
核心逻辑在 _response_downloaded 和 _adjust_delay 里。每个请求下载完,它读 request.meta.get("download_latency"),拿这个延迟去推算目标站的负载情况。响应慢,说明站点压力大或者你被限流了,就把 slot.delay 调大;响应快,就试探着调小,逼近 AUTOTHROTTLE_TARGET_CONCURRENCY(默认 1.0)。
坦白讲,这个算法不复杂,甚至有点粗糙。它假设「响应延迟 = 站点压力」,这个假设在很多场景下不成立,比如 CDN 缓存命中快但源站其实没压力,或者你的延迟是网络抖动不是站点限流。但作为一种「不需要你懂目标站、自动找到大概合理速率」的兜底方案,它够用了。
我的建议是,新项目上来先开 AUTOTHROTTLE_ENABLED = True,把 AUTOTHROTTLE_TARGET_CONCURRENCY 调到 2 到 3,比死磕 DOWNLOAD_DELAY 省心。真要精细控制,再换固定 delay 或者自己写中间件。
这个框架真正的价值,是声明式流水线
翻完源码我想明白一件事。Scrapy 能活十六年,不是因为它的下载器多强(现在 requests + asyncio 也能下),也不是因为它的解析多好(BeautifulSoup、parsel 一抓一把),而是它从第一天就把「爬虫」定义成了一条可插拔的数据流水线。
你看它的架构,调度器、下载器、下载中间件、爬虫中间件、Item Pipeline、扩展,全是独立组件,全靠 settings.py 里的类路径字符串装配。你要换调度器,改一行 SCHEDULER;要换下载器,改 DOWNLOADER;要加一个请求落地的清洗逻辑,写个 Pipeline 类塞进 ITEM_PIPELINES。Spider 本身只负责「声明我要爬什么、怎么解析」,完全不关心请求怎么调度、怎么下载、怎么存。
这个思路今天看稀松平常,但在 2010 年,大多数爬虫还是「一个脚本从上跑到下」的时候,Scrapy 把爬虫拆成声明式流水线,是相当超前的工程判断。
我觉得这个模式可以提炼一下,叫**「声明意图,委托执行」**。你的业务代码只说「我要这个 URL 的数据,解析完是这个结构」,至于请求怎么排队、怎么去重、怎么限速、怎么重试、怎么落盘,全交给框架的中间件链。这种分工的好处是,你换掉任何一环(比如把 HTTP 下载换成无头浏览器渲染),Spider 代码一行不用改。
当然,这套设计也有代价。就是前面说的,你想做一些「不那么标准」的事,比如 per-request 延迟、动态并发调整,会比在纯脚本里难,因为你得顺着它的组件接口走,不能随便 hack。Scrapy 的固执,既是它长寿的原因,也是它用起来偶尔别扭的原因。
如果你要做的是结构化数据的大规模采集,目标明确、流程规整,Scrapy 依然是首选,它的工程化程度、断点续爬、中间件生态,没有同类能打。但如果你要做的是高度定制化的、强反爬的、需要浏览器渲染的场景,与其跟 Scrapy 的框架较劲,不如直接上 Playwright 加几个手写脚本,反而更灵活。
框架不是越强大越好,是越合用越好。Scrapy 合用的场景,十六年没怎么变过。
评论互动