Scrapy 接入动态 IP 代理池:会话、重试与成本控制的完整实现
先说结论
采集项目真正卡住的地方,很少是"不会写爬虫",而是代理层没设计好:请求成功率忽高忽低、重试把 IP 池打穿、账单月底才发现超了一倍。
这篇把代理池接入 Scrapy 的四个关键环节拆开讲:中间件怎么写、会话怎么保、失败怎么退避、成本怎么算。代码可以直接跑。
一、把代理抽象成"可切换的资源",别硬编码
最差的做法是在 settings.py 里写死一个代理地址。稍好一点是每次请求随机取一个。都不对——代理是有状态、有配额、会失效的资源,要当成连接池来管。
# proxy_pool.py
import random, time, threading
class ProxyPool:
def __init__(self, fetcher):
self.fetcher = fetcher # 拉取代理列表的函数
self.lock = threading.Lock()
self.pool = [] # [{"addr": "ip:port", "fail": 0, "last_ok": 0}]
self.refresh()
def refresh(self):
items = self.fetcher() # 返回 ["1.2.3.4:8080", ...]
with self.lock:
self.pool = [{"addr": a, "fail": 0, "last_ok": 0} for a in items]
def get(self):
with self.lock:
alive = [p for p in self.pool if p["fail"] < 3]
if not alive:
self.refresh()
alive = self.pool
return random.choice(alive)["addr"]
def report(self, addr, ok):
with self.lock:
for p in self.pool:
if p["addr"] == addr:
if ok:
p["fail"] = 0
p["last_ok"] = time.time()
else:
p["fail"] += 1
要点:给每个代理记失败次数。失败三次就暂时踢出池子,比"全局重试"有效得多——否则一个坏代理会被反复抽到,把整个任务拖垮。
二、中间件:只做两件事,别写复杂逻辑
# middlewares.py
from scrapy import signals
import logging
class ProxyMiddleware:
def __init__(self, pool):
self.pool = pool
@classmethod
def from_crawler(cls, crawler):
mw = cls(crawler.settings.get("PROXY_POOL"))
crawler.signals.connect(mw.spider_closed, signal=signals.spider_closed)
return mw
def process_request(self, request, spider):
if request.meta.get("no_proxy"):
return None
addr = request.meta.get("proxy") or self.pool.get()
request.meta["_proxy_addr"] = addr
request.meta["proxy"] = "http://" + addr
return None
def process_response(self, request, response, spider):
addr = request.meta.get("_proxy_addr")
if addr:
self.pool.report(addr, response.status in (200, 301, 302))
if response.status in (403, 429):
retry = request.copy()
retry.meta["proxy"] = None
retry.dont_filter = True
return retry
return response
三个细节:
403和429要分开处理。403 是规则拦截,换出口可能有用;429 是频率超限,换出口往往没用,该降速。不要全部重试。- 重试必须换出口。用同一个代理重试被拒的请求,只会把失败次数刷高。
dont_filter=True别忘,否则 dupefilter 会把重试请求挡掉。
三、会话保持:什么时候必须用同一个出口
这是最容易出问题的地方。很多人的池子"成功率"很高,但登录态一直掉,原因就是每个请求都换了 IP。
需要保持会话的场景只有三类:
- 要登录的页面(Cookie 和 IP 绑定)
- 分页翻取(服务端按 IP 记录翻页位置)
- 需要连续操作的流程(加购、提交表单)
def start_requests(self):
session_proxy = self.pool.get() # 一个会话用一个出口
for url in self.start_urls:
yield scrapy.Request(
url,
meta={"proxy": session_proxy, "session_id": "task-001"},
callback=self.parse
)
反过来,不需要会话的场景就别硬保:纯列表页采集、静态页面抓取,每请求一换反而分散压力。
判断标准一句话:这个请求失败后能不能独立重来?能,就不用保会话。
四、退避与预算:别让重试吃掉成本
重试是有成本的。一条线路上跑三次失败重试,等于三次计费。所以退避策略要和预算绑在一起算。
class Backoff:
def __init__(self, base=1.0, cap=60.0, retries=3):
self.base, self.cap, self.retries = base, cap, retries
def wait(self, attempt):
if attempt >= self.retries:
return None
delay = min(self.cap, self.base * (2 ** attempt)) * (0.5 + random.random())
time.sleep(delay)
return delay
配合并发参数一起调:
| 场景 | 并发 | 延迟 | 重试上限 |
|---|---|---|---|
| 资讯类静态页 | 16–32 | 0.2s | 2 |
| 电商列表页 | 8–16 | 0.5s | 3 |
| 需要登录的流程 | 2–4 | 1–2s | 2 |
| 接口型数据 | 4–8 | 1s | 3 |
先降速,再换 IP。 很多"被封"其实是并发超了目标站承受能力,把并发砍一半、加随机间隔,往往不用换线路就恢复了。
五、怎么判断代理层真的在工作
上线后别只看"任务跑完了",看四个数:
成功率 = 200 响应数 / 总请求数 目标 > 92%
平均延迟 = 所有请求响应时间均值 目标 < 800ms
失败分布 = 按状态码分组 403/429 各占多少
单 IP 请求 = 总请求 / 独立 IP 数 是否超出承载
这些数字不监控,出问题就只能凭感觉猜。
六、几个踩过的坑
- DNS 还在走本地:代理只转发 TCP,域名解析仍可能由本机完成,导致地区对不上。解决方式是让代理端做远端解析(SOCKS5 用
socks5h://)。 - IPv6 绕过代理:部分代理只处理 IPv4,目标站支持双栈时可能优先走 IPv6 直连。
- 短效 IP 打断长任务:需要连续会话的任务用短效池,必然反复掉线,这类场景要选长效或独享线路。
- 不考虑 robots 和站点规则:技术上能做到,和业务上该做是两件事。稳定的数据来源是授权接口和合作,不是无限换 IP。
代理选型的判断标准(成功率、延迟、会话、地区、协议五项)单独写过一份清单,放在采集场景方案里;需要现场测自己出口质量,可以用IP 地址查询工具对比切换前后的归属与运营商。
浙公网安备 33010602011771号