一、问题
爬虫用代理不是稀罕事。但 Scrapy 默认的代理支持和重试机制是两个独立的模块,各管各的。这导致一个常见场景:
- 你配了
HttpProxyMiddleware,代理也能通 - 某个请求遇到代理超时或连接断开,
RetryMiddleware确实重试了 - 但是重试时 没有换代理,还是同一个死掉的代理,重试几次都白费
结果就是:代理池里有 50 个可用代理,但每次重试都钉死在那个已经坏掉的代理上,直到它耗尽所有重试次数才换下一个。
本文解决的就是这个问题------把代理切换和重试逻辑绑在一起,做成一个中间件。
二、先看一眼 Scrapy 的下载中间件链条
Scrapy 的下载中间件是一个处理链。请求从引擎发出后,经过每个中间件的 process_request 方法,层层下传到下载器;响应再逆流而上经过 process_response。
默认情况下:
Request → HttpProxyMiddleware.process_request(设代理)
→ RetryMiddleware.process_response(检查失败,决定重试)
问题是 RetryMiddleware 只标记"这个请求要重试",它不管代理。重试请求走的是同一个 Request 对象,代理地址被 HttpProxyMiddleware 在上一次就设好了,所以重试时还是同一个代理。
三、核心思路
把代理选择和重试逻辑收进同一个中间件,分三步:
- 代理选择 :从池子里取一个可用代理,设到
request.meta['proxy'] - 异常捕获 :在
process_exception里捕获连接错误、超时、Connection refused - 换代理重试 :标记重试之前,换掉
request.meta['proxy'],把当前这个坏代理标记为不可用
这样重试三次,每次都是不同的代理,命中率会高很多。
四、代码实现
4.1 代理池(简化版)
先写一个简单的代理池,不做太复杂的健康检查,只维护"可用列表"和"黑名单":
# proxy_pool.py
import random
import time
class ProxyPool:
def __init__(self, proxies: list[str]):
self._available = list(proxies)
self._blacklist: dict[str, float] = {} # proxy -> blacklisted_until
def get(self) -> str | None:
"""从可用列表中随机取一个代理"""
if not self._available:
self._recover()
if not self._available:
return None
return random.choice(self._available)
def mark_bad(self, proxy: str, cooldown: int = 30):
"""标记代理不可用,冷却 cooldown 秒"""
if proxy in self._available:
self._available.remove(proxy)
self._blacklist[proxy] = time.time() + cooldown
def _recover(self):
"""恢复过期的黑名单代理"""
now = time.time()
expired = [p for p, t in self._blacklist.items() if t <= now]
for p in expired:
del self._blacklist[p]
self._available.append(p)
不搞复杂的权重、延迟统计,够用就行。生产环境可以换成 Redis 后端或者用 scrapy-proxy-pool 那样的成熟方案。
4.2 自定义中间件
# middlewares.py
from scrapy import signals
from scrapy.downloadermiddlewares.retry import RetryMiddleware
from scrapy.utils.response import response_status_message
from .proxy_pool import ProxyPool
class RetryProxyMiddleware(RetryMiddleware):
"""在重试时切换代理的下载中间件"""
def __init__(self, settings):
super().__init__(settings)
proxy_list = settings.getlist("PROXY_LIST")
self.proxy_pool = ProxyPool(proxy_list) if proxy_list else None
@classmethod
def from_crawler(cls, crawler):
o = cls(crawler.settings)
crawler.signals.connect(o.spider_opened, signal=signals.spider_opened)
return o
def spider_opened(self, spider):
spider.logger.info(
f"ProxyPool initialized with {len(self.proxy_pool._available) if self.proxy_pool else 0} proxies"
)
def process_request(self, request, spider):
"""每次请求前从池子取一个代理"""
if self.proxy_pool and "proxy" not in request.meta:
proxy = self.proxy_pool.get()
if proxy:
request.meta["proxy"] = proxy
request.meta["download_timeout"] = self.max_timeout
return None
def process_exception(self, request, exception, spider):
"""捕获异常,换代理后重试"""
proxy = request.meta.get("proxy")
if proxy and self.proxy_pool:
spider.logger.warning(
f"Proxy {proxy} failed: {exception.__class__.__name__}. Switching..."
)
self.proxy_pool.mark_bad(proxy)
# 换一个新代理
new_proxy = self.proxy_pool.get()
if new_proxy:
request.meta["proxy"] = new_proxy
else:
del request.meta["proxy"]
# 调用 RetryMiddleware 内置的异常处理
return super().process_exception(request, exception, spider)
def process_response(self, request, response, spider):
"""处理 HTTP 500+ 响应,换代理重试"""
if response.status in self.retry_http_codes:
proxy = request.meta.get("proxy")
if proxy and self.proxy_pool:
self.proxy_pool.mark_bad(proxy)
new_proxy = self.proxy_pool.get()
if new_proxy:
request.meta["proxy"] = new_proxy
else:
del request.meta["proxy"]
return super().process_response(request, response, spider)
这个中间件继承自 Scrapy 自带的 RetryMiddleware,保留了它原有的重试逻辑(重试次数、延时、可重试的 HTTP 状态码),只是在出错时多了一步:把坏代理丢进黑名单,换一个新的。
4.3 settings.py 里启用
# settings.py
# 配置代理列表(生产环境建议从配置文件或环境变量读)
PROXY_LIST = [
"http://proxy1.example.com:8080",
"http://proxy2.example.com:8080",
"http://proxy3.example.com:8080",
]
# 禁用默认的重试中间件,用我们的代替
DOWNLOADER_MIDDLEWARES = {
"scrapy.downloadermiddlewares.retry.RetryMiddleware": None,
"scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware": None,
"project.middlewares.RetryProxyMiddleware": 543,
}
# 重试参数
RETRY_ENABLED = True
RETRY_TIMES = 3
RETRY_HTTP_CODES = [500, 502, 503, 504, 408, 429]
注意中间件优先级:543 是 Scrapy 默认 RetryMiddleware 的值。用同样的优先级表示替换原有位置,避免打乱整个处理链的顺序。
五、断线重试的细节
5.1 哪些异常会被捕获
process_exception 收到的是下载器抛出的异常。常见的有:
| 异常 | 触发条件 |
|---|---|
ConnectionRefusedError |
代理服务器拒绝连接 |
TimeoutError / TCPTimedOutError |
代理超时无响应 |
ConnectionResetError |
代理主动断连 |
DNSLookupError |
代理域名解析失败 |
Scrapy 把这些异常统一用 retry_exceptions 配置控制。如果你不想对所有异常都重试,可以改这个配置:
RETRY_EXCEPTIONS = [
"twisted.internet.error.ConnectionRefusedError",
"twisted.internet.error.TCPTimedOutError",
"twisted.internet.error.ConnectionLost",
]
5.2 max_timeout 和代理速度匹配
代理一般比自己直连慢。如果 DOWNLOAD_TIMEOUT 设得太小(比如默认的 180 秒对某些慢代理来说都算短),那代理还没回复就被超时了,会造成大量假失败。
建议针对代理请求单独设一个超时:
# 在 process_request 里
request.meta["download_timeout"] = 30 # 每个代理给 30 秒
或者在中间件初始化时设一个 max_timeout 类变量,通过 settings 给爬虫自己调。
5.3 重试次数不用太多
有人觉得"代理不稳定就多重试几次"。这个思路反了。一个好的代理池,每次换代理的命中率应该在 70% 以上。如果连续 3 次换代理都失败,那大概率不是代理的问题,而是目标站点封了你的请求特征(UA、Cookie、请求频率)。
RETRY_TIMES = 2 # 换 2 次代理重试
# 第一次失败 → 换代理重试
# 第二次失败 → 再换代理重试
# 第三次还失败 → 放弃,记录日志
超过这个次数还失败,应该降级处理而不是无限制重试。
六、生产环境要关注的三个问题
6.1 代理质量比数量重要
假如池子里有 100 个代理,但其中 80 个是失效的,那每次 get() 都大概率摸到坏代理。你的重试机制再好,也救不了质量太差的池子。
我自己的做法:每次请求完成后记录代理的响应时间、成功率,定期清理存活率低于 50% 的代理。
6.2 HTTPS 代理要区分
HTTP 代理和 HTTPS 代理的配置方式不同。对于 HTTPS 请求,代理需要支持 CONNECT 隧道。Scrapy 原生 HttpProxyMiddleware 在处理 https:// URL 时会自动设置 proxy 为 http:// 格式(因为 HTTP 代理的 CONNECT 方法走的是 HTTP 协议),所以写法是一样的:
request.meta["proxy"] = "http://user:pass@proxy.example.com:8080"
但如果你的代理是 SOCKS5,那就需要额外装 scrapy-socks 或者用 socks5:// 前缀 + 自定义中间件处理。
6.3 被封了怎么降级
这是最容易被忽略的。当代理全部用完,proxy_pool.get() 返回 None 时,两种选择:
- 改用直连(不设 proxy),让请求裸奔
- 停止爬虫,等下次调度
我的建议是:如果爬的目标站点不是高敏感类型,临时降级为直连 + 大幅降低请求频率,比彻底停掉要划算。毕竟代理用完通常是暂时的(等冷却恢复就行)。
new_proxy = self.proxy_pool.get()
if new_proxy:
request.meta["proxy"] = new_proxy
else:
# 没有可用代理了,临时直连,但加一个标志
request.meta["proxy"] = None
request.meta["proxy_exhausted"] = True
# 顺便降低这个请求的速度
request.meta["download_delay"] = 10
七、验证你的中间件有没有生效
测三件事就够了:
-
代理确实被用了 :在 log 里看到每个请求的
proxymeta -
坏代理确实被换了:给一个已知的死代理,看中间件是否立刻切到下一个
-
重试确实换了代理 :故意把代理设成
127.0.0.1:1(肯定连不上),看两次重试的 proxy 值是否不同简单验证:在 spider 的 parse 方法里打印
def parse(self, response):
proxy = response.request.meta.get("proxy")
retry_count = response.request.meta.get("retry_times", 0)
self.logger.info(f"Used proxy: {proxy}, retried: {retry_count}")
八、总结
Scrapy 自带的 HttpProxyMiddleware 和 RetryMiddleware 分开工作,导致重试不换代理的问题。解决思路是写一个合并的中间件,继承 RetryMiddleware 并在 process_exception 和 process_response 里加入代理切换逻辑。
核心改动就两处:
- 出错时把当前代理丢进黑名单
- 重试前从池子拿一个新代理
其他所有东西(重试次数、延时、可重试状态码)都复用 Scrapy 原生的逻辑,不用重新造。
如果你正在维护一个长期跑的 Scrapy 爬虫,代理这块值得花半小时配好。否则你会发现日志里全是重试成功但数据没回来的情况,排查起来更费时间。