为什么Scrapy集成动态代理IP不能只写一行proxy?
只写一行 proxy 会在并发上线后立刻塌方,因为动态代理 IP 有生存周期、有失败率、有并发上限,任何一项没处理都会传导到爬取失败。
一个只塞 proxy 字段的最小实现,遇到的第一个坑是 IP 时效。短效动态 IP 存活周期常见在 1 到 5 分钟,请求发出前刚从池里取的 IP,网络往返回来时可能已经失效。第二个坑是失败传导,一个 IP 挂了如果没有识别与下架逻辑,同一批任务会在死 IP 上反复重试直到全部超时。第三个坑是会话粘性缺失,登录态、加购、翻页这类需要同一出口 IP 的业务,随机换 IP 直接把状态打乱。
在网站采集器、舆情监测、拓客数据这三类典型场景里,这三个坑是并存的。舆情监测的高并发放大 IP 池的失效率,拓客数据的登录态放大会话粘性问题,采集器的多目标又要求失败重试逻辑要能识别不同状态码。
| 简单塞proxy的做法 | 会踩到的坑 | 生产环境后果 |
|---|---|---|
| 从txt文件读IP列表轮换 | IP过期没剔除 | 大量ConnectionRefused |
| 请求前随机挑一个IP | 不识别失败状态码 | 重试打在同一个死IP上 |
| 全局固定一个proxy | 没有Session-IP绑定 | 登录态、Cookie被打乱 |
| 每请求都换IP | 无并发调度 | 池被打空、请求排队 |
Scrapy应该选API提取还是隧道代理?
看并发规模和是否需要精细控制 IP 生命周期:API 提取适合大规模、需要在客户端做池管理的场景;隧道适合中小规模、快速接入、不想自己维护池。
两者的差异不在协议,而在"谁来管 IP 池"。API 提取模式下,客户端定时调用 API 拉一批 IP,池的大小、TTL、换 IP 时机全由客户端决定。隧道模式下,客户端只对一个固定的隧道地址发请求,代理服务方在后端每隔 N 秒或每个请求换一个出口 IP。
| 维度 | API提取模式 | 隧道模式 |
|---|---|---|
| 接入方式 | 定时调用API拉取IP,本地维护池 | 请求打到固定隧道地址 |
| 换IP时机 | 客户端主动切换 | 服务方后端自动切换 |
| 客户端复杂度 | 需自建IP池、TTL管理、失败下架 | 中间件只需一次配置 |
| 会话粘性 | 显式绑定session_id到IP | 通过sticky参数或user名带session |
| 并发承压 | 池大小需按QPS预估 | 由服务方承压 |
| 失败重试 | 需自己下架IP再取新的 | 直接重发即可 |
| 典型适配场景 | 舆情监测高并发采集、网站采集器批量任务 | 拓客数据中小规模采集、快速验证选型 |
| 中间件代码量 | 多,含池管理模块 | 少,仅设置proxy和鉴权头 |
一个经验值参考:单机 Scrapy CONCURRENT_REQUESTS 在 32 以下、单业务、无强会话粘性诉求,优先隧道模式,接入成本最低;CONCURRENT_REQUESTS 超过 64、或者多业务共用、或者需要 Session-IP 绑定,走 API 提取模式的收益开始超过它的复杂度。
API提取模式下的Scrapy代理中间件怎么写?
先写一个线程安全的 IP 池组件,再把它接进 Downloader Middleware 的三个钩子:process_request 挂代理,process_response 识别失败下架,process_exception 处理连接异常。
第一步:IP池组件
# proxy_pool.py
import time
import threading
import requests
from collections import deque
class ProxyPool:
def __init__(self, api_url, refresh_size=20, ttl=180, buffer_seconds=15):
self.api_url = api_url
self.refresh_size = refresh_size
self.ttl = ttl
self.buffer = buffer_seconds
self.pool = deque()
self.lock = threading.Lock()
def _fetch(self):
try:
resp = requests.get(
f"{self.api_url}?num={self.refresh_size}",
timeout=5,
)
data = resp.json()
except Exception:
return
now = time.time()
for item in data.get("data", []):
self.pool.append({
"proxy": f"http://{item['ip']}:{item['port']}",
"expire_at": now + self.ttl,
})
def get(self):
with self.lock:
now = time.time()
while self.pool:
item = self.pool[0]
if item["expire_at"] <= now + self.buffer:
self.pool.popleft()
continue
return item["proxy"]
self._fetch()
if self.pool:
return self.pool[0]["proxy"]
return None
def report_dead(self, proxy):
with self.lock:
self.pool = deque(x for x in self.pool if x["proxy"] != proxy)关键点有三个。TTL 里减掉 buffer_seconds,避免取到"刚好在往返途中过期"的 IP。report_dead 用整表过滤保证同一失效 IP 不会被再次取出。池空时同步拉新,避免请求阻塞在等待补池上。
第二步:代理中间件
# middlewares.py
from .proxy_pool import ProxyPool
class DynamicProxyMiddleware:
def __init__(self, api_url):
self.pool = ProxyPool(api_url)
@classmethod
def from_crawler(cls, crawler):
api_url = crawler.settings.get("PROXY_API_URL")
return cls(api_url)
def process_request(self, request, spider):
if request.meta.get("proxy"):
return
proxy = self.pool.get()
if proxy:
request.meta["proxy"] = proxy
request.meta["_proxy_used"] = proxy
def process_response(self, request, response, spider):
return response
def process_exception(self, request, exception, spider):
proxy = request.meta.get("_proxy_used")
if proxy:
self.pool.report_dead(proxy)
return Noneprocess_request 里加 if request.meta.get("proxy") 判断,是为了兼容上层 SessionProxyMiddleware 已经绑定 IP 的场景。_proxy_used 单独存一份,防止重试时 proxy 字段被别的中间件改写、找不到原来那个死 IP 无法下架。
第三步:settings.py 挂载
# settings.py
DOWNLOADER_MIDDLEWARES = {
'myspider.middlewares.SmartRetryMiddleware': 550,
'myspider.middlewares.DynamicProxyMiddleware': 750,
'scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware': None,
}
PROXY_API_URL = "https://proxy-api.example.com/get"
CONCURRENT_REQUESTS = 32
CONCURRENT_REQUESTS_PER_DOMAIN = 16
DOWNLOAD_TIMEOUT = 15
RETRY_ENABLED = False内置 HttpProxyMiddleware 关掉,避免它把环境变量里的 http_proxy 覆盖进来。内置 RetryMiddleware 关掉,改用后面自定义的 SmartRetryMiddleware,因为默认重试不会下架失效 IP。
隧道模式下的Scrapy代理中间件怎么写?
代码量比 API 提取模式少一半,核心是设置 proxy 地址和一个 Basic Auth 头,无需自建池。
# middlewares.py
import base64
class TunnelProxyMiddleware:
def __init__(self, tunnel, username, password):
self.proxy = f"http://{tunnel}"
auth = f"{username}:{password}".encode()
self.auth_header = b"Basic " + base64.b64encode(auth)
@classmethod
def from_crawler(cls, crawler):
s = crawler.settings
return cls(
tunnel=s.get("PROXY_TUNNEL"),
username=s.get("PROXY_USERNAME"),
password=s.get("PROXY_PASSWORD"),
)
def process_request(self, request, spider):
request.meta["proxy"] = self.proxy
request.headers[b"Proxy-Authorization"] = self.auth_header
def process_exception(self, request, exception, spider):
return None隧道模式下,process_exception 不用做 IP 下架,直接返回 None 让 Scrapy 走到下一个中间件即可,服务方会自动换出口。
# settings.py 隧道模式片段
PROXY_TUNNEL = "tunnel.example.com:12345"
PROXY_USERNAME = "your_user"
PROXY_PASSWORD = "your_pass"隧道模式有两个易踩的点。一是 Proxy-Authorization 头必须是 bytes 类型,用 str 会被 Scrapy 的 Header 序列化模块拒绝或去重。二是隧道服务方通常按连接维度换 IP,如果 Scrapy 复用 TCP 连接会导致多个请求走同一个出口 IP,需要在 settings 里配 DOWNLOAD_HANDLERS 关闭连接复用或在请求层拆分,具体做法看服务方文档。
遇到403/407/超时,Scrapy代理中间件该怎么重试?
按状态码分类处理:代理层错误(407/连接异常)立即下架 IP 并重试;目标站限制(403/429)下架 IP 加短延时重试;服务器 5xx 换 IP 重试;重试次数封顶避免死循环。
| 状态码/异常 | 含义 | 建议动作 | 是否下架IP |
|---|---|---|---|
| 200/301/302 | 正常 | 放行 | 否 |
| 403 | 目标站访问频率控制 | 换IP重试,延时0.5-2秒 | 是 |
| 407 | 代理鉴权失败 | 检查账密,重试1次 | 否 |
| 429 | 目标站限速 | 换IP重试+延时5-10秒 | 是 |
| 502/503/504 | 代理网关异常 | 换IP直接重试 | 是 |
| ConnectionError | 代理不可达 | 换IP直接重试 | 是 |
| TimeoutError | 连接超时 | 换IP直接重试 | 是 |
| 200但内容为验证码页 | 目标站识别 | 换IP+清Cookie重试 | 是 |
# middlewares.py
class SmartRetryMiddleware:
RETRIABLE_STATUS = {403, 429, 500, 502, 503, 504}
def __init__(self, max_retry=3):
self.max_retry = max_retry
@classmethod
def from_crawler(cls, crawler):
return cls(max_retry=crawler.settings.getint("PROXY_MAX_RETRY", 3))
def process_response(self, request, response, spider):
if response.status not in self.RETRIABLE_STATUS:
return response
retry_times = request.meta.get("proxy_retry_times", 0)
if retry_times >= self.max_retry:
spider.logger.warning(
f"Proxy retry exceeded: {request.url}, status={response.status}"
)
return response
return self._build_retry(request, retry_times, reason=f"status_{response.status}")
def process_exception(self, request, exception, spider):
retry_times = request.meta.get("proxy_retry_times", 0)
if retry_times >= self.max_retry:
return None
return self._build_retry(request, retry_times, reason=type(exception).__name__)
def _build_retry(self, request, retry_times, reason):
new_request = request.copy()
new_request.meta["proxy_retry_times"] = retry_times + 1
new_request.meta["proxy_retry_reason"] = reason
new_request.meta.pop("proxy", None)
new_request.meta.pop("_proxy_used", None)
new_request.dont_filter = True
return new_request_build_retry 里显式 pop("proxy") 和 pop("_proxy_used"),让下一次 DynamicProxyMiddleware.process_request 重新取一个新的 IP,而不是继续用死掉的那个。dont_filter=True 避免重试请求被 Scrapy 的去重指纹拦下。
在拓客数据这类需要区分"我方策略问题"和"目标站限制"的场景,proxy_retry_reason 便于后续做日志分析、判断到底是 IP 池质量差还是采集策略被限制。
需要IP会话粘性时,中间件怎么绑定Session和IP?
API 提取模式用一张 session_id 到 proxy 的映射表,隧道模式在账密里带 session 标识,两者都要处理"绑定的 IP 过期后如何续期"。
API提取模式:显式绑定表
# middlewares.py
import threading
from .proxy_pool import ProxyPool
class SessionProxyMiddleware:
def __init__(self, api_url, session_ttl=900):
self.pool = ProxyPool(api_url, ttl=session_ttl, refresh_size=10)
self.session_map = {}
self.lock = threading.Lock()
self.session_ttl = session_ttl
@classmethod
def from_crawler(cls, crawler):
return cls(
api_url=crawler.settings.get("PROXY_STICKY_API_URL"),
session_ttl=crawler.settings.getint("PROXY_SESSION_TTL", 900),
)
def process_request(self, request, spider):
session_id = request.meta.get("session_id")
if not session_id:
return
with self.lock:
entry = self.session_map.get(session_id)
if entry and entry["expire_at"] > time.time() + 30:
request.meta["proxy"] = entry["proxy"]
request.meta["_proxy_used"] = entry["proxy"]
return
proxy = self.pool.get()
if proxy:
self.session_map[session_id] = {
"proxy": proxy,
"expire_at": time.time() + self.session_ttl,
}
request.meta["proxy"] = proxy
request.meta["_proxy_used"] = proxySpider 里生成会话时给一个 session_id:
def start_requests(self):
for i in range(100):
yield scrapy.Request(
url="https://example.com/login",
meta={"session_id": f"session_{i}"},
callback=self.after_login,
)session_ttl 建议对齐动态 IP 的最大生存周期,或者选长效代理 IP 池的 TTL(常见 15 到 30 分钟)。session_ttl 短于 IP 实际存活会浪费池资源,长于 IP 存活会导致会话中途换 IP。
隧道模式:账密带session
多数隧道服务方约定,在用户名里带 session- 前缀就可以让同一个 session 值走固定出口 IP,通常几分钟到几十分钟不等。
def process_request(self, request, spider):
session_id = request.meta.get("session_id", "default")
user = f"{self.username}-session-{session_id}"
auth = f"{user}:{self.password}".encode()
request.meta["proxy"] = self.proxy
request.headers[b"Proxy-Authorization"] = (
b"Basic " + base64.b64encode(auth)
)具体的 session 参数格式各家不同,接入前需查所选服务方的账密规则文档。
Scrapy代理中间件的常见性能问题有哪些?
四个高频问题:IP池空、鉴权失败、请求排队、锁竞争,都能通过参数调整或代码微调解决,不需要重写架构。
| 问题现象 | 根本原因 | 定位手段 | 解决方案 |
|---|---|---|---|
| 日志频繁出现"proxy is None" | refresh_size小或TTL短或QPS高 | 打印 pool.size() 走势 | refresh_size ≥ 并发数; TTL对齐IP存活 |
| 大量407 Proxy Authentication Required | 账密错、Basic Auth编码错、字段名拼错 | 单独用requests验证一次隧道 | 检查Basic Auth的bytes类型和base64编码 |
| 请求排队严重、QPS上不去 | CONCURRENT_REQUESTS与池大小不匹配 | 观察Scrapy的scheduler待处理数 | 池大小 ≥ 2倍CONCURRENT_REQUESTS |
| 内存持续上涨 | 失败请求无上限重入 | 打印retry_times分布 | PROXY_MAX_RETRY设为3-5 |
| CPU占用高但IO低 | 池管理lock竞争 | py-spy采样看lock等待 | 拆分lock粒度或改用asyncio |
| 目标站返回验证码页而非403 | 未识别验证码状态 | 抽样response.text | 在SmartRetryMiddleware加内容检测 |
| 短时突发失败率飙升 | 池里的IP集中过期 | 记录IP过期时间分布 | 分批次拉新,错峰过期 |
在舆情监测这种长时间运行的场景,"分批次拉新"这一条特别关键。如果初始化时一次性拉满一个池、又不做错峰,会导致所有 IP 集中在同一分钟过期,出现周期性失败率脉冲。可以在 ProxyPool._fetch 里给每个 IP 的 expire_at 加一个 0 到 30 秒的随机抖动。
FAQ
Q:Scrapy自带的HttpProxyMiddleware和自定义代理中间件冲突怎么办?
在 settings.py 里把 scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware 设为 None 关掉即可。自带的中间件默认从环境变量 http_proxy、https_proxy 里读代理,如果本地或容器里刚好设置过这些变量,会在自定义中间件挂 proxy 之前先把请求打到环境变量里的地址,导致排查时怀疑代码写错。
Q:动态代理IP的TTL应该设多长?
按代理服务方公示的 IP 存活周期减去 15 到 30 秒缓冲区来设。短效动态代理常见 1 到 5 分钟存活,TTL 设 60 到 180 秒;长效代理常见 15 到 60 分钟,TTL 设 800 到 3300 秒。缓冲区是为了避免"取到手时还剩 5 秒、发出请求时已经失效"的边界情况,这类失败最难排查。
Q:Scrapy的CONCURRENT_REQUESTS和代理IP池大小怎么匹配?
经验值是池大小至少为 CONCURRENT_REQUESTS 的 2 倍,稳妥点做到 3 到 5 倍。因为并发下每个 slot 都可能同时持有一个 IP,同时还有请求正在飞行、IP 正在从池里出队和入队,池太小会导致 process_request 里频繁走同步补池、拉长请求排队时间。
Q:为什么dont_filter=True一定要加?
Scrapy 用请求指纹去重,同一个 URL 默认只调度一次。重试时如果不加 dont_filter=True,重试请求会被去重规则直接拦下,日志里看不到任何异常但请求就是不发出去。这是 Scrapy 代理重试逻辑最容易踩到的隐蔽坑之一。
Q:403和429在代理场景下有什么区别?该怎么区别处理?
403 通常是目标站基于 IP 信誉或访问频率控制机制做出的整体访问限制,往往需要换 IP 并等一段时间才能恢复;429 是显式的限速信号,通常带 Retry-After 头指示重试间隔。中间件里对 429 优先读取 Retry-After 决定延时,对 403 则用固定或指数退避策略。两者都应下架当前 IP,避免连续几次请求打在同一个已被限制的 IP 上。