首页
/ Scrapy Scheduler 详解:请求调度、优先级队列与断点续爬的底层实现

Scrapy Scheduler 详解:请求调度、优先级队列与断点续爬的底层实现

2026-09-04 14:45:29作者:曹令琨Iris

本文围绕 Scrapy 的调度器组件(Scheduler)展开,它负责接收引擎分发的待爬请求、将其存入内存或磁盘队列,并在需要时把下一个待下载请求交还引擎。读完本文,你将理解调度器的最小接口与默认实现 scrapy.core.scheduler.Scheduler 的工作方式、各 SCHEDULER_* 配置项的默认值与取值来源、请求出队顺序(DFO/BFO/自定义)的成因,以及 JOBDIR 断点续爬时磁盘队列的文件布局,并掌握如何用源码路径定位每一处行为的实现依据。

一、调度器在 Scrapy 架构中的角色

根据官方文档 Scheduler,调度器组件接收来自引擎的请求,把它们存入持久化和(或)非持久化的数据结构中;当引擎索要下一个待下载请求时,调度器再把请求交还。请求的原始来源有三类(见 BaseScheduler 类文档 的注释):

  • Spider 的 start 方法、start_urls 产生的请求,以及回调中派生的请求;
  • Spider 中间件的 process_spider_output / process_spider_exception 方法;
  • 下载器中间件的 process_request / process_response / process_exception 方法。

从源码结构看,引擎与调度器的交互收敛在很窄的调用面上:

  • 引擎启动时通过 _get_scheduler_class 加载 SCHEDULER 设置指定的类,并校验其是否实现了调度器接口,不满足会抛出 TypeError(见 engine.py);
  • Spider 打开时,引擎调用 scheduler.open(spider),关闭时调用 scheduler.close(reason)(见 engine.py);
  • 引擎周期性调用 next_request() 获取待下载请求,取不到时发出 scheduler_empty 信号(见 engine.py);
  • Spider 产出新请求时,引擎调用 enqueue_request(request);返回 False 会触发 request_dropped 信号且不再重试(见 engine.py)。

调度器出队顺序直接决定了请求的下载顺序,这是后文“Request order”一节的主线。

二、替换默认调度器

使用自定义调度器只需在设置中提供其完整 Python 路径:

SCHEDULER = "myproject.schedulers.MyScheduler"

SCHEDULER 的默认值是 scrapy.core.scheduler.Scheduler(见 default_settings.py)。接口校验由元类 BaseSchedulerMeta 完成:它重写 __subclasscheck__,只检查候选类是否同时具备可调用的 has_pending_requestsenqueue_requestnext_request 三个方法(见 scheduler.py)。因此判断“是否是一个调度器”看的是鸭子类型,而非继承关系。

三、最小调度器接口:BaseScheduler

BaseScheduler 定义了引擎所依赖的最小接口,共三类方法:

方法 说明
from_crawler(cls, crawler) 工厂方法,接收当前 Crawler 对象。基类实现直接返回 cls()
open(spider) 引擎打开 Spider 时调用,用于执行初始化代码
close(reason) 引擎关闭 Spider 时调用,接收关闭原因,用于执行清理代码
has_pending_requests() 抽象方法。队列中还有待处理请求返回 True,否则 False
enqueue_request(request) 抽象方法。处理引擎送来的请求;返回 True 表示成功入队,False 时引擎发出 request_dropped 信号,且不再尝试稍后重新调度该请求
next_request() 抽象方法。返回下一个待处理的 Request,或返回 None 表示当前无就绪请求——这意味当前 reactor 周期内不会有请求发往下载器,引擎会持续调用 next_request 直到 has_pending_requestsFalse

其中 enqueue_request 的文档特别指出:默认调度器在请求被去重过滤器(dupefilter)拒绝时返回 False。这为理解默认实现埋下伏笔。

四、默认调度器 Scheduler:双队列 + 去重过滤

默认调度器 Schedulerscheduler.py)的核心行为可以概括为:

  1. 请求存入优先级队列SCHEDULER_PRIORITY_QUEUE),按 Request.priority 排序;
  2. 默认全部使用内存队列;使用 JOBDIR 时额外创建磁盘队列,此时只有无法序列化的请求才留在内存队列中,且相同优先级下内存中的请求优先于磁盘中的请求;
  3. 每个优先级队列按优先级值分桶:同一优先级对应一个内部队列,内存桶用 SCHEDULER_MEMORY_QUEUE,磁盘桶用 SCHEDULER_DISK_QUEUE;内部队列决定同优先级请求的出队顺序;
  4. 启动请求单独存放start 请求默认进入独立的内部队列,顺序规则不同于普通请求;
  5. 重复请求由 DUPEFILTER_CLASS 过滤

4.1 构造与 from_crawler

Scheduler.from_crawlerscheduler.py)从设置中读取并组装所有依赖:

@classmethod
def from_crawler(cls, crawler):
    dupefilter_cls = load_object(crawler.settings["DUPEFILTER_CLASS"])
    return cls(
        dupefilter=build_from_crawler(dupefilter_cls, crawler),
        jobdir=job_dir(crawler.settings),
        dqclass=load_object(crawler.settings["SCHEDULER_DISK_QUEUE"]),
        mqclass=load_object(crawler.settings["SCHEDULER_MEMORY_QUEUE"]),
        logunser=crawler.settings.getbool("SCHEDULER_DEBUG"),
        stats=crawler.stats,
        pqclass=load_object(crawler.settings["SCHEDULER_PRIORITY_QUEUE"]),
        crawler=crawler,
    )

__init__ 的 7 个参数与来源:

参数 来源设置 用途
dupefilter DUPEFILTER_CLASS 检查并过滤重复请求的实例
jobdir JOBDIR 持久化爬取状态的目录,为 None 时不启用磁盘队列
dqclass SCHEDULER_DISK_QUEUE 持久化请求队列类
mqclass SCHEDULER_MEMORY_QUEUE 非持久化请求队列类
logunser SCHEDULER_DEBUG 是否记录不可序列化请求的告警
stats STATS_CLASS(引擎注入 crawler.stats 记录调度过程统计
pqclass SCHEDULER_PRIORITY_QUEUE 优先级队列类

另外 _get_start_queue_cls 会读取 SCHEDULER_START_DISK_QUEUESCHEDULER_START_MEMORY_QUEUE,为启动请求单独装载内部队列类(scheduler.py)。

4.2 open / close 生命周期

  • open(spider):(1) 初始化内存队列 self.mqs;(2) 若存在 jobdir 则初始化磁盘队列 self.dqs;(3) 返回去重过滤器的 open() 结果(scheduler.py);
  • close(reason):若存在磁盘队列,先调用 self.dqs.close() 拿到状态并写入 active.json,再交给去重过滤器 close(reason)scheduler.py)。

4.3 入队:先磁盘、后内存的降级策略

enqueue_requestscheduler.py)的流程:

def enqueue_request(self, request):
    if not request.dont_filter and self.df.request_seen(request):
        self.df.log(request, self.spider)
        return False                      # 被去重过滤,触发 request_dropped
    dqok = self._dqpush(request)
    if dqok:
        self.stats.inc_value("scheduler/enqueued/disk")
    else:
        self._mqpush(request)
        self.stats.inc_value("scheduler/enqueued/memory")
    self.stats.inc_value("scheduler/enqueued")
    return True

关键点:

  • 请求设置了 dont_filter=True 时会跳过重复检查;
  • JOBDIR 时优先尝试压入磁盘队列 _dqpush;压入失败(典型原因是请求不可序列化,ValueError)则回落到内存队列 _mqpush
  • 不可序列化请求会计入 scheduler/unserializable 统计,且当 SCHEDULER_DEBUG=True 时会打印一次告警日志(之后不再重复打印,见 scheduler.py)。

4.4 出队:先内存、后磁盘

next_requestscheduler.py)先从内存队列 pop(),取不到再尝试磁盘队列;每步都递增 scheduler/dequeued/memoryscheduler/dequeued/diskscheduler/dequeued 统计。__len__ 返回内存与磁盘队列中请求总数之和(scheduler.py)。

这套"入队先磁盘、出队先内存"的组合解释了文档中的一句话:启用 JOBDIR 后,只有不可序列化的请求才留在内存队列,而同一优先级下内存请求优先于磁盘请求出队。

4.5 JOBDIR 下的目录内容

文档明确警告:调度器在 job 目录中生成的文件属于实现细节,可能随版本变化,不要用于调试之外的目的。启用 JOBDIR 时,该调度器会:

  • 在 job 目录内创建 requests.queue 目录,保存尚未下载的所有请求;
  • requests.queue/ 内生成 active.json,记录 SCHEDULER_PRIORITY_QUEUE 的状态(startprios)。文件在作业(干净地)停止时写出,恢复时读入——对应源码 _write_dqs_state / _read_dqs_statescheduler.py);
  • requests.queue/ 作为持久化目录(key)、SCHEDULER_DISK_QUEUE 作为下游队列类来实例化 SCHEDULER_PRIORITY_QUEUE,优先级队列还可能通过下游队列创建更多文件。

磁盘队列目录由 _dqdir 创建:jobdir 非空时在 <jobdir>/requests.queue 建目录(scheduler.py);恢复爬取时 _dq 会读回状态并打印 Resuming crawl (N requests scheduled)scheduler.py)。

结合任务持久化文档中给出的 job 目录示例,可以看清每一层目录的"作者":

├── requests.queue
|   ├── active.json                  # Scheduler 写出的优先级队列状态
|   └── {hostname}-{hash}            # DownloaderAwarePriorityQueue 按下载槽建目录
|       └── {priority}{s?}           # ScrapyPriorityQueue 按优先级建目录(s 表示启动请求)
|           ├── q{00000}              # 下游磁盘队列的数据文件
|           └── info.json
├── requests.seen                    # RFPDupeFilter 的已见请求
└── spider.state                     # SpiderState 扩展的 spider.state

也就是说:requests.queue/active.json 出自 Scheduler{hostname}-{hash} 子目录出自 DownloaderAwarePriorityQueue(目录名中路径非法字符替换为下划线并附加 MD5 后缀以避免冲突,见 pqueues.py_path_safe),{priority}{s?} 子目录出自 ScrapyPriorityQueue,最内层的 q{00000}/info.json 出自 scrapy.squeues 中继承 queuelib 磁盘队列的实现。完整说明见 jobs.rst

五、请求出队顺序:DFO 默认值、启动请求与并发影响

5.1 默认是 DFO 顺序

在默认设置下,待处理请求存入 LIFO 队列(启动请求除外),因此爬取呈现"深度优先"(DFO, Depth-First Order)——通常是最省事的爬取顺序。而默认值本身就印证了这一点,default_settings.py

SCHEDULER = "scrapy.core.scheduler.Scheduler"
SCHEDULER_DEBUG = False
SCHEDULER_DISK_QUEUE = "scrapy.squeues.PickleLifoDiskQueue"   # LIFO
SCHEDULER_MEMORY_QUEUE = "scrapy.squeues.LifoMemoryQueue"     # LIFO
SCHEDULER_PRIORITY_QUEUE = "scrapy.pqueues.DownloaderAwarePriorityQueue"
SCHEDULER_START_DISK_QUEUE = "scrapy.squeues.PickleFifoDiskQueue"  # 启动请求用 FIFO
SCHEDULER_START_MEMORY_QUEUE = "scrapy.squeues.FifoMemoryQueue"

scrapy.squeues 中的队列均基于 queuelib 构建:磁盘队列(如 PickleFifoDiskQueue)用 request.to_dict() 序列化后经 pickle/marshal 落盘,内存队列(FifoMemoryQueue/LifoMemoryQueue)不做序列化(见 squeues.py)。文档同时给出磁盘队列的其他可选类:scrapy.squeues.PickleFifoDiskQueuescrapy.squeues.MarshalFifoDiskQueuescrapy.squeues.MarshalLifoDiskQueue,内存队列另有 scrapy.squeues.FifoMemoryQueue(见 settings 文档)。

5.2 启动请求的顺序

start 请求按从 Spider.start 中 yield 的顺序发出;在优先级相同的情况下,普通请求优先于启动请求。从源码看,这一行为来自 ScrapyPriorityQueue 的分桶设计:push 时检查 request.meta.get("is_start_request", False),是启动请求且配置了 start_queue_cls 就放入独立的 _start_queuespqueues.py);磁盘持久化时启动请求桶目录名带 s 后缀(如 -1s),普通桶为负优先级值(如 -1)(pqueues.py)。

若想取消启动请求的独立排队规则,把 SCHEDULER_START_MEMORY_QUEUESCHEDULER_START_DISK_QUEUE 设为 None(或空串)即可,让启动请求与其他请求共享同一套顺序与优先级。注意 settings 文档中的提示:在启动请求数量较少、尚未达到 CONCURRENT_REQUESTS 上限之前,它们按 FIFO 顺序发送;一旦并发打满,剩余启动请求会按逆序发出(因为 LifoMemoryQueue 兜底)。

5.3 强制 BFO 顺序

要改为"广度优先"(BFO, Breadth-First Order),按文档设置:

DEPTH_PRIORITY = 1
SCHEDULER_DISK_QUEUE = "scrapy.squeues.PickleFifoDiskQueue"
SCHEDULER_MEMORY_QUEUE = "scrapy.squeues.FifoMemoryQueue"

原理是双管齐下:DEPTH_PRIORITY=1 让深度中间件用深度值派生 Request.priority(深度越深优先级越低);FIFO 内部队列保证同优先级请求按入队顺序出队,两者叠加才形成真正的逐层爬取。

5.4 手动指定自定义顺序

直接给请求设置 Request.priority 即可强制特定请求顺序——ScrapyPriorityQueue.priority(request) 返回 -request.priority,即数值越小优先级越高pqueues.py),且文档要求只使用整数优先级。

5.5 并发会打破前几个请求的顺序

当待处理请求数低于 CONCURRENT_REQUESTSCONCURRENT_REQUESTS_PER_DOMAIN 的设定值时,这些请求会同时发出,所以爬取的前几个请求可能不符合期望顺序。把上述设置调低到 1 可以强制顺序(除第一个请求外),但会显著拖慢整体爬取速度。

六、优先级队列:ScrapyPriorityQueue 与 DownloaderAwarePriorityQueue

6.1 ScrapyPriorityQueue:每个优先级一个内部队列

ScrapyPriorityQueue 用多个内部队列(通常是 FIFO)实现优先级队列,每个优先级值对应一个内部队列。内部队列需实现 push / pop / close / __len__ 四个方法,可选实现 peek。构造函数通过 downstream_queue_cls 参数指定"新开优先级桶"时使用的队列类;startprios 用于恢复此前关闭时仍非空的优先级桶。

核心方法的行为(结合 pqueues.py):

  • push:按 -request.priority 定位桶,桶不存在则用 qfactory(磁盘)或 _sqfactory(启动请求)创建;同时维护当前最小桶号 curprio 以加速出队;
  • pop:从 curprio 对应的桶弹出;普通桶弹空后若启动桶还有请求,则转而从启动桶取,否则调用 _update_curprio 重算最小优先级;
  • close:关闭所有桶并返回仍有请求的优先级集合(作为 startprios 状态,正是 active.json 里保存的数据);
  • peek:返回下一个将出队的请求但不移除,若底层队列未实现 peek 则抛出 NotImplementedError

6.2 DownloaderAwarePriorityQueue:感知下载器繁忙度

DownloaderAwarePriorityQueue 在优先级之上再叠加一层"下载槽"(slot,通常即域名)维度:活跃下载数最少的域名优先出队。实现上它维护 slot -> ScrapyPriorityQueue 的映射:

  • push 时通过 DownloaderInterface.get_slot_key(request) 得到下载槽,为每个槽建一个子优先级队列;
  • pop 时通过 DownloaderInterface.stats 收集各槽的活跃下载数,_next_slot 选出活跃数最少的槽(同值时还有轮转策略:优先选字母序上"上一次选中槽之后"的槽,避免长期偏向同一槽,见 pqueues.py);
  • 某槽的队列弹空后删除该槽的队列并尝试清理其目录;
  • 若设置了 CONCURRENT_REQUESTS_PER_IP != 0 会直接抛 ValueError,因为它不支持按 IP 限制并发(pqueues.py)。

文档也点明了取舍:并行爬取大量不同域名时,DownloaderAwarePriorityQueue 比裸的 ScrapyPriorityQueue 效果更好(见 settings 文档)。

七、配置速查与验证路径

汇总与调度相关的设置项(默认值均来自 default_settings.py,逐项说明见 settings 文档):

设置项 默认值 作用
SCHEDULER scrapy.core.scheduler.Scheduler 调度器类
SCHEDULER_DEBUG False True 时打印不可序列化请求的调试信息(目前仅打印一次)
SCHEDULER_DISK_QUEUE scrapy.squeues.PickleLifoDiskQueue 磁盘队列类(另有 PickleFifoDiskQueueMarshalFifoDiskQueueMarshalLifoDiskQueue
SCHEDULER_MEMORY_QUEUE scrapy.squeues.LifoMemoryQueue 内存队列类(另有 FifoMemoryQueue
SCHEDULER_PRIORITY_QUEUE scrapy.pqueues.DownloaderAwarePriorityQueue 优先级队列类
SCHEDULER_START_DISK_QUEUE scrapy.squeues.PickleFifoDiskQueue 启动请求专用磁盘队列,None/"" 可禁用独立队列
SCHEDULER_START_MEMORY_QUEUE scrapy.squeues.FifoMemoryQueue 启动请求专用内存队列,同上
DUPEFILTER_CLASS scrapy.dupefilters.RFPDupeFilter 去重过滤器,JOBDIR 下写入 requests.seen
JOBDIR None 启用后启用磁盘队列与断点续爬

相关测试用例覆盖了本文各论断:入队/出队数量与优先级、迁移、与 DownloaderAwarePriorityQueue 的集成及状态不兼容检查、无 crawler 场景、不可序列化请求,见 test_scheduler.py;优先级队列行为见 test_pqueues.py;队列的请求序列化见 test_squeues_request.py

八、小结

调度器是 Scrapy 中"请求排班"的决策者:最小接口只有 has_pending_requestsenqueue_requestnext_request 三件套;默认实现用"磁盘队列优先入队 + 内存队列优先出队"的双队列结构兼顾性能与断点续爬,用 ScrapyPriorityQueue 的分桶实现优先级,用 DownloaderAwarePriorityQueue 在域名维度上均衡下载负载。理解了"优先级取负值比较"、"启动请求独立分桶"、"LIFO 默认带来 DFO"这三个细节,再配合 DEPTH_PRIORITY 与队列类的组合,就能精确控制任何项目的爬取顺序与恢复策略。

登录后查看全文
热门项目推荐
相关项目推荐