Scrapy内部机制解析:从异步事件循环到代理池与并发调优
前段时间同事来找我,说他用 requests 写的多线程采集脚本,开着 32 个线程去抓一个电商站点,跑不到十分钟,本机内存先爆了;换成 Scrapy 重新实现同样的采集逻辑之后,并发开到 64,跑了一整天都稳稳当当,而且被封 IP 的次数反而更少了。他问我 Scrapy 是不是用了什么黑科技,我说不是黑科技,是它从设计上就没打算让你用"线程等待"去换并发。
很多人把 Scrapy 当成"一个更好用的 requests 封装",这其实低估了它。Scrapy 的快,本质上是异步非阻塞的事件循环在起作用;Scrapy 的稳,则来自它那套调度器、去重指纹、中间件链路和限速机制。这篇文章我想从源码角度把这些内部机制拆开讲一遍,从 Twisted 事件循环说到代理池怎么接、并发参数怎么调,再聊聊集成 Playwright 处理动态 iframe 时的坑。适合已经会用 Scrapy 写简单爬虫、但想深入理解框架运行原理的读者,也适合那些用多线程脚本踩过坑、想搞明白"为什么 Scrapy 更稳"的人。
1. 从"多线程卡顿"到"异步不阻塞":Scrapy 的引擎到底在忙什么
1.1 为什么你的 requests 多线程脚本经常卡死
先用一个实际场景把问题说清楚。
requests 库是同步阻塞模型。你调用 requests.get() 的时候,当前线程会一直等,直到 DNS 解析完成、TCP 连接建立、响应头接收完毕、响应体全部读到内存,这个函数才返回。在这整个等待过程中,线程不能干任何别的事。
你用 ThreadPoolExecutor 开 20 个线程,表面看是"并发"了,但实际上 20 个线程几乎都处于阻塞等待状态。目标网站响应越慢,线程等待的时间越长,而你每开一个线程,操作系统就要给它分配线程栈空间和上下文切换成本。线程多了之后,GIL 的竞争、上下文切换的损耗、连接数失控,都会让性能和稳定性直线下降。
我同事那个脚本就是这样:每抓到一批 URL 就丢进线程池,线程池任务队列越积越多,内存里积压的全是 HTTP 响应对象。目标站稍微一慢,32 个线程全在 read() 阶段死等,看起来就像"卡死了"。
反过来看 Scrapy,它的核心并不是"多线程爬虫",而是"异步事件循环爬虫"。它用单线程维护成百上千个网络连接,线程数不会随并发数增加而膨胀,内存开销自然低得多。
1.2 Twisted 事件循环:单线程里如何跑成百上千个请求
Scrapy 底层建立在 Twisted 之上,Twisted 的核心是 Reactor 事件循环。
可以把事件循环理解成一个传菜员:厨房里有一堆菜在做,传菜员不可能每个灶台旁边站一个人干等着。他手里有一份清单,记录着每个灶台的菜做到哪一步了。哪个灶台的菜做好了,锅响了,他就过去端;没好的,他就继续巡场,或者去处理别的事情。单线程,但能同时照看几十口锅。
对应到网络模型里,"锅响"就是 socket 上的数据到达事件。Twisted 的 Reactor 会监听所有注册过的文件描述符,一旦某个 socket 有数据可读,就触发对应的回调函数。没有数据到达的时候,线程就在事件循环里空转或者休眠,不会卡在任何一个请求上。
所以 Scrapy 的并发,不是靠"开很多线程同时执行阻塞调用",而是靠"一个线程同时监听很多连接,谁有数据就处理谁"。这是一个本质差异。
这就是为什么 Scrapy 可以在配置项里把 CONCURRENT_REQUESTS 调到 100 甚至 200,而不会像多线程脚本那样把 CPU 和内存拖垮。真正限制并发上限的,变成了服务器文件描述符上限、目标站的负载承受能力,以及你自己机器的网络带宽。
1.3 Deferred 回调链:Scrapy 中异步任务的"交接棒"
事件循环解决了"不等数据"的问题,但还有一件事需要处理:请求发出去了,数据回来后,接下来要执行什么逻辑?
Twisted 给出了一个工具叫 Deferred。你可以把它当成一个"结果容器":它现在还没有值,但将来某个时刻会有。你可以在 Deferred 上挂多个回调,等数据真正到达时,这些回调会被依次执行。
这个机制很像接力棒。请求发出去,相当于把第一棒交给了网络;数据回来,回调链就开始跑:解析 HTML、提取数据、交给 Pipeline 入库、从提取到的链接中生成新请求、再丢给调度器。任何一环都不需要阻塞当前线程去等待另一环。
一个典型的 Scrapy 请求生命周期是这样的:
- Spider 生成初始 Request,交给 Engine。
- Engine 把 Request 发给 Scheduler 排队。
- Scheduler 出队,Engine 拿到请求后交给 Downloader。
- Downloader 发出真实网络请求,返回一个 Deferred。
- 数据到达,Downloader 触发 Deferred 回调链。
- Response 经过下载中间件,回到 Spider 的
parse()。 parse()里 yield 的 Item 进 Pipeline,yield 的 Request 再次进入调度器。
整个过程中,线程没有被任何一次网络 IO 卡住。遇到慢请求,它就先挂着,事件循环继续处理其他请求的数据到达。等你回过头来再看那个慢请求,数据可能已经到了,直接走回调即可。
提示:理解 Deferred 不一定需要读过 Twisted 源码,但一定要记住"Scrapy 是事件驱动的"这个心智模型。调试异步爬虫时,很多奇怪问题都是因为误用了同步思维,比如在回调里执行耗时的同步操作,那会真的阻塞整个事件循环。
2. 调度器与指纹去重:爬虫不乱、不重复的底层逻辑
2.1 请求队列的优先级从哪里来
Engine 把 Request 交给 Scheduler 之后,并不代表请求会立即被发送出去。Scheduler 内部维护着一个请求队列,Downloader 空闲时才会从队列里取请求。
这个队列不是简单的先进先出。Scrapy 的 __init__.py 里可以看到,调度器默认使用 heapq 实现内存队列,也就是说它是一个堆结构。每个 Request 对象上都有一个 priority 属性,默认是 0。数值越大,出队越早。
我自己用过一个场景:抓取一个分页网站,列表页的深度比较浅,详情页 URL 在列表页里才出现。默认情况下所有请求优先级相同,队列可能按某种顺序出队。如果我希望优先把列表页全部抓完,再去抓详情页,就可以在 yield 详情页 Request 时设置更高的 priority:
调度的另一个重要特性是队列的可持久化。配置项 SCHEDULER_DISK_QUEUE 和 SCHEDULER_MEMORY_QUEUE 分别控制磁盘队列和内存队列的实现。默认请求先放内存队列,但如果你开启了 JOBDIR,整个队列会被持久化到磁盘,方便断点续爬。
2.2 Request 指纹:你以为是 URL 去重,其实是整段请求的哈希
Scrapy 默认的去重器是 RFPDupeFilter,它判断一个请求是否重复,依靠的不是 URL 字符串,而是一个指纹。
指纹是怎么算的?看 scrapy/utils/request.py 里的 request_fingerprint() 函数,它会把请求的 method、url、body、请求头中几个关键字段组合起来,做 SHA1 哈希:
为什么要用指纹而不是直接拿 URL 判断?因为两个 Request 对象即使 URL 相同,也可能因为请求方法不同、POST body 不同、头部不同,而实际上是不同的请求。反过来,同一个 URL 带上不同的查询参数,也可能是不同的页面内容。
关于指纹去重,有一个非常容易踩的坑:一个请求如果因为代理失效、超时等原因需要重发,它默认也会经过去重器。这时候你会发现,重试请求会被 Filtered duplicate request 过滤掉,导致明明请求失败了却不重试。解决方法是给重试请求加上 dont_filter=True,这个选项告诉调度器:这个请求不用参与指纹去重。
另外一个常见场景是跟踪参数。比如同一个商品页面,URL 末尾带着不同的 utm_source、from=channel 参数,网页内容完全一样,但默认指纹不同,就会被当成不同 URL 重复抓取。这种情况可以在自定义去重器里做 URL 归一化,去掉这些无关参数再算指纹。
2.3 暂停与恢复:断点续爬背后的持久化机制
跑大规模采集的时候,最怕中途挂了要全部重来。Scrapy 提供的 JOBDIR 机制就是解决这个问题的。
启动爬虫时加上 -s JOBDIR=crawls/job1,Scrapy 会把调度器队列、去重指纹集合、请求的优先级状态等持久化到指定目录。进程停止后,下次用相同的 JOBDIR 启动,它会从上次暂停的位置继续。
这套机制的核心意义在于:去重指纹持久化之后,即使任务中断,已经抓过的 URL 也不会被重复抓取。对于动辄几百万 URL 的大型采集任务来说,这是"稳"的重要保证。
不过要注意,JOBDIR 不是万能的。如果你修改了 Spider 代码,比如改变了解析逻辑、回调函数名字变了,反序列化请求时可能会对不上。所以我一般只在不改代码的前提下用断点续爬,代码有改动时宁可重新建一个目录。
提示:磁盘上残留的 JOBDIR 目录里可能有大量序列化请求文件,占空间。每次跑完大型任务,记得清理不再需要的目录,别让它默默占用磁盘。
3. 代理池接入的正确姿势:下载中间件的"插队"艺术
3.1 中间件在请求链路中的确切位置
Scrapy 的中间件主要分三类:Spider 中间件、Downloader 中间件、Item Pipeline 中间件。代理池的接入,放在 Downloader 中间件里最合适。
看请求链路:Engine 把 Request 交给 Downloader 之前,请求会依次通过所有 Downloader 中间件的 process_request() 方法;响应返回时,又会逆序经过所有中间件的 process_response() 方法。
这意味着,Downloader 中间件是整个请求链路里的"咽喉要道":既可以统一修改请求头、设置代理、加 cookie,也可以在响应返回时统一检查状态码、判断代理是否被封。
为什么强调写在中间件里而不是 Spider 里?因为代理策略通常是全局的,你希望所有请求都走同一套逻辑,而不是在每个页面解析函数里手工设置 request.meta["proxy"]。解耦之后,换代理池供应商、改轮换策略,只需要动中间件这一个地方。
3.2 自建代理池的轮换与失效剔除
代理池的结构其实很简单:一个代理列表加上一套质量检测机制。
我常用的自建代理池包括三部分:
- 代理来源:付费代理 API 拉取、公开代理抓取、住宅代理供应商接口。
- 检测模块:定时对代理发起请求,验证连通性、响应速度、当前出口 IP 的匿名度。
- 调度模块:给每个代理维护连续失败次数,超过阈值就从可用池里摘除。
轮换策略上,最简单的做法是每次请求随机取一个代理。但有些目标站要求同一账号的登录 cookie 绑定 IP,这种情况下就不能完全随机,需要按会话保持 IP 黏性。
下面是一个简化版的代理池调度逻辑:
在实际项目里,代理池通常作为一个独立服务运行,提供获取代理、上报失败、拉黑代理的 HTTP 接口。Scrapy 中间件通过接口获取代理,而不是自己维护列表,这样多个爬虫进程可以共享同一个代理池。
3.3 换代理后反而被风控?说说指纹一致性的坑
代理池接入之后,很多人会踩一个意想不到的坑:明明 IP 在轮换,为什么封得更快了?
原因往往不在 IP,而在指纹一致性。IP 只是网站风控的一个维度,它还会看 User-Agent、浏览器指纹、Cookie、TLS 握手特征等。如果你的 IP 一直在变,但同一个 Cookie 对应的登录态在十几个不同 IP 上跳来跳去,风控系统很容易把这个账号标记为"异常登录"。
我常用的配合策略是:代理分组 + 会话绑定。把代理池按地域或供应商分组,同一个登录账号尽量固定走一组代理;同时在代理变化时同步更换 UA 头、清掉可能携带地理信息的 Cookie。不要小看这个问题,很多高频采集项目最后不是被 IP 频率限制封掉的,而是被"指纹错位"判死的。
还有一个跟指纹去重相关的细节:同一个 URL 先通过代理 A 请求失败,再通过代理 B 请求成功,这两个请求会因为 Request 指纹相同而被默认去重过滤。所以代理失效后的重试请求一定记得设置 dont_filter=True,否则你以为在重试,实际被调度器静默丢弃了。
4. 并发参数与 AutoThrottle:快与稳的平衡木
4.1 并发数、延迟、超时:先搞清楚每个参数控制什么
Scrapy 的很多性能参数都集中在 settings.py 里,但它们控制的东西完全不同。我把最常用的几个列表如下:
| 配置项 | 作用 | 默认值 | 我的经验值 |
|---|---|---|---|
| CONCURRENT_REQUESTS | 全局最大并发请求数 | 16 | 一般不超过 64 |
| CONCURRENT_REQUESTS_PER_DOMAIN | 单个域名最大并发数 | 8 | 4 - 16 |
| CONCURRENT_REQUESTS_PER_IP | 单个 IP 的最大并发数 | 0(不启用) | 代理池场景下建议限制 |
| DOWNLOAD_DELAY | 同一域名的请求间隔秒数 | 0 | 根据目标站情况 0.1 - 2 |
| DOWNLOAD_TIMEOUT | 单个请求的等待超时秒数 | 180 | 建议 10 - 20 |
| RETRY_TIMES | 失败请求重试次数 | 2 | 2 - 3,别太多 |
先别急着把所有数值都调大。高并发带来的是高连接数和高内存占用,目标站的负载承受能力才是天花板。抓一个每秒能响应几百次的站点,并发开 100 没问题;抓一个普通虚拟主机,并发 20 就可能把对方打挂,然后你的 IP 进黑名单。
CONCURRENT_REQUESTS_PER_IP 这个参数我在用代理池的时候特别注意。因为每个代理 IP 背后是一个独立的出口连接,如果不限制单 IP 并发,可能一个代理节点被分配几十个并发请求,导致代理供应商那边很容易标记异常。
4.2 AutoThrottle 的"自适应限速"原理与经验调参
Scrapy 内置了 AutoThrottle 机制,目的很简单:根据目标站的实际响应速度,动态调整每个域名的并发数。
它的核心逻辑是:统计每个域名下请求的平均响应时间,如果响应变慢,就降低并发;如果响应很快,就适当提高并发。这比人肉设定固定 DOWNLOAD_DELAY 要优雅得多,尤其适合抓取一批性能差异很大的网站。
启用方式是在 settings.py 里加:
我在实际项目里一般把 AUTOTHROTTLE_TARGET_CONCURRENCY 设为 5 到 10。太高的话,响应一慢,请求就开始排队积压,反而造成雪崩。
另一个经验是:开着 AutoThrottle 的时候,不要同时设 DOWNLOAD_DELAY,否则两个机制会互相干扰。AutoThrottle 会自动调整延迟,你再手工加固定延迟,整个节奏就乱了。
4.3 重试与异常降级:目标站点抖动时的保命策略
网络请求永远存在不确定性,目标站可能 5 分钟前还正常,突然开始 503 或者响应超时。稳定爬虫的关键不在于"请求永不失败",而在于"失败之后能优雅降级"。
Scrapy 默认的重试机制是 RetryMiddleware,它会对超时、500、502、503、504 等错误自动发起重试。可以通过 RETRY_TIMES 和 RETRY_HTTP_CODES 控制重试次数和范围。
我通常还会自定义一个中间件,专门处理 429(请求过多)和 403 这类状态码。它们的处理方式完全不同:429 一般会带 Retry-After 响应头,告诉你多久后再试;403 则往往意味着 IP 被封,重试也没用,应该换代理或者降速。
因为 Scrapy 是事件循环驱动的,你可以在延时后把请求重新塞回调度器,而整个过程不会阻塞其他请求。对于"稳"来说,这种降级策略比一味拉高并发重要得多。
5. 动态页面与事件循环冲突:Scrapy Playwright 集成实录
5.1 为什么动态 iframe 用普通 Request 拿不到
Scrapy 默认的 Downloader 发出去的是原生 HTTP 请求,拿到的是服务器直接返回的 HTML 字符串。如果页面里有一个 iframe,那 iframe 的内容并不是这个 HTML 字符串的一部分,而是一个独立的 HTML 文档地址。它需要浏览器根据 iframe 的 src 属性再去发一个请求,才能加载出来。
更麻烦的是,很多 iframe 内容还是 JS 动态生成的。主页面先加载一个空 iframe,然后脚本往 iframe 里写入内容,或者 iframe 内部再发 XHR 请求,渲染出数据。这种情况下,你拿到的初始 HTML 里甚至连 iframe 的最终 src 都可能不对,更别提里面的数据了。
解决的办法是引入一个真正的浏览器环境,让 JS 执行完、iframe 内容渲染完,再去读取最终 DOM。这就是 Playwright 这类无头浏览器工具的价值所在。
5.2 asyncio 与 Twisted 的桥接:爬虫里跑浏览器的正确方式
Playwright 是基于 asyncio 的,而 Scrapy 基于 Twisted 的 Reactor 事件循环。两个事件循环同时存在,如果处理不当,就会互相打架。
scrapy-playwright 这个插件做的事情,就是在两者之间做桥接。它利用 Twisted 的 reactor.callLater() 把 asyncio 的协程调度嫁接到 Twisted 事件循环中,让 Playwright 的异步操作能嵌入 Scrapy 的请求生命周期。
在 settings.py 里启用:
第二个配置很重要:必须把 Twisted 的 Reactor 替换成基于 asyncio 的实现,否则两个事件循环无法共存。我没有去细扒 scrapy-playwright 的源码,但根据经验,集成时最常见的错误就是忘记设置 TWISTED_REACTOR,导致运行时直接报错。
5.3 动态 iframe 内容抓取的一个完整示例
下面是一个实际可用的示例。场景是:页面主文档里有一个 iframe,iframe 内部通过 JS 加载了一个表格,我需要抓取这个表格里的数据。
首先,正常请求页面,并让 Playwright 接管:
这里最关键的是 frame_locator() 和 wait_for() 两个调用。frame_locator() 允许你跨越 iframe 边界定位元素,wait_for() 确保 JS 渲染完成后才开始抓取。如果没有等待就读取,大概率拿到空列表。
5.4 我在集成 Playwright 时遇到的资源回收问题
用 Playwright 抓动态页面,最大的坑不是抓不到数据,而是内存泄漏。
每个 meta={"playwright": True} 的请求都会在浏览器里打开一个页面。如果不主动关闭,页面句柄会把浏览器上下文占住,跑一两个小时,内存就上去了。上面示例里我写了 await page.close(),这绝对不能省。
另外,playwright_include_page=True 会把 Playwright 的 page 对象传给你,但这个 page 对象只在该请求的回调链上有效。不要尝试在回调结束后还持有它,或者把它传给其他请求用。它的生命周期绑定在单次请求上。
如果网站主要页面是静态的,只有少量动态 iframe,我建议只在必要请求上启用 Playwright,而不是把所有请求都设为 playwright=True。无头浏览器比普通 HTTP 请求慢几个数量级,全部都走浏览器,爬虫速度会感人到让你怀疑人生。
还有一个跟事件循环相关的细节:在 Spider 里写 async def parse() 时,里面不能再随意调用阻塞式同步代码,比如大量正则匹配倒是还好,但如果你用 pandas.read_html() 这类耗时操作,它会把事件循环整个卡住,其他所有请求都跟着暂停。动态页面采集里,耗时的 DOM 操作最好只做必要动作,复杂的清洗扔到 Item Pipeline 里去做。
最后说点我的个人体会
从 requests 多线程切到 Scrapy 之后,我对"快"和"稳"的理解变了很多:快不是把并发参数拉满,而是让事件循环持续高效运转;稳不是祈求网络不出问题,而是用调度、去重、代理、重试这一整套机制把不确定性消化掉。
如果你也在用 Scrapy,建议跑任务的时候开着 log,观察 downloader/response_count、downloader/request_bytes、scheduler/enqueued 这几个计数器。它们能直观反映爬虫当前是"吃饱了"还是"饿着",比盲目调高并发靠谱得多。把这些机制吃透之后,你会发现自己写爬虫的姿势完全不一样了。