一个 Request 从 spider 出发,经过调度器、下载器、爬虫中间件、回调、pipeline 再回来,每一站到底按什么顺序、由谁推着走?这篇给 Scrapy 2.19 的每一层挂上钩子,把一次抓取的完整轨迹按毫秒打出来,再用实验验证调度顺序、并发上限和“跑完”的判断。
序号 相对时间 事件 / 对象
1 201ms signal:spider_opened
2 202ms signal:engine_started
3 202ms 爬虫中间件 process_start
4 202ms spider.start() 产出 /
5 202ms signal:request_scheduled /
6 202ms 下载器中间件 process_request /
7 203ms signal:request_reached_downloader /
8 206ms signal:request_left_downloader /
9 206ms 下载器中间件 process_response /
10 206ms signal:response_received /
11 206ms 爬虫中间件 process_spider_input /
12 207ms callback parse /
13 207ms 爬虫中间件 process_spider_output dict
14 207ms 爬虫中间件 process_spider_output Request next
15 207ms pipeline process_item {'page': 'home'}
16 207ms signal:request_scheduled next
17 207ms signal:item_scraped {'page': 'home'}
18 207ms 下载器中间件 process_request next
19 208ms signal:request_reached_downloader next
20 208ms signal:request_left_downloader next
21 208ms 下载器中间件 process_response next
22 208ms signal:response_received next
23 209ms 爬虫中间件 process_spider_input next
24 209ms callback parse2 next
25 209ms 爬虫中间件 process_spider_output dict
26 209ms pipeline process_item {'page': 'next'}
27 209ms signal:item_scraped {'page': 'next'}
28 209ms signal:spider_idle
29 210ms signal:spider_closed
共 29 个事件;服务器收到 2 个请求:['/', '/next']引擎(scrapy/core/engine.py)把 spider.start() 产出的 Request 交给调度器,再从调度器取出交给下载器。下载器中间件、爬虫中间件、回调、pipeline 各自在固定的位置被调用:process_request → 下载 → process_response → process_spider_input → 回调 → process_spider_output → item 去 pipeline、Request 回调度器。上面的记录能验证每一步的先后,也能看到信号 request_reached_downloader、response_received、item_scraped 在哪个点发出。
默认:服务器收到请求的顺序 / /b /a /b2 /a2 /a1 /b1
默认设置下的交错(下载与回调是流水线,下载器一空出来引擎就取下一个请求,不等上一个回调):
开始下载/ 回调开始/ 开始下载b 开始下载a 回调开始b 开始下载b2 回调开始a 开始下载a2 回调开始b2 开始下载a1 回调开始a2 开始下载b1 回调开始a1 回调开始b1
只设 DEPTH_PRIORITY=1:服务器收到请求的顺序 / /b /a /b2 /a2 /a1 /b1
只换成 FIFO 队列:服务器收到请求的顺序 / /a /b /a1 /a2 /b1 /b2
DEPTH_PRIORITY=1 + FIFO 队列:服务器收到请求的顺序 / /a /b /a1 /a2 /b1 /b2默认队列是后进先出,但因为“下载”和“回调”是流水线(下载器一空出来引擎就去取下一个请求,不等上一个回调跑完),并发为 1 时也不是严格的深度优先。要广度优先,换成 FIFO 的内存队列和磁盘队列;只改 DEPTH_PRIORITY 在这个例子里没有改变顺序。
CONCURRENT_REQUESTS 服务器同时处理的峰值 总耗时(秒,页面全部下完) 理想值 24×0.3/N
1 1 7.34 7.20 (response_received_count=25)
4 4 1.88 1.80 (response_received_count=25)
8 8 0.94 0.90 (response_received_count=25)
24 24 0.34 0.30 (response_received_count=25)引擎只要下载器还有空位(needs_backout() 为假)就继续从调度器取请求。CONCURRENT_REQUESTS 就是这个空位数。实测总耗时几乎等于 24×0.3/N 再加几十毫秒。(同一域名还有一个 CONCURRENT_REQUESTS_PER_DOMAIN,在默认的 asyncio reactor 下的实际效果见「并发控制」那一篇。)
yield 一个 dict:服务器收到 ['/'];item_scraped_count=1;spider_exceptions/count=0
被当成 item 的内容:["{'a': 1}"];日志里没有 ERROR
yield 一个 Request:服务器收到 ['/', '/y'];item_scraped_count=0;spider_exceptions/count=0
被当成 item 的内容:(无);日志里没有 ERROR
return None(什么都不写):服务器收到 ['/'];item_scraped_count=0;spider_exceptions/count=0
被当成 item 的内容:(无);日志里没有 ERROR
return 一个列表 [dict, Request]:服务器收到 ['/', '/y'];item_scraped_count=2;spider_exceptions/count=0
被当成 item 的内容:["{'a': 1}", "{'a': 1}"];日志里没有 ERROR
return 一个字符串:服务器收到 ['/'];item_scraped_count=1;spider_exceptions/count=0
被当成 item 的内容:['abc'];日志里没有 ERROR
yield 一个整数:服务器收到 ['/'];item_scraped_count=1;spider_exceptions/count=0
被当成 item 的内容:['42'];日志里没有 ERROR引擎只区分“是不是 Request”:是就调度,不是就当成 item 交给 pipeline。它自己不检查 item 的类型,所以字符串和整数也会被计入 item_scraped_count;类型检查是 pipeline 或导出器的事。
153 ms e=parse url=http://127.0.0.1:9960/
154 ms e=spider_idle n=1 empties=4
5151 ms e=spider_idle n=2 empties=5
5152 ms e=在第 2 次 idle 里塞进一个新请求
5155 ms e=late 回调 url=http://127.0.0.1:9960/late
5156 ms e=spider_idle n=3 empties=8
10151 ms e=spider_idle n=4 empties=9
相邻两次 spider_idle 的间隔(ms):[4997, 5, 4995];finish_reason=finished;服务器收到 ['/', '/late']下载器、scraper、start()、调度器全空才算 idle。处理 spider_idle 的函数可以抛 DontCloseSpider 让爬虫继续活着(常见于从外部队列取任务),这种情况下引擎靠 5 秒一次的心跳重新检查;在 idle 里用 engine.crawl() 塞新请求则会立刻被处理。