Scrapy 2.17 源码解析:Scheduler 调度器与磁盘/内存双队列

本文基于 Scrapy 2.17.0 的真实源码逐行分析。

其中 SCHEDULER_START_MEMORY_QUEUE / SCHEDULER_START_DISK_QUEUE(起始请求独立队列)是 2.13+ 才有的机制,旧版本没有,网上资料基本没覆盖。

目录

前言

上一篇 我们拆了 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 是否记录不可序列化请求

注意 pqclassdqclass / mqclass 不是同一层:前者是外层的优先级调度,后者是内层的实际存储。这个嵌套关系是理解整个调度器的关键。

DownloaderAwarePriorityQueue 本身的设计我之前单独写过,这里不重复:

Scrapy: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 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

三步:

  1. 先过去重 (除非 dont_filter=True),被判重则直接返回 False
  2. 尝试推进磁盘队列
  3. 磁盘失败则退回内存队列

第 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 的路径:

  • 没配 JOBDIRself.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_REQUESTS or CONCURRENT_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_dictrequest_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 四种组合不用写四遍序列化逻辑。

顺带一提,picklemarshal 两个模块的差异我之前拆过:

Python 序列化模块 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)

几个实用的判断:

  • enqueueddequeued → 爬虫被中断,队列里还有积压
  • 配了 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 的设计可以概括成五点:

  1. 接口极简 ------引擎只要 enqueue_request / next_request / has_pending_requests 三个方法,靠元类做结构检查
  2. 三层嵌套------优先级队列 → 按 priority 分桶 → 内存/磁盘内部队列
  3. 双介质互补------入队「磁盘优先、内存兜底」,出队「内存优先、磁盘兜底」,序列化失败自动降级不中断
  4. 起始请求独立成队(2.13+)------用 FIFO 保序,且优先级低于普通请求
  5. 续爬靠 active.json + 桶目录,只在干净退出时落盘

下次遇到「爬取顺序不对」「续爬丢请求」「去重把不该去的去掉了」,先看 scheduler/* 这几个 stats,基本能定位到具体是哪一层的问题。


参考


下一篇 :《知识拓展:RFPDupeFilter 与请求指纹算法详解》,把本文第四节一笔带过的 self.df.request_seen(request) 展开------指纹到底怎么算的,canonicalize_url 归一化了什么、又漏了什么(有两个反直觉的坑,实测给你看)。

如果这篇对你有帮助,点赞收藏是最大的支持。

相关推荐
晚风醉蝶1 小时前
1-11-奇偶排序-OddEvenSort
java·数据结构·算法
CODER03041 小时前
Anaconda和Mamba创建环境管理包常用命令合集
python·深度学习·conda·虚拟环境·mamba·常用命令大全
闲猫1 小时前
LangGraph / Capabilities / Fault tolerance
python·agent·langgraph
夏天拐跑了西瓜2 小时前
Spring Cloud 微服务实战(三):一个服务挂了,凭什么拖垮整个系统?Sentinel 限流熔断实战
java·spring cloud·微服务
IvanCodes2 小时前
Python 基础语法(一):变量、数据类型与运算符
python
2601_967264282 小时前
Jetpack Compose 实践指南:从入门到进阶
java·学习
m0_587383003 小时前
社区家政系统开发实战:从需求分析到上线部署全指南
java·spring boot·spring·需求分析
凤山老林3 小时前
动态 i18n 体系落地:Spring Boot 多租户热加载与前后端协同实践
java·spring boot·后端·i18n
程序员三藏3 小时前
浅谈性能测试
自动化测试·软件测试·python·测试工具·职场和发展·测试用例·性能测试