2019数据采集分布式

把爬虫队列搬到 Redis

单机跑的采集脚本,数据量上去之后不是代码快不快的问题——是重复抓取、任务分不下去、中断没法续跑这三件事同时发生。

第一次写采集,基本都是一个 for 循环加 requests。站点小、页面少的时候这没问题。真正撑不住的时候,往往不是慢,而是没法扩展——想加快只能堆循环,想加机器却无处下手。

三个卡点

单进程的爬虫把三样东西都存在自己的内存里,它们一起失效:

  • 重复抓取 去重靠进程内的 set,进程一重启就清空,同一批链接会从零开始再抓一遍。
  • 任务分不下去 想再起一个进程分担,但没有共享的待抓队列,只能手工把链接列表切成两半——新链接进来就没法动态分配了。
  • 中断不能续跑 跑到一半进程挂了或机器重启,前面几万条全部作废。

三个问题指向同一件事:状态得从进程里搬出去。只要待抓队列和已抓指纹活在一个进程外面,加机器就是多起一个进程,重启就是接着跑。

换掉哪三样

Scrapy 本身有调度器、去重器、起始队列,但都局限在单进程内存中。scrapy-redis 做的事不是"给 Scrapy 加个 Redis 插件",而是把这三个组件各给了一份 Redis 实现:

组件默认实现换成
调度器内存里的优先队列Redis list,多进程共享
去重器内存 setRedis set,存请求指纹
起始 URLstart_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 加退避时间。

这三条都不复杂,但都是"跑起来之后才发现"的——所以真要从头写,我会先把失败队列和全局限速设计进去,而不是等出问题再补。