3.1 调度器与去重指纹 本节摘要:调度器决定"下一张发哪张票",去重过滤器决定"这张票收不收"。本节讲清指纹的生成算法、三层队列的选型逻辑、优先级的生效规则,以及 dontfilter 这个"放行开关"的代价计算——候车大厅的全部机制,都在这一节。 Spider 车间里 yield 出的每一个 Request,进入的第一站就是这里。此前你只知道"引擎会去排队",本节把队列拆开:票怎么查重、怎么排优先级、存在内存还是磁盘。 去重指纹:一张票的身份证 每张新票到达调度器,先被算出指纹——把请求方法、地址、参数排序后拼接,取摘要: 这个设计能解释两个高频疑问。其一,"列表页内容更新了为什么抓到的还是旧数据"——不是缓存,是指纹还在,重发的请求被判重复直接拒收。
本节摘要:调度器决定"下一张发哪张票",去重过滤器决定"这张票收不收"。本节讲清指纹的生成算法、三层队列的选型逻辑、优先级的生效规则,以及 dont_filter 这个"放行开关"的代价计算——候车大厅的全部机制,都在这一节。
Spider 车间里 yield 出的每一个 Request,进入的第一站就是这里。此前你只知道"引擎会去排队",本节把队列拆开:票怎么查重、怎么排优先级、存在内存还是磁盘。
每张新票到达调度器,先被算出指纹——把请求方法、地址、参数排序后拼接,取摘要:
# 指纹生成的示意还原(实际在框架的请求指纹模块中) from hashlib import sha1 from urllib.parse import urlsplit, parse_qsl def fingerprint(request): parts = urlsplit(request.url) # 参数按名字排序后拼接:顺序不同、内容相同的地址算同一张票 query = sorted(parse_qsl(parts.query)) base = request.method.upper() + parts.netloc + parts.path for k, v in query: base += k + v return sha1(base.encode()).hexdigest()
# 同一地址的两种参数顺序,指纹相同 —— 只会被下载一次 GET books.example.com/list?page=2&sort=price GET books.example.com/list?sort=price&page=2 #指纹一致: a1b2c3d4...
这个设计能解释两个高频疑问。其一,"列表页内容更新了为什么抓到的还是旧数据"——不是缓存,是指纹还在,重发的请求被判重复直接拒收。其二,"为什么参数顺序不同的两个地址只抓了一个"——排序消除了顺序差异,这是特性不是缺陷。
默认指纹集合存在进程内存里,爬虫一停就清空。所以"重启后旧请求重新发"是正常行为,不是去重失效。要让去重跨会话生效,得把指纹落到外部存储——最常用的是借用 Redis 的集合,这也是第 6 章分布式爬取的基石:多台机器共享一个指纹集合,全集群不会重复下载。
# settings 配置模块:自定义去重类的挂载点 DUPEFILTER_CLASS = "myproject.dupefilter.RedisDupeFilter"
自己实现去重类时,守住两个方法即可:open_spider 时准备存储,request_seen 里查指纹、查不到就写入并返回 False。框架的调度循环会尊重你的答案。
调度器内部有三层去处,由两个配置键决定:
# settings 配置模块:调度队列选型 JOBDIR = "crawls/books-001" # 设置后启用磁盘队列,支持断点续抓 # 不设置则为纯内存队列:快,但重启即丢 SCHEDULER_PRIORITY_QUEUE = "scrapy.pqueues.ScrapyPriorityQueue" # 默认按优先级分堆
| 队列 | 优点 | 代价 | 适用 |
|---|---|---|---|
| 内存队列 | 速度快 | 重启全丢 | 一次性任务、小规模采集 |
| 磁盘队列(JOBDIR) | 断点续抓 | 写盘开销、任务目录管理 | 长任务、允许中断 |
| 外部队列(Redis 等) | 集群共享、持久 | 依赖额外组件 | 分布式,见第 6 章 |
priority 在默认队列里按"大值先发"生效。注意它与并发的关系:优先级只决定出队顺序,不决定同时有几个在飞——那是并发参数(下一节)的事。

默认查重严苛是正确的设计,但有两类请求必须放行:会随时间变化的内容页,和同一地址不同表单参数的 POST。做法是出票时标注 dont_filter=True。它的代价要想清楚:这张票每次 yield 都会真的发出去,若 Spider 里有循环缺陷,重复请求将无限累积。工程纪律是——用 dont_filter 的地方必须能数清理论请求量,数不清就不要用。
# 需要每次都真实发出的轮询页 yield scrapy.Request( "https://books.example.com/hot-rank", callback=self.parse_rank, dont_filter=True, # 排行榜每轮都抓最新 )
💡 关键直觉:去重是给"同一资源"看的,不是给"同一地址"看的。判断要不要放行,问自己"这次请求希望拿到与上次不同的内容吗"——答案是否,就让它接受查重。
把本节机制串成一个真实配置案例。背景:一个站点采集任务里混着三类票——详情页(要抓的数据本体)、翻页(纯过渡)、排行榜轮询(要每轮最新)。目标:详情页优先发车,翻页靠后,轮询不受去重约束。
def parse_list(self, response): # 详情页:优先级 100,先发 for href in response.css("a.book::attr(href)").getall(): yield response.follow(href, callback=self.parse_book, priority=100) # 翻页:优先级 10,垫底 nxt = response.css("a.next::attr(href)").get() if nxt: yield response.follow(nxt, callback=self.parse_list, priority=10)
# 轮询票:绕过去重 + 长任务配磁盘队列 def poll_rank(self): yield scrapy.Request( "https://books.example.com/hot", callback=self.parse_rank, dont_filter=True, priority=50, ) # settings 配置模块 JOBDIR = "crawls/books-001" # 断点续抓:重启后队列与指纹待办不丢
结果:日志里详情页地址先于翻页批量出现;轮询票每轮都真实发车且不占指纹名额;任务中途重启后,从断点目录接着跑,没有重复下载旧页。解读:三类票各得其所的关键在于把"要不要去重"与"什么时候发"当成两个独立决策——前者看内容是否变化,后者看业务主次。
变式:若站点按 IP 限流严格,优先级策略要反过来想——与其让详情页插队,不如把单域并发压低、总时长拉长,速度让位给通过率(4.3 节的节奏参数接管)。排队策略没有万能配置,只有与目标站姿态匹配的组合。
机制明白了,拨盘在哪里?下一节进 settings 配置模块,把行为边界一次配齐。