每天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 秒
关掉延迟,并发开到 80.98 秒
AutoThrottle 目标并发 2.02.02 秒

并行起点那组快了 10.6 倍,链式那组只快了 3.8 倍。 差距不在参数上,在蜘蛛的形状上。

链式翻页每一页的 URL 都藏在上一个响应里,同一时刻永远只有一个请求在飞。并发数调到 8 也好调到 80 也好,没有第二个请求可以并发。它那 3.8 倍全是关掉延迟挣来的。

蜘蛛形状决定并发上限

这件事我当初没想明白,看着 CONCURRENT_REQUESTS_PER_DOMAIN 一路加,耗时纹丝不动,还以为是 Scrapy 没读配置。并发数只对天生并行的起点有用。 列表页上能一次拿到全部详情页链接的那种结构,是该花力气优化的形状。我自己的感受是,蜘蛛的形状定下来之前,调参基本是在给自己找事。

另一个数字也很说明问题。同一个草稿模板跑出来的 scrapy crawl 项目,默认就带着 CONCURRENT_REQUESTS_PER_DOMAIN = 1DOWNLOAD_DELAY = 1。写代码练手没问题,真要抓点东西,这两个值得自己动。

⚠️ 顺带提一个测量口径的陷阱。我一开始从 process_request 开始掐表,算出来的平均耗时 2994 毫秒。改用 request.meta["download_latency"](这个值由 HTTP 下载器写在纯网络往返那一步)之后,平均只有 266 毫秒。

差了 11 倍,多出来的全是调度器排队的时间。你设了 1 秒延迟,请求就得在队列里等着,这段等待跟站点快慢毫无关系。只看一个平均数就判断站点变慢了,很容易得出反向结论。

2. AutoThrottle 自己找节奏

手工调延迟的问题在于,你不知道对方能承受多少。设保守了浪费时间,设激进了被封。

AutoThrottle 的思路是把这件事交给反馈。它盯着每次请求的响应延迟,延迟高就放慢、降并发,延迟低就加快。你要告诉它的是「我大概想同时发几个请求」,而不是「延迟设几秒」。

1
2
3
4
5
AUTOTHROTTLE_ENABLED = True
AUTOTHROTTLE_START_DELAY = 0.5
AUTOTHROTTLE_MAX_DELAY = 10.0
AUTOTHROTTLE_TARGET_CONCURRENCY = 2.0
AUTOTHROTTLE_DEBUG = True

打开调试之后能看到它在线调参。

1
2
3
4
5
slot: quotes.toscrape.com | conc: 7 | delay:  361 ms (+261) | latency:  722 ms | size:  1962 bytes
slot: quotes.toscrape.com | conc: 6 | delay:  356 ms (-5)   | latency:  702 ms | size:  2875 bytes
slot: quotes.toscrape.com | conc: 5 | delay:  354 ms (-1)   | latency:  705 ms | size:  1940 bytes
slot: quotes.toscrape.com | conc: 4 | delay:  353 ms (+0)   | latency:  707 ms | size:  1802 bytes
slot: quotes.toscrape.com | conc: 3 | delay:  352 ms (+0)   | latency:  704 ms | size:  1937 bytes

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,跑四次。

页级并发耗时相对串行的加速
110.23 秒1.0 倍
42.73 秒3.7 倍
161.09 秒9.4 倍
320.83 秒12.4 倍

收益递减看得很清楚。从 1 到 4 拿了 3.7 倍,从 16 到 32 只多拿了 0.3 倍多一点。原因是这组数据里服务器本身没有压力,唯一的开销就是那个 200 毫秒的固定延迟。真实站点上加并发会先撞到对方的能力上限,再撞到你自己机器的连接数,收益曲线比这个更早变平。

到这里都还正常。真正让我意外的是下一个实验。你想想看,一个按域限流的旋钮,如果它不生效,那前面那些「安全爬取」的建议就全落空了。

域级并发形同虚设

我一直以为限制爬速的安全做法是按住 CONCURRENT_REQUESTS_PER_DOMAIN,把页级并发放开。这个直觉在 2.19 上不成立。

同一组 5 个页面,把页面延迟调到 1000 毫秒,这样串行和并行的差别会拉到 5 秒左右,看得更清楚。三套配置各跑一遍。

页级并发域级并发耗时
185.09 秒
3211.07 秒
115.09 秒

第一行和第三行的耗时几乎一样,5.09 对 5.09。域级并发设成 8 和设成 1 没有任何区别,决定耗时的只有页级并发那个值。

中间那一行更说明问题。页级并发 32,域级并发 1,按定义「同一个域同时只允许 1 个请求在飞」,它应该跑出 5 秒。实际是 1.07 秒,全部并行。

我不信,去读了运行时状态。下载器上挂着一个 slots 字典,按域分槽,每个槽有自己的并发值。我把槽位的值打出来,确实读到了 1。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
槽位并发=1,共 7 次「队列非空且有空闲槽」的出队事件:
相对时刻       正在传输           队列剩余           空闲槽
--------------------------------------------------
0.000s     0              1              1
0.008s     0              1              1
0.008s     0              1              1
0.008s     0              1              1
0.008s     0              1              1
0.008s     0              1              1
0.009s     0              1              1

出队时看到的「正在传输」峰值 = 0

这组数字就是答案。槽位声明了并发 1,7 次出队全挤在 9 毫秒之内完成,而每次出队的时候「正在传输」都是 0。

域级并发闸门为什么不合上

「正在传输」这个计数是用来判断槽位有没有空的依据。它一直是 0,说明每个请求都在被计入这个计数之前就已经离开队列了。限流判断发生在这一步之后,于是这道闸门根本没有机会合上。

⚠️ 我去读了 2.19 的下载器源码,机制是清楚的。出队那段是个同步的 while 循环,条件里有一句 slot.free_transfer_slots() > 0,而它的实现是「槽位并发数减去正在传输的个数」。

1
2
3
4
while slot.queue and slot.free_transfer_slots() > 0:
    slot.lastseen = now
    request, queue_dfd = slot.queue.popleft()
    _schedule_coro(self._wait_for_download(slot, request, queue_dfd))

问题在于 _schedule_coro 只是把协程排进调度,不会立刻执行。而「把自己加进正在传输」这一句,是在那个协程真正跑起来之后才执行的。

1
2
async def _download(self, slot, request):
    slot.transferring.add(request)

于是整个 while 循环在同一个同步块里把队列抽空了,那期间 transferring 一直是空的,free_transfer_slots() 就一直返回完整的并发数。闸门全程没合上。

页级并发走的是另一条路,它在下载器自己的 active 集合上判断,跟这个循环无关,所以它是真的生效。

⚠️ 说清楚这个机制不是为了让你去改源码。对你的实际价值是,在 2.19 上想控制爬速,别指望 CONCURRENT_REQUESTS_PER_DOMAIN 它的值会被读进槽位,看起来配置生效了,实际不起作用。

那控速该用什么。三个层次按优先级。

  1. CONCURRENT_REQUESTS 是唯一真正生效的并发旋钮,先调它
  2. DOWNLOAD_DELAYDOWNLOAD_DELAY_JITTER 控制请求间隔,这个也生效
  3. 要按域精细控制,用 AutoThrottle 扩展,它按每个域自己的响应时间动态调

至于 CONCURRENT_REQUESTS = 0 这个「不限」的写法,我建议别用。它的含义是把对方的承受能力当成无限的,而这在真实站点上从来不是真的。

瓶颈常常不在网络

前面几节全在调网络那一侧的参数。但爬虫有两个半场,下载是一半,处理 item 是另一半。第二半被我忽略了很久。说到底,一只爬虫跑得快不快,取决于两个半场里慢的那个。

管道是串行执行的。引擎把每个 item 依次交给管道链上的每一个组件,前一个处理完才轮到下一个。这条链是串的,管道里的耗时是累加的,而且它跟下载不是并行关系,是一条独立的串行链路。

我拿一个故意变慢的管道做对照。蜘蛛抓 100 页,每页服务端延迟 50 毫秒,页级并发开到 16。管道里加一个人为延迟,从 0 调到 20 毫秒每条。

管道延迟耗时
00.70 秒
20 毫秒/条2.42 秒

一百条乘以 20 毫秒是 2.0 秒,跟实测的 2.42 秒基本对得上。而下载那一半的计算值是 100 乘 50 毫秒除以 16,大约 0.31 秒。

管道的串行耗时已经比下载的并行耗时大好几倍,这时候并发调多大都没有用。 你把页级并发放到 64,整个爬虫的耗时还是 2 秒出头,因为上游在等下游。

管道长这样。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class SlowPipeline:
    def __init__(self, sleep_ms, crawler):
        self.sleep_ms = sleep_ms
        self.crawler = crawler
        self.stats = crawler.stats

    @classmethod
    def from_crawler(cls, crawler):
        return cls(crawler.settings.getint("PIPELINE_SLEEP_MS", 0), crawler)

    def process_item(self, item):
        if self.sleep_ms:
            time.sleep(self.sleep_ms / 1000.0)
            self.stats.inc_value("pipeline/slept")
        return item

⚠️ 这段代码里用的是 time.sleep,它会把整个事件循环按住。这是教学用的极端例子,但它对应的真实写法一点不夸张,同步的数据库客户端、同步的 HTTP 请求、同步的文件写入,全都是同一个效果。

判断自己的管道有没有成为瓶颈,有个很简单的办法。跑完之后对比这两个数。

下载半场与处理半场

统计项看什么
downloader/response_count 除以总耗时下载的实际吞吐
item_scraped_count 除以总耗时整条链路的实际吞吐

两个数差得越多,说明管道那一段吃掉了越多时间。还有一个更直接的信号,item_scraped_count 明显小于 response_received_count,而这个差距又不是因为解析没命中,那大概率就是管道在拖。

解法有两条。一条是把管道改成异步的,process_item 写成 async def,Scrapy 会 await 它,用 asyncpgaiomysql 这类异步驱动把阻塞去掉。另一条是把管道里那些重活挪出主流程,比如先进一个内存队列,由单独的协程批量写。

第一条更彻底,代价是你的数据库驱动得换。第二条改动小,但引入了队列,进程被强杀的时候队列里的数据会丢,得自己权衡。

顺带说一个观察。这事儿我在跑这些对照的时候看得很清楚,同一份蜘蛛在管道没压力的情况下,从并发 16 加到 32 只快了 0.3 秒左右。在你还没量出瓶颈在哪之前,动并发这个旋钮的收益上限其实很低。 先把两个吞吐数字比一比,比一直试参数效率高得多。

把请求省下来,把数据看清楚

调优这件事有两个配套动作,一个帮你少发请求,一个帮你把已经发生的事看清楚。

调试选择器的时候反复抓同一个页面是常态,每次都真发请求既慢又对不起对方。把响应缓存到本地能省掉这部分。而跑完之后的那一大坨统计,是排查问题的第一手材料,比翻日志快得多。

这两件事都属于「调优之前的准备工作」,但它们的回报很直接。

1. 缓存与统计

调试选择器的时候反复抓同一个页面很常见。HTTPCACHE_ENABLED 就是为这个准备的,它把响应原样存到磁盘,下次同样的请求直接读盘。

1
2
3
HTTPCACHE_ENABLED = True
HTTPCACHE_EXPIRATION_SECS = 600
HTTPCACHE_DIR = "httpcache"

同一个十页站点连跑两次,差距是这样的。

1
2
3
4
5
第一次  'httpcache/firsthand': 10, 'httpcache/miss': 10, 'httpcache/store': 10
        'elapsed_time_seconds': 10.958

第二次  'httpcache/hit': 10
        'elapsed_time_seconds': 0.041

10.96 秒掉到 0.041 秒,两百多倍。firsthand 是「第一次见到、真的发了请求」,hit 是「直接读盘」。

⚠️ 缓存有个副作用很容易咬人。你改了选择器、改了 parse 逻辑,跑起来结果没变,原因就是它还在吃旧缓存。这时候去清 HTTPCACHE_DIR千万别先怀疑代码。缓存这东西在调试期挺好用,忘了它的存在就挺烦人。

缓存之外,还有一类信息只有统计里有。跑完那一刻打印的那一大坨字典,不是日志噪音,是排查问题的第一手材料。

我在完整案例里把关键的几项挑出来,让蜘蛛在关闭时自己写成报表。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
    def write_report(self, spider, reason=None):
        st = self.crawler.stats.get_stats()

        def g(key, default=0):
            return st.get(key, default)

        start = st.get("start_time")
        elapsed = (
            (datetime.now(timezone.utc) - start).total_seconds() if start else 0.0
        )
        net_n, net_ms = g("latency/net_count"), g("latency/net_total_ms")
        wall_n, wall_ms = g("latency/wall_count"), g("latency/wall_total_ms")

⚠️ 这里踩了两个时序坑。elapsed_time_secondsfinish_reason 这两个值由 CoreStats 扩展在 spider_closed 里写,而我们的回调可能排在它前面,读出来就是 0 和空。耗时自己从 start_time 算,结束原因直接用信号传进来的 reason

报表长这样。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
======== 本次爬取报表 ========
结束原因        : finished
请求总数        : 64
响应 200        : 63
抓到的 item     : 60
下载的图片      : 60
图片失败        : 0
重试次数        : 0
重试放弃        : 0
UA 轮换次数     : 64
去重过滤        : 0
平均网络往返    : 266 ms(64 次)
平均端到端耗时  : 2994 ms(64 次,含排队)
总耗时          : 10.75 秒

最后聊两句日志。LOG_LEVEL 默认 INFOLOG_FILE 能把它落到文件。2.19 加了 LOG_COLORLOG_INSTALL_ROOT_HANDLER,终端上的颜色和根 logger 的接管都能配。

⚠️ 别图省事把 LOG_LEVEL 设成 DEBUG 然后跑一整天。媒体管道在 DEBUG 下会给每张图打三到四行,抓十万张图就是几十万行日志,磁盘写满只是时间问题。要看细节就开一次短跑,看完关掉。

容易栽的跟头

坑 1:把 CONCURRENT_REQUESTS_PER_DOMAIN 当成限速的主要手段。 这是这一篇里最贵的一个坑,因为我花了很长时间才定位到。实测数据是页级并发 1 加域级并发 8 跑了 5.09 秒,页级并发 32 加域级并发 1 跑了 1.07 秒。域级那个值在运行时的槽位里确实读到了,但它拦不住请求。在 2.19 上想控速就用 CONCURRENT_REQUESTSDOWNLOAD_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_secondsfinish_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_countitem_scraped_count、总耗时,三个数除以一下就是两个吞吐。改一个参数再跑一次,两个吞吐都动了才算改对了地方。

这一篇里的数字都是在一台机器上一次跑出来的,绝对值换台机器就不一样。留着它们是让你看趋势,不是当硬指标用。

这篇里要是有哪里讲得不对,欢迎拍砖。