本文深入探討 Scrapy-Redis 在分佈式爬蟲場景下的去重機制、資源管理和數據持久化策略,幫助開發者理解真實生產環境中的技術選型。
目錄
[TOC]
一、核心問題:為什麼要用 Redis 管理起始 URL?
1.1 傳統方式 vs Redis 方式
傳統 Scrapy 方式
pythonclass MySpider(scrapy.Spider):
name = 'myspider'
def start_requests(self):
# 每次啓動爬蟲都會執行這裏
yield scrapy.FormRequest(
url='http://example.com/api',
formdata={'page': '1', 'type': 'news'},
callback=self.parse
)
Scrapy-Redis 方式
pythonclass MySpider(RedisSpider):
name = 'myspider'
redis_key = 'myspider:start_urls'
def make_request_from_data(self, data):
# 從 Redis 讀取數據後構造請求
url = data.decode('utf-8')
return scrapy.FormRequest(
url=url,
formdata={'page': '1', 'type': 'news'},
callback=self.parse
)
### 1.2 資源浪費的本質
#### 場景對比:3 台服務器同時運行
**不使用 Redis 管理入口(使用共享調度器)**
T1時刻:
服務器A: start_requests() → 生成Request對象
→ 計算指紋 fingerprint_1
→ 檢查Redis去重集合 → 不存在 → 添加到Redis
→ 請求入隊
T2時刻(幾乎同時):
服務器B: start_requests() → 生成Request對象
→ 計算指紋 fingerprint_1 (相同!)
→ 檢查Redis去重集合 → 已存在! → 丟棄請求 ✗
服務器C: start_requests() → 生成Request對象
→ 計算指紋 fingerprint_1 (相同!)
→ 檢查Redis去重集合 → 已存在! → 丟棄請求 ✗
**資源浪費**:
- CPU:30 次指紋計算(實際只需 10 次)
- 網絡:30 次 Redis 查詢(實際只需 10 次)
- 內存:30 個 Request 對象創建(實際只需 10 個)
**使用 Redis 管理入口**
Redis中存儲: "myspider:start_urls" → [url1, url2, ..., url10]
服務器A: LPOP取出url1, url2, url3 → 計算3次指紋 → 入隊3個
服務器B: LPOP取出url4, url5, url6, url7 → 計算4次指紋 → 入隊4個
服務器C: LPOP取出url8, url9, url10 → 計算3次指紋 → 入隊3個
優勢:
10 次指紋計算(無浪費)
10 次 Redis 操作(無浪費)
原子操作保證每個 URL 只被一台服務器處理
1.3 資源消耗對比表
階段操作場景B變體最優方案差異入隊SHA1計算30次10次浪費20次入隊Redis SADD30次10次浪費20次入隊Request對象創建30個10個浪費20個入隊網絡往返(Redis)30次10次浪費20次處理HTTP請求10次10次無差異 ✓處理隊列操作10次ZPOP10次ZPOP無差異 ✓
二、理解 Scrapy-Redis 的去重機制
2.1 純 Scrapy 的去重(基於內存)
python# Scrapy默認配置
DUPEFILTER_CLASS = 'scrapy.dupefilters.RFPDupeFilter'
工作方式
class BaseDupeFilter:
def __init__(self):
self.fingerprints = set() # 存在內存中
def request_seen(self, request):
fp = self.request_fingerprint(request)
if fp in self.fingerprints:
return True # 已見過
self.fingerprints.add(fp)
return False
**問題**:每台服務器的內存獨立,無法共享去重信息
服務器A的內存: {fp1, fp2, fp3}
服務器B的內存: {fp4, fp5, fp6} # 完全獨立
服務器C的內存: {fp7, fp8, fp9}
結果:三台服務器可能爬取相同的URL
2.2 Scrapy-Redis 的去重(基於 Redis)
python# 配置使用Redis去重
DUPEFILTER_CLASS = 'scrapy_redis.dupefilter.RFPDupeFilter'
工作方式
class RFPDupeFilter:
def __init__(self, server, key):
self.server = server # Redis連接
self.key = key # Redis鍵名
def request_seen(self, request):
fp = self.request_fingerprint(request)
# 使用Redis的Set存儲指紋(所有服務器共享)
added = self.server.sadd(self.key, fp)
return added == 0 # 0表示已存在
**優勢**:所有服務器共享同一個 Redis Set
Redis
|
+--------------+--------------+
| | |
服務器A 服務器B 服務器C
| | |
+-------> {fp1, fp2, fp3, ...} <-------+
共享的指紋集合
2.3 請求指紋的計算方式
python# scrapy/utils/request.py
def request_fingerprint(request, include_headers=None):
"""
計算請求的SHA1指紋
考慮因素:
- URL
- HTTP方法(GET/POST等)
- POST數據(如果有)
- 指定的Headers(可選)
"""
# 1. 規範化URL
url = canonicalize_url(request.url)
# 2. 獲取HTTP方法
method = request.method.upper()
# 3. 獲取POST數據
body = request.body or b''
# 4. 組合所有數據並計算SHA1
fingerprint_data = (
method.encode('utf-8') +
url.encode('utf-8') +
body
)
return hashlib.sha1(fingerprint_data).hexdigest()
三、重複爬取的問題與解決方案
3.1 問題描述
第一次爬取後,Redis 中的 dupefilter 保存了所有 URL 的指紋:
bash# 第一次爬取後
redis> SCARD myspider:dupefilter
(integer) 10000
第二次啓動爬蟲
scrapy crawl myspider
結果:所有請求都被過濾
[scrapy.core.scheduler] INFO: Filtered duplicate request
爬蟲立即結束
3.2 解決方案彙總
方案1:清空去重集合(最簡單)
bash# 方法1: 清空dupefilter
redis-cli DEL myspider:dupefilter
方法2: 清空所有相關鍵
redis-cli KEYS "myspider:*" | xargs redis-cli DEL
Python 腳本清理:
pythonimport redis
def reset_spider(spider_name):
"""重置爬蟲的Redis數據"""
r = redis.Redis(host='localhost', port=6379, db=0)
# 清除去重集合
r.delete(f'{spider_name}:dupefilter')
# 清除請求隊列(可選)
r.delete(f'{spider_name}:requests')
print(f"Spider '{spider_name}' 已重置")
使用
reset_spider('myspider')
方案2:使用時間戳鍵(按時間隔離)
python# settings.py
from datetime import datetime
SCHEDULER = "scrapy_redis.scheduler.Scheduler"
DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter"
每天使用不同的去重鍵
DATE = datetime.now().strftime('%Y%m%d')
SCHEDULER_QUEUE_KEY = f'%(spider)s:requests:{DATE}'
DUPEFILTER_KEY = f'%(spider)s:dupefilter:{DATE}'
結果
Redis中的鍵:
myspider:dupefilter:20250101 (今天)
myspider:dupefilter:20250102 (明天)
方案3:設置去重過期時間
pythonfrom scrapy_redis.dupefilter import RFPDupeFilter
class TimedRFPDupeFilter(RFPDupeFilter):
"""帶過期時間的去重過濾器"""
def __init__(self, server, key, debug=False, expire=86400):
super().__init__(server, key, debug)
self.expire = expire # 默認24小時
def request_seen(self, request):
fp = self.request_fingerprint(request)
added = self.server.sadd(self.key, fp)
# 設置過期時間
if added:
self.server.expire(self.key, self.expire)
return added == 0
settings.py
DUPEFILTER_CLASS = 'myproject.dupefilter.TimedRFPDupeFilter'
DUPEFILTER_EXPIRE = 86400 # 24小時後自動刪除
方案4:智能清理策略
pythonimport redis
from datetime import datetime, timedelta
class RedisManager:
def __init__(self, spider_name):
self.spider_name = spider_name
self.redis = redis.Redis(host='localhost', port=6379, db=0)
self.dupefilter_key = f'{spider_name}:dupefilter'
self.meta_key = f'{spider_name}:meta'
def should_clean(self):
"""智能判斷是否需要清理"""
last_clean = self.redis.get(f'{self.meta_key}:last_clean')
if not last_clean:
return True
last_time = datetime.fromisoformat(last_clean.decode('utf-8'))
# 超過7天自動清理
if datetime.now() - last_time > timedelta(days=7):
return True
# 指紋數量超過閾值
fp_count = self.redis.scard(self.dupefilter_key)
if fp_count > 10000000:
return True
return False
def clean_with_backup(self):
"""清理前備份"""
backup_file = f'backup_{self.spider_name}_{datetime.now():%Y%m%d}.txt'
with open(backup_file, 'w') as f:
fps = self.redis.smembers(self.dupefilter_key)
for fp in fps:
f.write(f"{fp}\n")
self.redis.delete(self.dupefilter_key)
self.redis.set(
f'{self.meta_key}:last_clean',
datetime.now().isoformat()
)
print(f"已清理並備份到 {backup_file}")
四、SCHEDULER_PERSIST:數據持久化的關鍵配置
4.1 配置説明
python# settings.py
SCHEDULER_PERSIST = True(默認)
爬蟲結束後保留Redis數據
SCHEDULER_PERSIST = True
SCHEDULER_PERSIST = False
爬蟲結束後清理Redis數據
SCHEDULER_PERSIST = False
4.2 PERSIST = False 的嚴重問題
問題1:爬蟲意外中斷導致數據丟失
bash# 起始URL: 15000個
========== 爬蟲啓動 ==========
$ scrapy crawl myspider
啓動時:所有URL入隊
dupefilter: 15000個指紋(所有URL在入隊時就記錄了)
requests: 15000個請求
========== 爬取進行中 ==========
已處理5000個請求
dupefilter: 15000個指紋(不變!)
requests: 10000個請求(減少了5000個)
========== 意外中斷 ==========
Killed
PERSIST = False 的結果:
dupefilter: 0個(全部15000個指紋丟失!)
requests: 0個(剩餘10000個請求丟失)
========== 重啓後 ==========
需要重新爬取: 15000個URL(全部)
浪費的工作: 5000個已完成的請求
關鍵理解:
dupefilter 記錄的是"所有被調度過的請求",不是"已完成的請求"
請求在入隊時就加入 dupefilter,完成後不會從 dupefilter 移除
因此中斷時丟失的是全部 15000 個指紋,而非 5000 個
問題2:分佈式環境下的災難
python# 3台服務器正在運行
服務器A: 正在爬取
服務器B: 正在爬取
服務器C: 正在爬取
服務器B需要重啓(維護/升級)
服務器B: 關閉 → SCHEDULER_PERSIST=False → 清空Redis ❌
災難發生
服務器A和C: 發現Redis數據被清空
→ 開始重複爬取
→ 可能被封IP
關鍵問題:在分佈式環境中,任何一台服務器關閉都會清空共享的 Redis 數據!
問題3:無法支持增量爬取
python# 需求:每小時爬取新增的數據
第1小時
scrapy crawl myspider
爬取100個新URL
爬蟲結束(PERSIST=False)
Redis清空
第2小時
scrapy crawl myspider
沒有去重記錄 → 又爬取了第1小時的100個URL ❌
再爬取新增的50個URL
結果:重複爬取,數據冗餘
4.3 PERSIST = True 的優勢
優勢1:容錯恢復(斷點續爬)
bash# ========== 爬蟲中斷 ==========
已完成: 5000個
隊列中: 10000個
dupefilter: 15000個指紋
========== PERSIST = True ==========
Redis數據完整保留
$ redis-cli SCARD myspider:dupefilter
(integer) 15000
$ redis-cli ZCARD myspider:requests
(integer) 10000
========== 重啓爬蟲 ==========
$ scrapy crawl myspider
自動恢復:
✓ dupefilter中有15000個指紋
✓ 已完成的5000個URL會被自動過濾
✓ 直接處理剩餘的10000個請求
✓ 無縫恢復,0個URL重複爬取
優勢2:支持增量爬取
bash# Day 1
$ scrapy crawl myspider
爬取1000個URL → Redis記錄1000個指紋
Day 2
$ scrapy crawl myspider
檢查所有URL → 前1000個被過濾(已爬過)✓
只爬取新增的200個URL ✓
真正的增量爬取
優勢3:分佈式環境穩定性
bash# 3台服務器運行
某台服務器重啓
服務器B重啓
→ Redis數據完整保留 ✓
→ 重啓後繼續工作 ✓
→ 不影響其他服務器 ✓
4.4 dupefilter 的工作機制詳解
關鍵概念
python# dupefilter 記錄的是"所有被調度過的請求"
包括:
1. 已完成的請求 ✓
2. 正在處理的請求 ✓
3. 隊列中等待的請求 ✓
目的:防止同一個URL被多次加入隊列
完整生命週期
python# 起始URL: 15000個
========== 階段1: 調度階段 ==========
for url in start_urls: # 15000個
request = Request(url)
# 計算指紋並加入dupefilter
fp = sha1(url)
redis.sadd('dupefilter', fp) # dupefilter += 1
# 加入請求隊列
redis.zadd('requests', request) # requests += 1
Redis狀態:
dupefilter: 15000個指紋
requests: 15000個請求
========== 階段2: 爬取階段 ==========
處理第1個請求
request = redis.zpop('requests')
download_and_parse(request)
dupefilter: 15000個(不變!)
requests: 14999個
處理第5000個請求後
dupefilter: 15000個(仍然不變!)
requests: 10000個
驗證代碼
pythonimport redis
r = redis.Redis(decode_responses=True)
r.delete('test:dupefilter', 'test:requests')
print("=== 模擬15000個URL入隊 ===")
for i in range(15000):
fp = f"fingerprint_{i}"
r.sadd('test:dupefilter', fp)
r.zadd('test:requests', {f'request_{i}': 0})
print(f"dupefilter: {r.scard('test:dupefilter')}") # 15000
print(f"requests: {r.zcard('test:requests')}") # 15000
print("\n=== 模擬處理5000個請求 ===")
for i in range(5000):
r.zpopmin('test:requests')
# 注意:沒有從dupefilter刪除!
print(f"dupefilter: {r.scard('test:dupefilter')}") # 還是15000!
print(f"requests: {r.zcard('test:requests')}") # 變成10000
print("\n=== PERSIST=False,清空 ===")
r.delete('test:dupefilter', 'test:requests')
print(f"dupefilter: {r.scard('test:dupefilter')}") # 0(全部丟失)
print(f"requests: {r.zcard('test:requests')}") # 0
五、生產環境的最佳實踐
5.1 方案選擇矩陣
場景類型推薦配置理由學習/測試PERSIST = False簡單方便,每次都是全新爬取定時全量爬取PERSIST = False + 任務管理每次任務獨立增量爬取PERSIST = True + 定期清理必須保留歷史長時間運行PERSIST = True + 監控容錯恢復至關重要分佈式爬蟲PERSIST = True + 管理工具任何服務器重啓不應影響全局生產環境PERSIST = True + 策略清理安全第一
5.2 推薦配置:開發環境
python# settings.py
SCHEDULER = "scrapy_redis.scheduler.Scheduler"
DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter"
自動清理
SCHEDULER_PERSIST = False
REDIS_HOST = 'localhost'
REDIS_PORT = 6379
5.3 推薦配置:生產環境
python# settings.py
SCHEDULER = "scrapy_redis.scheduler.Scheduler"
DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter"
保留數據,確保安全
SCHEDULER_PERSIST = True
使用任務ID隔離
from datetime import datetime
TASK_ID = datetime.now().strftime('%Y%m%d_%H%M%S')
SCHEDULER_QUEUE_KEY = f'%(spider)s:requests:{TASK_ID}'
DUPEFILTER_KEY = f'%(spider)s:dupefilter:{TASK_ID}'
REDIS_HOST = 'redis.example.com'
REDIS_PORT = 6379
REDIS_PASSWORD = 'your_password'
5.4 使用 Airflow 進行任務調度
pythonfrom airflow import DAG
from airflow.operators.bash_operator import BashOperator
from datetime import datetime, timedelta
default_args = {
'owner': 'data_team',
'depends_on_past': False,
'start_date': datetime(2025, 1, 1),
'retries': 1,
}
dag = DAG(
'ecommerce_spider',
default_args=default_args,
schedule_interval='0 2 * * *',
)
清理舊數據
clean_task = BashOperator(
task_id='clean_redis',
bash_command='python manage_redis.py clean --days 7',
dag=dag,
)
運行爬蟲
crawl_task = BashOperator(
task_id='run_spider',
bash_command='scrapy crawl myspider',
dag=dag,
)
驗證數據
validate_task = BashOperator(
task_id='validate_data',
bash_command='python validate.py',
dag=dag,
)
clean_task >> crawl_task >> validate_task
六、常見問題 FAQ
Q1: 為什麼不能每次都用 PERSIST = False?
A: 因為在生產環境中:
爬蟲可能意外中斷,False 會導致所有數據丟失
分佈式環境下,任何一台服務器關閉都會清空共享 Redis
無法支持增量爬取
無法實現斷點續爬
Q2: 如何處理 dupefilter 數據累積?
A: 推薦方案:
使用時間戳隔離鍵名(按天/按批次)
設置過期時間(自定義 DupeFilter)
定期清理腳本(配合任務調度系統)
智能清理策略(根據時間/數量/內存使用)
Q3: POST 請求如何使用 Redis 管理起始 URL?
A:
pythonfrom scrapy_redis.spiders import RedisSpider
import json
class MyPostSpider(RedisSpider):
name = 'mypost_spider'
redis_key = 'mypost:start_urls'
def make_request_from_data(self, data):
data = data.decode('utf-8')
try:
# 解析JSON配置(包含URL和POST參數)
config = json.loads(data)
url = config['url']
post_data = config.get('formdata', {})
return scrapy.FormRequest(
url=url,
formdata=post_data,
callback=self.parse,
dont_filter=True
)
except json.JSONDecodeError:
# 普通URL
return scrapy.FormRequest(
url=data,
formdata={'page': '1'},
callback=self.parse
)
Redis推送
import redis
r = redis.Redis()
r.lpush('mypost:start_urls', json.dumps({
"url": "http://example.com/api",
"formdata": {"page": "1", "type": "news"}
}))
Q4: 如何監控 Redis 中的數據?
A:
bash# 查看去重集合大小
redis-cli SCARD myspider:dupefilter
查看請求隊列大小
redis-cli ZCARD myspider:requests
查看所有相關鍵
redis-cli KEYS "myspider:*"
查看內存使用
redis-cli INFO memory
## 七、總結
### 核心要點
1. **使用 Redis 管理起始 URL** 可以避免分佈式環境下的資源浪費(重複的指紋計算、Redis 查詢)
2. **dupefilter 記錄的是所有被調度過的請求**,不是已完成的請求,請求完成後指紋不會從 dupefilter 移除
3. **`SCHEDULER_PERSIST = True` 是生產環境的標準配置**,提供容錯恢復、增量爬取、分佈式穩定性
4. **`SCHEDULER_PERSIST = False` 適合學習和測試**,但在生產環境中可能導致數據丟失
5. **數據清理應該由業務邏輯決定**,而不是框架配置,推薦使用任務調度系統、智能清理腳本等方案
### 學習路徑建議
第1階段(入門):
使用 PERSIST = False,理解基本概念
第2階段(進階):
使用 PERSIST = True + 手動清理
理解生產環境需求
第3階段(實戰):
PERSIST = True + 任務調度系統
掌握工程化思維
參考資源
Scrapy 官方文檔
Scrapy-Redis GitHub
Redis 官方文檔