第一次写采集,基本都是一个 for 循环加 requests。站点小、页面少的时候这没问题。真正撑不住的时候,往往不是慢,而是没法扩展——想加快只能堆循环,想加机器却无处下手。
三个卡点
单进程的爬虫把三样东西都存在自己的内存里,它们一起失效:
- 重复抓取 去重靠进程内的
set,进程一重启就清空,同一批链接会从零开始再抓一遍。 - 任务分不下去 想再起一个进程分担,但没有共享的待抓队列,只能手工把链接列表切成两半——新链接进来就没法动态分配了。
- 中断不能续跑 跑到一半进程挂了或机器重启,前面几万条全部作废。
三个问题指向同一件事:状态得从进程里搬出去。只要待抓队列和已抓指纹活在一个进程外面,加机器就是多起一个进程,重启就是接着跑。
换掉哪三样
Scrapy 本身有调度器、去重器、起始队列,但都局限在单进程内存中。scrapy-redis 做的事不是"给 Scrapy 加个 Redis 插件",而是把这三个组件各给了一份 Redis 实现:
| 组件 | 默认实现 | 换成 |
|---|---|---|
| 调度器 | 内存里的优先队列 | Redis list,多进程共享 |
| 去重器 | 内存 set | Redis set,存请求指纹 |
| 起始 URL | start_urls 列表 | 一个 Redis key,随时用 lpush 追加 |
spider 从 Spider 换成 RedisSpider,不再需要 start_urls,而是一直阻塞等待那个 key。起几个进程就是几个 worker,接到的是同一份队列,谁也不会重复处理同一条。
核心配置
# settings.py
SCHEDULER = "scrapy_redis.scheduler.Scheduler"
DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter"
SCHEDULER_PERSIST = True # 关键:关闭时不清队列
REDIS_URL = "redis://127.0.0.1:6379"
# spider.py
from scrapy_redis.spiders import RedisSpider
class SiteSpider(RedisSpider):
name = "site"
redis_key = "site:start_urls"
def parse(self, response):
yield {"url": response.url, "title": response.css("title::text").get()}
SCHEDULER_PERSIST = True 这四行里最要紧。它默认是 False,spider 关闭时会把待抓队列和去重集合一起删掉——那样"断点续跑"这件事等于没做,重启后一切归零。
跑起来之后
配置改成这样,日常操作就变成几条 Redis 命令:
- 加机器:起一个新进程即可,不改任何配置,也不用手工分配链接。
- 看积压:
llen site:start_urls就是还有多少条待抓。 - 重抓某站:
del site:dupefilter清掉指纹,再把 URL 推回队列。
分布式之后,"快"不再是目标。能不能停下来、能不能接着跑、出错的那几条能不能找出来,才是这套东西真正的价值。
如果重做,会改三处
这套方案能跑,但用久了会碰到几个它在设计上没打算解决的问题:
- 去重集合没有过期时间。 长时间运行会一直涨内存。要么按日期切 key(
site:dupefilter:20261109),要么换成 bloom filter,宁可接受极小的误判率。 - 限速是各算各的。
DOWNLOAD_DELAY是每个 spider 单独生效,机器一多,对目标站点的实际压力是worker 数 × delay。要真的控速,得把令牌桶也放进 Redis 做全局配额。 - 失败队列和正常队列混在一起。 重试的请求和首次请求抢同一个 list,重试多了会挤占正常进度。一开始就该分开,用独立的 key 加退避时间。
这三条都不复杂,但都是"跑起来之后才发现"的——所以真要从头写,我会先把失败队列和全局限速设计进去,而不是等出问题再补。