代理池从 0 到 1 完整实现:批量提取 + 可用性检测 + 自动剔除(附代码)
去年有个项目,需要稳定爬一个电商网站做长期价格监控。单 IP 跑,三天就被封了。当时我第一反应是"加代理",但真正上手才发现:手上拿着一堆 IP 容易,让它们稳定跑起来才是技术活。
这篇文章把我当时踩过的坑梳理出来:一个生产可用的代理池,至少要解决 4 件事——批量提取、可用性检测、轮换使用、失效剔除。一件一件说。
一、先理解代理池要解决什么问题
很多人以为代理池就是"搞一堆 IP 备用"。这只是表象。真实场景下,代理池要同时满足三个要求:
- 够用:IP 数量要覆盖你并发任务的上限。比如你 100 个线程同时跑,至少要有 100 个可用 IP,多留 20% 冗余会更稳。
- 稳定:单个 IP 随时可能失效——可能是目标站封了你,也可能是服务商轮换掉了那个节点。所以池子要能"自动换血",失效的踢掉、新鲜的补进来。
- 可控:要知道每个 IP 当前的状态(可用/不可用/响应时间),按需选择——长任务用稳的、短任务用便宜的。
围绕这三点,代理池的代码可以拆成 4 个模块。
二、模块 1:批量提取 IP
大多数代理池接口都支持 API 批量拉取,返回的格式一般是纯文本(一行一个 IP:PORT)或者 JSON。这里给一个通用的解析方法:
importrequestsfromtypingimportListdeffetch_proxies(api_url:str,count:int=10)->List[str]:"""从 API 批量提取 IP,返回 ['ip:port', ...] 格式列表"""resp=requests.get(f"{api_url}?count={count}",timeout=10)resp.raise_for_status()# 兼容纯文本和 JSON 两种返回格式text=resp.text.strip()iftext.startswith('['):# JSON 格式:[{"ip":"1.2.3.4","port":8080}, ...]data=resp.json()return[f"{item['ip']}:{item['port']}"foritemindata]else:# 纯文本格式:一行一个 ip:portreturn[line.strip()forlineintext.split('\n')ifline.strip()]# 使用示例api="https://你的代理池接口/get"ips=fetch_proxies(api,count=20)print(f"提取到{len(ips)}个 IP")几个要点:
timeout=10必须加——API 接口偶发卡顿会拖死整个池子。- 解析逻辑要兼容多种格式(接口返回格式经常变)。
- 提取失败要 raise,让上层捕获重试,不要静默吞掉。
三、模块 2:可用性检测
提取出来的 IP 不能直接用,必须先验证。这是新手最容易踩坑的地方——
“我明明配了代理,为什么请求还是失败?”
八成是因为 IP 本身就是死的(服务商轮换、目标站封禁、超时)。所以必须先验证。
importrequestsimportconcurrent.futuresfromtypingimportList,Tuple# 验证用的目标站点(推荐选稳定、响应快的)TEST_URL="http://httpbin.org/ip"TEST_TIMEOUT=5defcheck_proxy(proxy:str)->Tuple[str,float,bool]:"""检测单个代理是否可用,返回 (proxy, 响应时间秒, 是否可用)"""proxies={"http":f"http://{proxy}","https":f"http://{proxy}",}try:resp=requests.get(TEST_URL,proxies=proxies,timeout=TEST_TIMEOUT)resp.raise_for_status()# 用响应时间作为质量评分(越短越好)latency=resp.elapsed.total_seconds()return(proxy,latency,True)exceptException:return(proxy,float('inf'),False)defbatch_check(proxies:List[str],max_workers:int=50)->List[Tuple[str,float,bool]]:"""并发检测一批代理"""results=[]withconcurrent.futures.ThreadPoolExecutor(max_workers=max_workers)asexecutor:futures={executor.submit(check_proxy,p):pforpinproxies}forfutureinconcurrent.futures.as_completed(futures):results.append(future.result())returnresults# 使用示例raw_ips=fetch_proxies(api,count=50)checked=batch_check(raw_ips)available=[(p,lat)forp,lat,okincheckedifok]print(f"50 个 IP 中,可用{len(available)}个")几个要点:
- 用
httpbin.org/ip做测试目标——它返回你的出口 IP,能直接看到代理是否生效。 - 必须并发检测:50 个 IP 串行检测要 50 秒,并发 5 秒搞定。
- 响应时间是宝贵的质量信号——保存下来用于后续"优选 IP"。
- 异常一律吞掉,返回
(proxy, inf, False)即可,不要 raise。
四、模块 3:轮换策略
可用 IP 拿到手了,怎么用?最常见的有 3 种策略:
策略 1:随机轮换(最常用)
importrandomdefget_random_proxy(pool:List[str])->str:"""从池中随机选一个"""returnrandom.choice(pool)简单粗暴,但有效。适合大部分场景。
策略 2:加权轮换(按质量排序)
defget_weighted_proxy(pool:List[Tuple[str,float]])->str:"""按响应时间加权,响应越短权重越高"""# 响应时间越短,分数越高weights=[1.0/(lat+0.01)for_,latinpool]proxies=[pforp,_inpool]returnrandom.choices(proxies,weights=weights,k=1)[0]适合对稳定性要求高的场景(比如长任务、登录态保持)。
策略 3:会话保持(同一个任务用同一个 IP)
classSessionPool:"""会话级代理池:同一个 session_id 始终用同一个 IP"""def__init__(self,pool:List[Tuple[str,float]]):self.pool=pool self.sessions={}# session_id -> proxydefget_for_session(self,session_id:str)->str:ifsession_idnotinself.sessions:self.sessions[session_id]=get_weighted_proxy(self.pool)returnself.sessions[session_id]适合需要登录态或者 Cookie 不希望被刷新的场景。
五、模块 4:失效剔除(最容易被忽略)
池子里面的 IP 不是一成不变的。失效原因五花八门,常见的有这么几种:
- 目标站封禁——你的 IP 被对方拉黑了。
- 服务端下线——服务商轮换了节点,老 IP 直接连不上。
- 网络抖动——临时不可用,过段时间又恢复。
如果不管这些失效 IP,你的爬虫会频繁超时、重试、失败,最后整个任务拖垮。
importthreadingimporttimeimportrandomfromtypingimportList,TupleclassProxyPool:"""完整的代理池实现"""def__init__(self,api_url:str,check_interval:int=300):self.api_url=api_url self.check_interval=check_interval# 检测间隔(秒)self.pool:List[Tuple[str,float]]=[]# (ip:port, latency)self.lock=threading.Lock()self._stop=Falseself._thread=Nonedefstart(self):"""启动后台检测线程"""self._refresh()self._thread=threading.Thread(target=self._loop,daemon=True)self._thread.start()print(f"代理池启动,当前可用 IP:{len(self.pool)}个")defstop(self):self._stop=Trueifself._thread:self._thread.join()defget(self)->str:"""线程安全地获取一个代理"""withself.lock:ifnotself.pool:raiseRuntimeError("代理池为空,请等待初始化完成")# 按响应时间加权选择proxies=[pforp,_inself.pool]weights=[1.0/(lat+0.01)for_,latinself.pool]returnrandom.choices(proxies,weights=weights,k=1)[0]def_refresh(self):"""重新提取 + 重新检测"""try:raw=fetch_proxies(self.api_url,count=30)checked=batch_check(raw)new_pool=[(p,lat)forp,lat,okincheckedifok]withself.lock:self.pool=new_poolexceptExceptionase:print(f"代理池刷新失败:{e}")def_loop(self):"""定时刷新"""whilenotself._stop:time.sleep(self.check_interval)self._refresh()# 使用示例pool=ProxyPool(api_url="https://你的代理池接口/get",check_interval=300)pool.start()# 在爬虫中使用forpageinrange(1,100):proxy=pool.get()proxies={"http":f"http://{proxy}","https":f"http://{proxy}"}resp=requests.get(f"https://目标网站.com/list?page={page}",proxies=proxies)# ... 处理响应pool.stop()几个要点:
check_interval不要设太短——频繁检测本身会增加接口负担,反而容易被风控。建议 300 秒(5 分钟)起。- 后台线程用
daemon=True,主程序退出时自动结束,不会变成僵尸进程。 _refresh失败要兜底——不要因为一次失败就把整个池子清空。
六、5 个常见坑(避坑清单)
坑 1:检测时并发数太高把出口 IP 拉黑
max_workers=50检测 1000 个 IP,对方站点会看到你这台机器 1 秒发 50 个请求,轻则限速,重则直接拉黑。
建议:检测并发控制在 10-20,单 IP 验证完释放再下一个。或者用多个测试目标轮询。
坑 2:检测通过的 IP,实际请求时还是失败
可能是测试站点(httpbin)允许的访问,但目标站点拒绝。建议:测试目标和真实目标是不同反爬强度时,要分别检测。
坑 3:响应时间慢的 IP 也用了
latency > 5s的 IP 基本等于半死不活。建议加个阈值过滤:
available=[(p,lat)forp,lat,okincheckedifokandlat<5.0]坑 4:池子全空时直接 raise 崩溃
业务高峰时所有 IP 同时失效,池子瞬间清空,你的爬虫直接抛异常崩盘。建议:池子空时返回上次用过的 IP(标记为"不确定"),而不是抛异常。
坑 5:测试用真实业务目标,导致对方站点大量无效请求
httpbin.org / example.com 这种专门做测试的站点是首选。不要用你的真实业务目标做检测——会被反爬系统标记。
七、常见问题
Q1:池子要多大够用?
看你的并发量。建议并发数 × 1.5(留 50% 冗余)。10 个线程并发,15 个 IP 起步;100 个线程,150 个 IP 起步。
Q2:检测间隔多久合适?
5 分钟(300 秒)是个不错的起点。如果你的 IP 失效很快(目标站封得猛),可以缩到 60-120 秒;如果 IP 相对稳定,可以放到 600 秒。
Q3:HTTP 和 SOCKS5 代理能混用吗?
可以但建议分开——检测时分别验证,使用时按协议选。如果混用,requests 的 proxies 配置会变复杂。
Q4:代理池要部署在哪?
本地能跑,但生产建议部署在与目标站同区域(比如目标站主要用户在国内,就放国内服务器)。跨区域访问会显著降低速度。
Q5:怎么知道代理池在正常工作?
加日志:pool.start()时打印池大小,每次get()命中时打印当前池大小。突然降为 0 或者响应时间普遍飙升,就是异常信号。
写到这里基本完整了。代理池的核心不是"IP 多",而是"自动换血 + 自动剔除"。把这套搭起来,你会发现爬虫稳定性上了一个台阶——从三天两头掉线变成连续跑一周不掉。
八、调试技巧(没人告诉你的那些)
最后说几个实际调试中走过弯路、后来才想明白的技巧,都是书本不会写的:
技巧 1:加一个 fallback IP
池子里的 IP 全部失效时,爬虫会直接挂掉。生产环境不能冒这个险。
defget_with_fallback(self,fallback:str="127.0.0.1:0")->str:"""获取代理,池子空时返回 fallback(本地直连或占位)"""withself.lock:ifnotself.pool:returnfallback# ... 正常选择逻辑业务层可以判断:如果返回的是 fallback,就暂停任务、报警、走人工介入流程。
技巧 2:响应时间用滑动平均,不用瞬时值
单个 IP 的响应时间波动很大(早 9 点快、晚 9 点慢)。直接拿瞬时值做权重会抖动剧烈。
# 伪代码:滑动平均new_latency=0.7*old_latency+0.3*current_latency70% 历史 + 30% 当前,平滑很多。
技巧 3:分组检测,别全量刷新
200 个 IP 全量检测要 10 秒。这 10 秒内业务线程都在等连接,体验卡顿。
按 IP 末位分组,比如ip.endswith('1')的为一组,5 分钟轮换一组。这样每 5 分钟只检测 20 个,检测时间 1 秒,不影响业务。
技巧 4:把检测结果落到本地文件
importjsondefsave_state(self,path="proxy_pool_state.json"):withself.lock:withopen(path,'w')asf:json.dump(self.pool,f)defload_state(self,path="proxy_pool_state.json"):try:withopen(path)asf:self.pool=json.load(f)exceptFileNotFoundError:pass程序重启时不用等 5 分钟重新检测,直接加载上次的可用 IP。这种"暖启动"对长跑任务特别有用。
技巧 5:加监控告警
池子里 IP 数量跌破阈值(比如少于 20 个)就发通知。短信、邮件、企微 webhook 都可以。
def_refresh(self):# ... 检测完后iflen(self.pool)<20:send_alert(f"代理池可用 IP 仅{len(self.pool)}个,请检查")这一条能救命——你不会半夜被业务叫醒,但会自动收到通知。
九、写在最后
代理池这个东西,写完一次到处能用。我后来做的几个项目都直接 copy 这套代码过来改改 API 地址,省了大量重复劳动。
如果你是第一次搭代理池,建议按这个顺序推进,别一上来就想着搭完美的:
- 先跑通「批量提取 + 单个检测」,达到最小可用。
- 再加上「并发批量检测」,这一步能显著提速。
- 接着上「轮换策略」,让池子真的能用。
- 最后才加「失效剔除 + 后台线程」,这一步才到生产可用。
我会卡在设计阶段很久——结果往往是写一半就放弃了。先跑通,再迭代,比一遍写到完美靠谱得多。
下一篇我打算写 requests 的 Session 性能优化(跟今天这篇呼应),如果你正在为爬虫速度发愁,可以关注。