每天15分钟玩转Scrapy - 性能调优
开场
性能这件事,我的经验是可以量出来的部分比想象中多。
很多人调优的顺序是反的。先凭感觉把并发开大,发现没变快,再加缓存,还是没变快,最后开始怀疑框架。真正该做的第一步是量出瓶颈在哪,而这件事比想象的简单,因为 Scrapy 已经把每个环节的耗时和计数都记下来了,你只要知道去看哪几个数。
这一篇分成三块。先把旋钮认全,说清楚哪个真的有用。然后是我自己做的一组并发对照,结果有点出乎意料,因为这个坑我花了不少时间才定位到。最后说一件容易被忽略的事,瓶颈经常不在网络那一侧。
所有数字都来自同一台机器上的一次运行,横向比较有效,绝对值别当成硬指标。这类测量对机器状态很敏感,我建议你在自己的环境里重跑一遍。
速度先量再调
聊调优之前先把变量数清楚。延迟类的旋钮只有三个。
| 旋钮 | 作用 |
|---|---|
DOWNLOAD_DELAY | 同一个域两次请求之间的基准间隔 |
DOWNLOAD_DELAY_JITTER | 在基准上抖动的幅度,0.5 表示上下 50% |
CONCURRENT_REQUESTS_PER_DOMAIN | 同一个域同时在飞的请求数 |
第三个旋钮有个 2.18 起的新玩法,CONCURRENT_REQUESTS 设成 0 表示不限。别急着用,这个开关的意思是「把对方的承受能力当成无限」。
第二个旋钮是新面孔。老教程会让你设 RANDOMIZE_DOWNLOAD_DELAY = True,这个设置现在弃用了,2.19 上显式设置它还会打 ScrapyDeprecationWarning。新写法是 DOWNLOAD_DELAY_JITTER = 0.5,语义从「开不开抖动」变成「抖多大」。
而 0.5 恰好就是 DOWNLOAD_DELAY_JITTER 的默认值,抖动公式是 delay * (1 + uniform(-jitter, jitter))。拿默认值算,设了 1 秒延迟,实际间隔在 0.5 到 1.5 秒之间随机。
1. 两组蜘蛛的对比
我做了两个蜘蛛。一个是链式翻页,解析完第 1 页才知道第 2 页在哪。另一个把 10 个页面 URL 一次性全抛出去。
同一个站点、同样 100 条数据,三套参数各跑一次。
链式翻页那一组。
| 配置 | 耗时 | | 模板默认,1 秒延迟加单并发 | 11.72 秒 | | 关掉延迟,并发开到 8 | 3.08 秒 | | AutoThrottle 目标并发 2.0 | 3.43 秒 |
并行起点那一组。
| 配置 | 耗时 |
|---|---|
| 模板默认,1 秒延迟加单并发 | 10.38 秒 |
| 关掉延迟,并发开到 8 | 0.98 秒 |
| AutoThrottle 目标并发 2.0 | 2.02 秒 |
并行起点那组快了 10.6 倍,链式那组只快了 3.8 倍。 差距不在参数上,在蜘蛛的形状上。
链式翻页每一页的 URL 都藏在上一个响应里,同一时刻永远只有一个请求在飞。并发数调到 8 也好调到 80 也好,没有第二个请求可以并发。它那 3.8 倍全是关掉延迟挣来的。

这件事我当初没想明白,看着 CONCURRENT_REQUESTS_PER_DOMAIN 一路加,耗时纹丝不动,还以为是 Scrapy 没读配置。并发数只对天生并行的起点有用。 列表页上能一次拿到全部详情页链接的那种结构,是该花力气优化的形状。我自己的感受是,蜘蛛的形状定下来之前,调参基本是在给自己找事。
另一个数字也很说明问题。同一个草稿模板跑出来的 scrapy crawl 项目,默认就带着 CONCURRENT_REQUESTS_PER_DOMAIN = 1 和 DOWNLOAD_DELAY = 1。写代码练手没问题,真要抓点东西,这两个值得自己动。
⚠️ 顺带提一个测量口径的陷阱。我一开始从 process_request 开始掐表,算出来的平均耗时 2994 毫秒。改用 request.meta["download_latency"](这个值由 HTTP 下载器写在纯网络往返那一步)之后,平均只有 266 毫秒。
差了 11 倍,多出来的全是调度器排队的时间。你设了 1 秒延迟,请求就得在队列里等着,这段等待跟站点快慢毫无关系。只看一个平均数就判断站点变慢了,很容易得出反向结论。
2. AutoThrottle 自己找节奏
手工调延迟的问题在于,你不知道对方能承受多少。设保守了浪费时间,设激进了被封。
AutoThrottle 的思路是把这件事交给反馈。它盯着每次请求的响应延迟,延迟高就放慢、降并发,延迟低就加快。你要告诉它的是「我大概想同时发几个请求」,而不是「延迟设几秒」。
| |
打开调试之后能看到它在线调参。
| |
conc 是当前并发,后面括号里是这次相对上次调整了多少。第一次它按站点延迟把延迟从 100 毫秒一口气顶到 361 毫秒,之后稳定在 352 毫秒附近不再摆动。
实测耗时 2.02 秒对不限速的 0.98 秒。慢了一倍,但这一倍买来的是「不把对方打挂」。
⚠️ 开了 AutoThrottle 就别再定死 DOWNLOAD_DELAY,两个旋钮抢同一个位置,结果不好预期。规范做法是把 DOWNLOAD_DELAY 留着默认,让扩展自己接管。
并发到底能开多大
这个问题我原本以为有标准答案,量完之后发现答案有点意外。说实话我一开始是照着文档的语义去理解的,以为有这个旋钮就等于有了这道闸门。
先搭一个可控的环境。我写了个服务端,/index?n=&ms= 返回 n 条指向详情页的链接,/item/<i>?ms= 每条固定延迟 ms 毫秒。要测并发的效果,这样最干净,服务器本身不成为变量。
然后一个蜘蛛,从 /index 一次拿到 50 个详情页链接。50 页,每页延迟 200 毫秒,串行跑完的理论值是 10.2 秒。
改 CONCURRENT_REQUESTS,跑四次。
| 页级并发 | 耗时 | 相对串行的加速 |
|---|---|---|
| 1 | 10.23 秒 | 1.0 倍 |
| 4 | 2.73 秒 | 3.7 倍 |
| 16 | 1.09 秒 | 9.4 倍 |
| 32 | 0.83 秒 | 12.4 倍 |
收益递减看得很清楚。从 1 到 4 拿了 3.7 倍,从 16 到 32 只多拿了 0.3 倍多一点。原因是这组数据里服务器本身没有压力,唯一的开销就是那个 200 毫秒的固定延迟。真实站点上加并发会先撞到对方的能力上限,再撞到你自己机器的连接数,收益曲线比这个更早变平。
到这里都还正常。真正让我意外的是下一个实验。你想想看,一个按域限流的旋钮,如果它不生效,那前面那些「安全爬取」的建议就全落空了。
域级并发形同虚设
我一直以为限制爬速的安全做法是按住 CONCURRENT_REQUESTS_PER_DOMAIN,把页级并发放开。这个直觉在 2.19 上不成立。
同一组 5 个页面,把页面延迟调到 1000 毫秒,这样串行和并行的差别会拉到 5 秒左右,看得更清楚。三套配置各跑一遍。
| 页级并发 | 域级并发 | 耗时 |
|---|---|---|
| 1 | 8 | 5.09 秒 |
| 32 | 1 | 1.07 秒 |
| 1 | 1 | 5.09 秒 |
第一行和第三行的耗时几乎一样,5.09 对 5.09。域级并发设成 8 和设成 1 没有任何区别,决定耗时的只有页级并发那个值。
中间那一行更说明问题。页级并发 32,域级并发 1,按定义「同一个域同时只允许 1 个请求在飞」,它应该跑出 5 秒。实际是 1.07 秒,全部并行。
我不信,去读了运行时状态。下载器上挂着一个 slots 字典,按域分槽,每个槽有自己的并发值。我把槽位的值打出来,确实读到了 1。
| |
这组数字就是答案。槽位声明了并发 1,7 次出队全挤在 9 毫秒之内完成,而每次出队的时候「正在传输」都是 0。

「正在传输」这个计数是用来判断槽位有没有空的依据。它一直是 0,说明每个请求都在被计入这个计数之前就已经离开队列了。限流判断发生在这一步之后,于是这道闸门根本没有机会合上。
⚠️ 我去读了 2.19 的下载器源码,机制是清楚的。出队那段是个同步的 while 循环,条件里有一句 slot.free_transfer_slots() > 0,而它的实现是「槽位并发数减去正在传输的个数」。
| |
问题在于 _schedule_coro 只是把协程排进调度,不会立刻执行。而「把自己加进正在传输」这一句,是在那个协程真正跑起来之后才执行的。
| |
于是整个 while 循环在同一个同步块里把队列抽空了,那期间 transferring 一直是空的,free_transfer_slots() 就一直返回完整的并发数。闸门全程没合上。
页级并发走的是另一条路,它在下载器自己的 active 集合上判断,跟这个循环无关,所以它是真的生效。
⚠️ 说清楚这个机制不是为了让你去改源码。对你的实际价值是,在 2.19 上想控制爬速,别指望 CONCURRENT_REQUESTS_PER_DOMAIN。 它的值会被读进槽位,看起来配置生效了,实际不起作用。
那控速该用什么。三个层次按优先级。
CONCURRENT_REQUESTS是唯一真正生效的并发旋钮,先调它DOWNLOAD_DELAY加DOWNLOAD_DELAY_JITTER控制请求间隔,这个也生效- 要按域精细控制,用
AutoThrottle扩展,它按每个域自己的响应时间动态调
至于 CONCURRENT_REQUESTS = 0 这个「不限」的写法,我建议别用。它的含义是把对方的承受能力当成无限的,而这在真实站点上从来不是真的。
瓶颈常常不在网络
前面几节全在调网络那一侧的参数。但爬虫有两个半场,下载是一半,处理 item 是另一半。第二半被我忽略了很久。说到底,一只爬虫跑得快不快,取决于两个半场里慢的那个。
管道是串行执行的。引擎把每个 item 依次交给管道链上的每一个组件,前一个处理完才轮到下一个。这条链是串的,管道里的耗时是累加的,而且它跟下载不是并行关系,是一条独立的串行链路。
我拿一个故意变慢的管道做对照。蜘蛛抓 100 页,每页服务端延迟 50 毫秒,页级并发开到 16。管道里加一个人为延迟,从 0 调到 20 毫秒每条。
| 管道延迟 | 耗时 |
|---|---|
| 0 | 0.70 秒 |
| 20 毫秒/条 | 2.42 秒 |
一百条乘以 20 毫秒是 2.0 秒,跟实测的 2.42 秒基本对得上。而下载那一半的计算值是 100 乘 50 毫秒除以 16,大约 0.31 秒。
管道的串行耗时已经比下载的并行耗时大好几倍,这时候并发调多大都没有用。 你把页级并发放到 64,整个爬虫的耗时还是 2 秒出头,因为上游在等下游。
管道长这样。
| |
⚠️ 这段代码里用的是 time.sleep,它会把整个事件循环按住。这是教学用的极端例子,但它对应的真实写法一点不夸张,同步的数据库客户端、同步的 HTTP 请求、同步的文件写入,全都是同一个效果。
判断自己的管道有没有成为瓶颈,有个很简单的办法。跑完之后对比这两个数。

| 统计项 | 看什么 |
|---|---|
downloader/response_count 除以总耗时 | 下载的实际吞吐 |
item_scraped_count 除以总耗时 | 整条链路的实际吞吐 |
两个数差得越多,说明管道那一段吃掉了越多时间。还有一个更直接的信号,item_scraped_count 明显小于 response_received_count,而这个差距又不是因为解析没命中,那大概率就是管道在拖。
解法有两条。一条是把管道改成异步的,process_item 写成 async def,Scrapy 会 await 它,用 asyncpg、aiomysql 这类异步驱动把阻塞去掉。另一条是把管道里那些重活挪出主流程,比如先进一个内存队列,由单独的协程批量写。
第一条更彻底,代价是你的数据库驱动得换。第二条改动小,但引入了队列,进程被强杀的时候队列里的数据会丢,得自己权衡。
顺带说一个观察。这事儿我在跑这些对照的时候看得很清楚,同一份蜘蛛在管道没压力的情况下,从并发 16 加到 32 只快了 0.3 秒左右。在你还没量出瓶颈在哪之前,动并发这个旋钮的收益上限其实很低。 先把两个吞吐数字比一比,比一直试参数效率高得多。
把请求省下来,把数据看清楚
调优这件事有两个配套动作,一个帮你少发请求,一个帮你把已经发生的事看清楚。
调试选择器的时候反复抓同一个页面是常态,每次都真发请求既慢又对不起对方。把响应缓存到本地能省掉这部分。而跑完之后的那一大坨统计,是排查问题的第一手材料,比翻日志快得多。
这两件事都属于「调优之前的准备工作」,但它们的回报很直接。
1. 缓存与统计
调试选择器的时候反复抓同一个页面很常见。HTTPCACHE_ENABLED 就是为这个准备的,它把响应原样存到磁盘,下次同样的请求直接读盘。
| |
同一个十页站点连跑两次,差距是这样的。
| |
10.96 秒掉到 0.041 秒,两百多倍。firsthand 是「第一次见到、真的发了请求」,hit 是「直接读盘」。
⚠️ 缓存有个副作用很容易咬人。你改了选择器、改了 parse 逻辑,跑起来结果没变,原因就是它还在吃旧缓存。这时候去清 HTTPCACHE_DIR,千万别先怀疑代码。缓存这东西在调试期挺好用,忘了它的存在就挺烦人。
缓存之外,还有一类信息只有统计里有。跑完那一刻打印的那一大坨字典,不是日志噪音,是排查问题的第一手材料。
我在完整案例里把关键的几项挑出来,让蜘蛛在关闭时自己写成报表。
| |
⚠️ 这里踩了两个时序坑。elapsed_time_seconds 和 finish_reason 这两个值由 CoreStats 扩展在 spider_closed 里写,而我们的回调可能排在它前面,读出来就是 0 和空。耗时自己从 start_time 算,结束原因直接用信号传进来的 reason。
报表长这样。
| |
最后聊两句日志。LOG_LEVEL 默认 INFO,LOG_FILE 能把它落到文件。2.19 加了 LOG_COLOR 和 LOG_INSTALL_ROOT_HANDLER,终端上的颜色和根 logger 的接管都能配。
⚠️ 别图省事把 LOG_LEVEL 设成 DEBUG 然后跑一整天。媒体管道在 DEBUG 下会给每张图打三到四行,抓十万张图就是几十万行日志,磁盘写满只是时间问题。要看细节就开一次短跑,看完关掉。
容易栽的跟头
坑 1:把 CONCURRENT_REQUESTS_PER_DOMAIN 当成限速的主要手段。 这是这一篇里最贵的一个坑,因为我花了很长时间才定位到。实测数据是页级并发 1 加域级并发 8 跑了 5.09 秒,页级并发 32 加域级并发 1 跑了 1.07 秒。域级那个值在运行时的槽位里确实读到了,但它拦不住请求。在 2.19 上想控速就用 CONCURRENT_REQUESTS 加 DOWNLOAD_DELAY,要按域精细控制就上 AutoThrottle。
坑 2:以为并发开得越大越快。 同一组 50 页的对照里,从并发 1 到 4 拿了 3.7 倍,从 16 到 32 只多拿了 0.3 倍多一点。并发这个旋钮的收益是有上限的,撞到上限之后再往上加,多出来的只有你自己机器的连接数和对方的判断。跑一次矩阵,找到拐点,比一直往上试有效。
坑 3:管道里做同步 IO。 这是第二个隐形的瓶颈。管道是在 item 处理链上串行执行的,里面阻塞多久,整条链路就等多久。实测里管道加 20 毫秒每条,100 条就让总耗时从 0.70 秒涨到 2.42 秒,这时候下载并发调到多少都不影响结果。判据很简单,item_scraped_count 除以总耗时,跟 downloader/response_count 除以总耗时比一比,差得多就说明管道在拖。
坑 4:在 spider_closed 回调里读 elapsed_time_seconds 和 finish_reason。 这两个值由 CoreStats 扩展在它的 spider_closed 里写,你的回调排在它前面就会读到 0 和空字符串。耗时自己从 start_time 算,结束原因直接用信号传进来的 reason 参数。
坑 5:改了选择器,结果没变,去怀疑代码。 大概率是 HTTPCACHE_ENABLED 还开着。缓存按请求指纹命中,你改的是解析逻辑不是请求地址,它照样给你旧响应。先去清 HTTPCACHE_DIR 再排查,能省下不少时间。
坑 6:图省事把 LOG_LEVEL 设成 DEBUG 跑一整天。 媒体管道在 DEBUG 下会给每张图打三到四行,抓十万张图就是几十万行日志,磁盘写满只是时间问题。要看细节就开一次短跑,看完关掉。这个坑的特点是不会立刻出事,等你发现的时候盘已经满了。
坑 7:CONCURRENT_REQUESTS = 0。 这个写法表示不限并发。它的语义是把对方的承受能力当成无限的,而真实站点上从来不是。要压满本机能力的时候可以短跑测一下上限,别把这行留在生产配置里。
小结
这一篇如果只留一句话,我会留这句,先量再调。
前三块内容其实都在讲同一件事的不同侧面。蜘蛛的形状决定了并发的上限,链式翻页的蜘蛛并发开多大都只有 3.8 倍,天生并行的起点能拿 10.6 倍。并发这个旋钮本身有拐点,实测里 16 到 32 只多 0.3 倍。管道是第二条串行链路,它慢下来之后网络那侧的参数全都失效。
而域级并发失效那件事给了我一个提醒。配置被读进去了,不等于配置生效了。 那个 8 在运行时的槽位里明明白白读得到,行为上却跟 1 完全一样。要不是我去钩住出队那一步看 transferring 的实际值,光看日志是永远发现不了的。你手上的一个配置项到底有没有在干活,这件事得靠测量回答。
我自己的习惯是,每次要给爬虫提速之前先跑一次基线,把这几项记下来。downloader/response_count、item_scraped_count、总耗时,三个数除以一下就是两个吞吐。改一个参数再跑一次,两个吞吐都动了才算改对了地方。
这一篇里的数字都是在一台机器上一次跑出来的,绝对值换台机器就不一样。留着它们是让你看趋势,不是当硬指标用。
这篇里要是有哪里讲得不对,欢迎拍砖。