Scrapy Scheduler 详解:请求调度、优先级队列与断点续爬的底层实现
本文围绕 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_requests、enqueue_request、next_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_requests 为 False |
其中 enqueue_request 的文档特别指出:默认调度器在请求被去重过滤器(dupefilter)拒绝时返回 False。这为理解默认实现埋下伏笔。
四、默认调度器 Scheduler:双队列 + 去重过滤
默认调度器 Scheduler(scheduler.py)的核心行为可以概括为:
- 请求存入优先级队列(
SCHEDULER_PRIORITY_QUEUE),按Request.priority排序; - 默认全部使用内存队列;使用
JOBDIR时额外创建磁盘队列,此时只有无法序列化的请求才留在内存队列中,且相同优先级下内存中的请求优先于磁盘中的请求; - 每个优先级队列按优先级值分桶:同一优先级对应一个内部队列,内存桶用
SCHEDULER_MEMORY_QUEUE,磁盘桶用SCHEDULER_DISK_QUEUE;内部队列决定同优先级请求的出队顺序; - 启动请求单独存放:
start请求默认进入独立的内部队列,顺序规则不同于普通请求; - 重复请求由
DUPEFILTER_CLASS过滤。
4.1 构造与 from_crawler
Scheduler.from_crawler(scheduler.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_QUEUE 与 SCHEDULER_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_request(scheduler.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_request(scheduler.py)先从内存队列 pop(),取不到再尝试磁盘队列;每步都递增 scheduler/dequeued/memory、scheduler/dequeued/disk、scheduler/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_state(scheduler.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.PickleFifoDiskQueue、scrapy.squeues.MarshalFifoDiskQueue、scrapy.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_queues(pqueues.py);磁盘持久化时启动请求桶目录名带 s 后缀(如 -1s),普通桶为负优先级值(如 -1)(pqueues.py)。
若想取消启动请求的独立排队规则,把 SCHEDULER_START_MEMORY_QUEUE 与 SCHEDULER_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_REQUESTS 或 CONCURRENT_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 |
磁盘队列类(另有 PickleFifoDiskQueue、MarshalFifoDiskQueue、MarshalLifoDiskQueue) |
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_requests、enqueue_request、next_request 三件套;默认实现用"磁盘队列优先入队 + 内存队列优先出队"的双队列结构兼顾性能与断点续爬,用 ScrapyPriorityQueue 的分桶实现优先级,用 DownloaderAwarePriorityQueue 在域名维度上均衡下载负载。理解了"优先级取负值比较"、"启动请求独立分桶"、"LIFO 默认带来 DFO"这三个细节,再配合 DEPTH_PRIORITY 与队列类的组合,就能精确控制任何项目的爬取顺序与恢复策略。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00