bigywater.tech
Scrapy · 1

引擎主循环:一个请求从调度器到下载器再回到 spider 的路径

一个 Request 从 spider 出发,经过调度器、下载器、爬虫中间件、回调、pipeline 再回来,每一站到底按什么顺序、由谁推着走?这篇给 Scrapy 2.19 的每一层挂上钩子,把一次抓取的完整轨迹按毫秒打出来,再用实验验证调度顺序、并发上限和“跑完”的判断。

29 个
两个请求的抓取里,各层钩子与信号一共触发的事件数
7.34 → 0.34 s
CONCURRENT_REQUESTS 从 1 增到 24,25 个请求(每个 0.3 s)的总耗时
≈5 s
DontCloseSpider 之后 spider_idle 重复触发的间隔(4997 / 4995 ms)

一个请求的完整路径

实测 · 本机执行输出
    序号      相对时间  事件 / 对象
     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() 塞新请求则会立刻被处理。

分步演示

请求的路径
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Python 3.14.8 / Scrapy 2.19.0 / Twisted 26.4.0 / lxml 6.1.3 / parsel 1.12.1(临时虚拟环境);所有请求只发给本机自己起的测试服务器(127.0.0.1,端口 9960 以上,不访问任何真实网站);服务器可以按实验需要制造延迟、重定向、压缩、断连等行为,并记录收到的原始请求;每个实验在临时目录里新建 Scrapy 项目或单文件 spider,命令输出来自真实的 scrapy 进程;计时用 time.perf_counter,数字每次运行会有小幅抖动。第一节:自己写的最小 spider,挂了扩展(信号)、下载器中间件、爬虫中间件、pipeline;第二节:树状站点,CONCURRENT_REQUESTS=1;第三节:服务器端 time.sleep(0.3) 制造延迟,峰值是服务器端同时在处理的请求数;第五节:signals.spider_idle + DontCloseSpider
    • 没有验证:多个域名同时抓取时的调度顺序;优先级(Request.priority)与深度的组合(见后面的文章);磁盘队列(JOBDIR)下的顺序;网络延迟不是 0 时下载器与回调的交错程度;scheduler_empty 在真实负载下的触发频率;异步回调(async def)对引擎循环的影响(见后面的文章);Scrapy 2.19 之外的版本
    京ICP备2026067502号-1京公网安备11010502063487号