本文基于 Scrapy 2.17.0 的真实源码逐行分析。
其中
SCHEDULER_START_MEMORY_QUEUE/SCHEDULER_START_DISK_QUEUE(起始请求独立队列)是 2.13+ 才有的机制,旧版本没有,网上资料基本没覆盖。
目录
-
- 前言
- 一、`BaseScheduler`:只有三个必需方法
- 二、组件装配:六个可插拔的类
- 三、三层队列结构
- 四、`enqueue_request`:磁盘优先,内存兜底
- 五、`next_request`:内存优先,磁盘兜底
- [六、默认 LIFO:为什么是深度优先](#六、默认 LIFO:为什么是深度优先)
- 七、起始请求的独立队列(2.13+)
- [八、磁盘序列化:`to_dict` 与 `request_from_dict`](#八、磁盘序列化:
to_dict与request_from_dict) -
- [`ValueError` 是怎么来的](#
ValueError是怎么来的)
- [`ValueError` 是怎么来的](#
- [九、JOBDIR 断点续爬](#九、JOBDIR 断点续爬)
- [十、一个源码细节:`peek()` 和 `pop()` 的顺序不一致](#十、一个源码细节:
peek()和pop()的顺序不一致) - [十一、stats 指标怎么读](#十一、stats 指标怎么读)
- 十二、几个容易踩的坑
-
- [坑 1:以为 `dont_filter=True` 能跳过队列](#坑 1:以为
dont_filter=True能跳过队列) - [坑 2:`request.meta` 里塞不可序列化对象](#坑 2:
request.meta里塞不可序列化对象) - [坑 3:改了 BFO 却只改一个设置](#坑 3:改了 BFO 却只改一个设置)
- [坑 4:JOBDIR 目录复用](#坑 4:JOBDIR 目录复用)
- [坑 5:强杀进程丢状态](#坑 5:强杀进程丢状态)
- [坑 1:以为 `dont_filter=True` 能跳过队列](#坑 1:以为
- 总结
- 参考
前言
上一篇 我们拆了 ExecutionEngine,调度循环里最关键的一行是:
python
request = self._slot.scheduler.next_request()
引擎对调度器的要求极其简单------给我下一个请求。但这一行背后藏着三层队列、两套存储介质、一个去重器和一整套断点续爬机制。这篇我们钻进去。
一、BaseScheduler:只有三个必需方法
先看引擎对调度器的最小要求。scrapy/core/scheduler.py:
python
class BaseSchedulerMeta(type):
"""Metaclass to check scheduler classes against the necessary interface"""
def __instancecheck__(cls, instance: Any) -> bool:
return cls.__subclasscheck__(type(instance))
def __subclasscheck__(cls, subclass: type) -> bool:
return (
hasattr(subclass, "has_pending_requests")
and callable(subclass.has_pending_requests)
and hasattr(subclass, "enqueue_request")
and callable(subclass.enqueue_request)
and hasattr(subclass, "next_request")
and callable(subclass.next_request)
)
class BaseScheduler(metaclass=BaseSchedulerMeta):
...
这是上一篇末尾留的钩子:issubclass 被元类改写成了结构检查,只要有这三个方法就算「是」调度器,不需要真的继承。
完整接口一共 6 个方法,但只有 3 个是硬性的:
| 方法 | 是否必需 | 职责 |
|---|---|---|
enqueue_request(request) |
✅ | 入队,返回 bool 表示是否成功存下 |
next_request() |
✅ | 出队,队列空时返回 None |
has_pending_requests() |
✅ | 是否还有待处理请求 |
from_crawler(crawler) |
❌ | 工厂方法 |
open(spider) |
❌ | 爬虫启动时初始化 |
close(reason) |
❌ | 爬虫关闭时收尾 |
回想上一篇引擎里的这段:
python
if hasattr(scheduler, "open") and (d := scheduler.open(self.crawler.spider)):
await maybe_deferred_to_future(d)
hasattr 判断正是因为 open / close 是可选的。写第三方调度器时,最少实现三个方法就能跑。
二、组件装配:六个可插拔的类
from_crawler 一次性把所有可替换部件装配起来:
python
@classmethod
def from_crawler(cls, crawler: Crawler) -> Self:
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,
)
对应的默认值(scrapy/settings/default_settings.py):
| 设置项 | 默认值 | 作用 |
|---|---|---|
SCHEDULER_PRIORITY_QUEUE |
DownloaderAwarePriorityQueue |
外层优先级队列 |
SCHEDULER_MEMORY_QUEUE |
LifoMemoryQueue |
内层内存队列 |
SCHEDULER_DISK_QUEUE |
PickleLifoDiskQueue |
内层磁盘队列 |
SCHEDULER_START_MEMORY_QUEUE |
FifoMemoryQueue |
起始请求的内存队列 |
SCHEDULER_START_DISK_QUEUE |
PickleFifoDiskQueue |
起始请求的磁盘队列 |
DUPEFILTER_CLASS |
RFPDupeFilter |
去重器 |
JOBDIR |
None |
断点续爬目录 |
SCHEDULER_DEBUG |
False |
是否记录不可序列化请求 |
注意 pqclass 和 dqclass / mqclass 不是同一层:前者是外层的优先级调度,后者是内层的实际存储。这个嵌套关系是理解整个调度器的关键。
DownloaderAwarePriorityQueue本身的设计我之前单独写过,这里不重复:
三、三层队列结构

从外到内:
第 1 层:Scheduler 本身持有两个优先级队列
python
def open(self, spider: Spider) -> Deferred[None] | None:
self.spider: Spider = spider
self.mqs: ScrapyPriorityQueue = self._mq()
self.dqs: ScrapyPriorityQueue | None = self._dq() if self.dqdir else None
return self.df.open()
self.mqs------ 内存优先级队列,永远存在self.dqs------ 磁盘优先级队列,只有配了JOBDIR才存在
第 2 层:每个优先级队列内部按 priority 分桶
python
class ScrapyPriorityQueue:
def __init__(self, crawler, downstream_queue_cls, key,
startprios=(), *, start_queue_cls=None):
self.downstream_queue_cls = downstream_queue_cls
self._start_queue_cls = start_queue_cls
self.key = key
self.queues: dict[int, QueueProtocol] = {}
self._start_queues: dict[int, QueueProtocol] = {}
self.curprio: int | None = None
self.init_prios(startprios)
queues 是 {优先级: 队列} 的字典------每个不同的 priority 值单独开一个内部队列。
第 3 层:内部队列才是真正存数据的地方
LifoMemoryQueue(内存)或 PickleLifoDiskQueue(磁盘)。这两个都是在 queuelib 的基础队列上包了一层,底层实现我之前单独写过:
优先级的符号约定
python
def priority(self, request: Request) -> int:
return -request.priority
取了负号。 所以 Request(priority=10) 在内部存成 -10。配合:
python
if self.curprio is None or priority < self.curprio:
self.curprio = priority
curprio 取的是最小值 。也就是说:request.priority 越大 → 内部值越小 → 越先被取出。
类文档里写着 "Only integer priorities should be used. Lower numbers are higher priorities."------这句说的是内部值,不是
request.priority。对用户而言仍然是「priority 越大越优先」,别搞反了。
四、enqueue_request:磁盘优先,内存兜底
python
def enqueue_request(self, request: Request) -> bool:
if not request.dont_filter and self.df.request_seen(request):
self.df.log(request, self.spider)
return False
dqok = self._dqpush(request)
assert self.stats is not None
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),被判重则直接返回False - 尝试推进磁盘队列
- 磁盘失败则退回内存队列
第 3 步的降级逻辑藏在 _dqpush 里:
python
def _dqpush(self, request: Request) -> bool:
if self.dqs is None:
return False
try:
self.dqs.push(request)
except ValueError as e: # non serializable request
if self.logunser:
msg = (
"Unable to serialize request: %(request)s - reason:"
" %(reason)s - no more unserializable requests will be"
" logged (stats being collected)"
)
logger.warning(msg, {"request": request, "reason": e},
exc_info=True, extra={"spider": self.spider})
self.logunser = False
assert self.stats is not None
self.stats.inc_value("scheduler/unserializable")
return False
return True
两条返回 False 的路径:
- 没配
JOBDIR→self.dqs is None→ 所有请求都走内存 - 请求序列化失败 →
ValueError→ 这一条走内存,其余照常落盘
第二条是关键:磁盘队列不是「全有或全无」。一个带了 lambda 回调或数据库连接对象的请求塞不进 pickle,它会被静默降级到内存,不影响其他请求持久化。
注意 self.logunser = False 这行------只警告第一次 ,之后靠 scheduler/unserializable 这个统计项计数。这是防止日志被刷屏的常见手法。
排查「为什么断点续爬丢了一部分请求」时,第一件事就是看
scheduler/unserializable是不是非零。
五、next_request:内存优先,磁盘兜底
python
def next_request(self) -> Request | None:
request: Request | None = self.mqs.pop()
assert self.stats is not None
if request is not None:
self.stats.inc_value("scheduler/dequeued/memory")
else:
request = self._dqpop()
if request is not None:
self.stats.inc_value("scheduler/dequeued/disk")
if request is not None:
self.stats.inc_value("scheduler/dequeued")
return request
出队和入队的介质优先级是反的:
| 操作 | 顺序 |
|---|---|
enqueue_request |
磁盘 → 内存 |
next_request |
内存 → 磁盘 |

看起来矛盾,其实很合理。类文档里点明了:
For a given priority value, requests in memory take precedence over requests in disk.
内存队列里装的是序列化失败的请求。这些请求本来就是「异类」,让它们优先被消费掉,可以尽早把它们从内存里清出去------否则它们会一直占着内存直到爬取结束。
六、默认 LIFO:为什么是深度优先
默认配置是 LifoMemoryQueue + PickleLifoDiskQueue,后进先出。
结果就是 DFO(深度优先)爬取顺序:抓到一个列表页,解析出 20 个详情页链接,最后 yield 的那个会最先被抓。
类文档给出了切成 BFO(广度优先)的完整配方:
python
# settings.py ------ 切换到广度优先
DEPTH_PRIORITY = 1
SCHEDULER_DISK_QUEUE = "scrapy.squeues.PickleFifoDiskQueue"
SCHEDULER_MEMORY_QUEUE = "scrapy.squeues.FifoMemoryQueue"
三个都要改,只改一个没用。 原因是 DEPTH_PRIORITY = 1 让深度越大的请求 priority 越低(进不同的桶),而同一个桶内部的顺序仍由 FIFO/LIFO 决定。
一个容易被忽略的前提
文档里还有一段提醒:
While pending requests are below the configured values of
CONCURRENT_REQUESTSorCONCURRENT_REQUESTS_PER_DOMAIN, those requests are sent concurrently. As a result, the first few requests of a crawl may not follow the desired order.
并发本身会打乱顺序。 队列只决定「取出顺序」,取出后 16 个请求是并行下载的,谁先返回不确定。想严格按序,只能把并发降到 1------代价是速度。
七、起始请求的独立队列(2.13+)
这是新版本才有的机制。回看 __init__:
python
self._sdqclass: type[BaseQueue] | None = self._get_start_queue_cls(crawler, "DISK")
self._smqclass: type[BaseQueue] | None = self._get_start_queue_cls(crawler, "MEMORY")
def _get_start_queue_cls(self, crawler: Crawler | None, queue: str) -> type[BaseQueue] | None:
if crawler is None:
return None
cls = crawler.settings[f"SCHEDULER_START_{queue}_QUEUE"]
if not cls:
return None
return cast("type[BaseQueue]", load_object(cls))
于是优先级队列内部维护了两套桶:
python
def push(self, request: Request) -> None:
priority = self.priority(request)
is_start_request = request.meta.get("is_start_request", False)
if is_start_request and self._start_queue_cls:
if priority not in self._start_queues:
self._start_queues[priority] = self._sqfactory(priority)
q = self._start_queues[priority]
else:
if priority not in self.queues:
self.queues[priority] = self.qfactory(priority)
q = self.queues[priority]
q.push(request) # this may fail (eg. serialization error)
if self.curprio is None or priority < self.curprio:
self.curprio = priority
靠 request.meta["is_start_request"] 分流。默认配置下:
| 请求类型 | 队列类型 | 顺序 |
|---|---|---|
| 普通请求 | LifoMemoryQueue |
LIFO(深度优先) |
| 起始请求 | FifoMemoryQueue |
FIFO(按 yield 顺序) |
为什么起始请求要用 FIFO? 因为 Spider.start() 里 yield 的顺序通常是有意义的(比如按页码、按分类),用 LIFO 会把它倒过来,反直觉。
而 pop() 让普通请求优先于起始请求:
python
def pop(self) -> Request | None:
while self.curprio is not None:
try:
q = self.queues[self.curprio] # 先查普通队列
except KeyError:
pass
else:
m = q.pop()
if not q:
del self.queues[self.curprio]
q.close()
if not self._start_queues:
self._update_curprio()
return m
if self._start_queues: # 普通队列空了才查起始队列
try:
q = self._start_queues[self.curprio]
except KeyError:
self._update_curprio()
else:
m = q.pop()
if not q:
del self._start_queues[self.curprio]
q.close()
self._update_curprio()
return m
else:
self._update_curprio()
return None
对应文档里那句:
given the same
priority, other requests take precedence over start requests
含义是:优先把已经抓到的链接消化完,再去开新的起始页。这样能避免起始请求一次性铺开太多、导致中间状态堆积。
想恢复旧行为(起始请求和普通请求一视同仁),把这两个设置置空即可:
python
SCHEDULER_START_MEMORY_QUEUE = None
SCHEDULER_START_DISK_QUEUE = None
八、磁盘序列化:to_dict 与 request_from_dict
磁盘队列没法直接存 Request 对象,得先转字典。这层转换用装饰器工厂动态生成(scrapy/squeues.py):
python
def _scrapy_serialization_queue(queue_class):
class ScrapyRequestQueue(queue_class):
def __init__(self, crawler: Crawler, key: str):
self.spider = crawler.spider
super().__init__(key)
@classmethod
def from_crawler(cls, crawler: Crawler, key: str, *args, **kwargs) -> Self:
return cls(crawler, key)
def push(self, request: Request) -> None:
request_dict = request.to_dict(spider=self.spider)
super().push(request_dict)
def pop(self) -> Request | None:
request = super().pop()
if not request:
return None
return request_from_dict(request, spider=self.spider)
return ScrapyRequestQueue
四个磁盘队列类都是这么生成出来的:
python
PickleFifoDiskQueue = _scrapy_serialization_queue(_PickleFifoSerializationDiskQueue)
PickleLifoDiskQueue = _scrapy_serialization_queue(_PickleLifoSerializationDiskQueue)
MarshalFifoDiskQueue = _scrapy_serialization_queue(_MarshalFifoSerializationDiskQueue)
MarshalLifoDiskQueue = _scrapy_serialization_queue(_MarshalLifoSerializationDiskQueue)
这种「用函数生成类」的写法在 Scrapy 里很常见,好处是 FIFO/LIFO × Pickle/Marshal 四种组合不用写四遍序列化逻辑。
顺带一提,
pickle和marshal两个模块的差异我之前拆过:
ValueError 是怎么来的
python
def _pickle_serialize(obj: Any) -> bytes:
try:
return pickle.dumps(obj, protocol=4)
# Both pickle.PicklingError and AttributeError can be raised by pickle.dump(s)
# TypeError is raised from parsel.Selector
except (pickle.PicklingError, AttributeError, TypeError) as e:
raise ValueError(str(e)) from e
三种底层异常被统一转成 ValueError,_dqpush 才能用一个 except ValueError 全接住。注释还特意标了 TypeError 来自 parsel.Selector------把 Selector 塞进 request.meta 是导致落盘失败的经典原因之一。
九、JOBDIR 断点续爬
目录结构
配了 JOBDIR=crawls/myspider 之后:
crawls/myspider/
├── requests.queue/ # 调度器的磁盘队列
│ ├── active.json # 关闭时的优先级状态(startprios)
│ ├── -1/ # 优先级 -1 的普通请求桶
│ ├── -1s/ # 优先级 -1 的起始请求桶(注意 s 后缀)
│ └── 0/
└── requests.seen # 去重指纹,一行一条
s 后缀就是起始请求桶的标记:
python
def qfactory(self, key: int) -> QueueProtocol:
return build_from_crawler(self.downstream_queue_cls, self.crawler,
self.key + "/" + str(key))
def _sqfactory(self, key: int) -> QueueProtocol:
return build_from_crawler(self._start_queue_cls, self.crawler,
f"{self.key}/{key}s")
状态的存与取
python
def close(self, reason: str) -> Deferred[None] | None:
if self.dqs is not None:
state = self.dqs.close()
self._write_dqs_state(self.dqdir, state)
return self.df.close(reason)
def _read_dqs_state(self, dqdir: str) -> Any:
path = Path(dqdir, "active.json")
if not path.exists():
return ()
with path.open(encoding="utf-8") as f:
return json.load(f)
def _write_dqs_state(self, dqdir: str, state: Any) -> None:
with Path(dqdir, "active.json").open("w", encoding="utf-8") as f:
json.dump(state, f)
ScrapyPriorityQueue.close() 返回的是还有数据的优先级列表:
python
def close(self) -> list[int]:
active: set[int] = set()
for queues in (self.queues, self._start_queues):
for p, q in queues.items():
active.add(p)
q.close()
return list(active)
重启时读回来,用 init_prios 把这些桶重新挂上:
python
def _dq(self) -> ScrapyPriorityQueue:
state = self._read_dqs_state(self.dqdir)
q = build_from_crawler(
self.pqclass, self.crawler,
downstream_queue_cls=self.dqclass,
key=self.dqdir,
startprios=state,
start_queue_cls=self._sdqclass,
)
if q:
logger.info("Resuming crawl (%(queuesize)d requests scheduled)",
{"queuesize": len(q)}, extra={"spider": self.spider})
return q
看到 Resuming crawl (N requests scheduled) 这条日志,就说明续爬生效了。
⚠️ 注意
close()里那句「dump pending requests to disk」------只有干净退出(Ctrl+C一次、等它收尾)才会写active.json。强杀进程(
kill -9或连按两次Ctrl+C)会丢掉状态文件,桶目录还在但startprios为空,重启时那些请求就读不回来了。
十、一个源码细节:peek() 和 pop() 的顺序不一致
ScrapyPriorityQueue 提供了可选的 peek():
python
def peek(self) -> Request | None:
if self.curprio is None:
return None
try:
queue = self._start_queues[self.curprio] # 先查起始队列
except KeyError:
queue = self.queues[self.curprio]
return cast("Request", queue.peek())
对比第七节的 pop()------它先查 self.queues(普通队列)。两者顺序恰好相反。
也就是说,当同一个 curprio 下两种桶都非空时:
peek()返回起始队列的队头pop()返回的却是普通队列的队头
peek() 的返回值和下一次 pop() 的返回值会不一致 ,这与 peek 的语义("the next object to be returned by pop")冲突。
不过实际影响有限:在 Scrapy 自身的代码里 peek() 没有被任何调度路径调用,它是留给第三方组件的公开 API。如果你在自定义调度器或监控扩展里用 peek() 做预判,这个差异需要留意。
十一、stats 指标怎么读
调度器埋了 7 个统计项,爬完在日志尾部能看到:
| 指标 | 含义 |
|---|---|
scheduler/enqueued |
入队总数 |
scheduler/enqueued/disk |
其中落盘的 |
scheduler/enqueued/memory |
其中进内存的 |
scheduler/dequeued |
出队总数 |
scheduler/dequeued/disk |
其中从磁盘取的 |
scheduler/dequeued/memory |
其中从内存取的 |
scheduler/unserializable |
序列化失败被迫走内存的 |
dupefilter/filtered |
被去重器拦掉的(来自 dupefilter) |
几个实用的判断:
enqueued≫dequeued→ 爬虫被中断,队列里还有积压- 配了 JOBDIR 但
enqueued/memory很高 → 大量请求序列化失败,续爬会丢 unserializable非零 → 去查request.meta里塞了什么不可 pickle 的对象dupefilter/filtered异常高 → URL 里可能有随机参数(时间戳、token),需要自定义指纹
十二、几个容易踩的坑
坑 1:以为 dont_filter=True 能跳过队列
dont_filter 只跳过去重 ,请求照样入队、照样排优先级。想插队得改 priority。
坑 2:request.meta 里塞不可序列化对象
最常见的三类:
python
# ❌ 都会导致落盘失败
yield Request(url, meta={"selector": response.css("div")}) # parsel.Selector
yield Request(url, meta={"conn": db_connection}) # 数据库连接
yield Request(url, meta={"cb": lambda x: x}) # lambda
配了 JOBDIR 时这些请求会静默降级到内存,重启就没了。开 SCHEDULER_DEBUG = True 能看到第一条警告。
坑 3:改了 BFO 却只改一个设置
DEPTH_PRIORITY / SCHEDULER_DISK_QUEUE / SCHEDULER_MEMORY_QUEUE 必须三个一起改 ,只改队列类不改 DEPTH_PRIORITY,不同深度的请求仍在不同的桶里,效果打折。
坑 4:JOBDIR 目录复用
同一个 JOBDIR 只能给一个爬虫 用。两个爬虫共用会互相读到对方的 requests.seen,导致大面积误去重。
坑 5:强杀进程丢状态
前面说过,active.json 只在干净关闭时写。生产环境务必用 SIGTERM 或单次 Ctrl+C,给它收尾时间。
总结
Scheduler 的设计可以概括成五点:
- 接口极简 ------引擎只要
enqueue_request/next_request/has_pending_requests三个方法,靠元类做结构检查 - 三层嵌套------优先级队列 → 按 priority 分桶 → 内存/磁盘内部队列
- 双介质互补------入队「磁盘优先、内存兜底」,出队「内存优先、磁盘兜底」,序列化失败自动降级不中断
- 起始请求独立成队(2.13+)------用 FIFO 保序,且优先级低于普通请求
- 续爬靠
active.json+ 桶目录,只在干净退出时落盘
下次遇到「爬取顺序不对」「续爬丢请求」「去重把不该去的去掉了」,先看 scheduler/* 这几个 stats,基本能定位到具体是哪一层的问题。
参考
- Scrapy 源码:scrapy/core/scheduler.py
- Scrapy 源码:scrapy/pqueues.py
- Scrapy 源码:scrapy/squeues.py
- 官方文档:Jobs: pausing and resuming crawls
- 官方文档:Settings reference
下一篇 :《知识拓展:RFPDupeFilter 与请求指纹算法详解》,把本文第四节一笔带过的 self.df.request_seen(request) 展开------指纹到底怎么算的,canonicalize_url 归一化了什么、又漏了什么(有两个反直觉的坑,实测给你看)。
如果这篇对你有帮助,点赞收藏是最大的支持。