
刚开始用短效代理时,我一直觉得自己维护 IP 池也没多复杂。
调接口取一批 IP,顺手记下过期时间;某个节点连接失败,就换下一个。一个脚本这么写没问题,两个脚本也能继续凑合。等任务开始长时间运行,进程多起来,麻烦才慢慢冒出来。
哪个 IP 正在被使用,哪个已经连续失败,指定城市的资源要放进哪个池子,不同进程怎么避免反复拿到同一条记录……业务代码没增加多少,维护代理的代码倒是越来越长。
也是到了这一步,我才会考虑把本地 IP 池换成隧道代理。
隧道代理的思路不复杂:程序不再直接拿到一批出口 IP,而是始终连接一个固定入口。具体从哪个出口转发、什么时候切换,由服务端处理。
代码确实会干净不少。
import os
import requests
proxy_url = os.environ["TUNNEL_PROXY_URL"]
proxies = {
"http": proxy_url,
"https": proxy_url,
}
response = requests.get(
"https://example.com/public-page",
proxies=proxies,
timeout=(5, 15),
)
print(response.status_code)拿极安代理的国内隧道产品来说,它采用的就是这种统一入口方式,出口切换和异常节点处理放在云端,程序侧不再需要维护具体 IP。同时支持 HTTP、HTTPS、SOCKS5,以及白名单和账密两种常见鉴权方式。原来的程序已经支持标准代理配置时,通常改一下地址和认证信息就能接入。
不过,我不会因为这十几行代码能跑通,就认定产品可以上线。
固定入口只解决了“程序不用自己存一堆 IP”这件事。出口多久换一次、峰值请求能不能接住、连接异常以后程序怎么处理,这些问题还在。
隧道代理并不都是每个请求更换出口。
有的产品默认每次请求切换,有的可以保持几十秒或几分钟,还有的能根据地区设置转发规则。这个区别看起来只是后台多了几个选项,放进程序里,影响却很直接。
如果每个请求互不相关,比如逐个读取一批公开页面,每次换出口一般没什么问题。
但有些程序不是这么跑的。它可能先访问列表页,拿到 Cookie 或其他状态,再继续请求详情页。即使代码始终使用同一个 Session,也不代表代理出口会保持不变。
import requests
def create_session(proxies):
session = requests.Session()
session.proxies.update(proxies)
session.headers.update({
"User-Agent": "DataClient/1.0",
})
return session
def fetch_pages(session):
list_page = session.get(
"https://example.com/public-list",
timeout=(5, 15),
)
list_page.raise_for_status()
detail_page = session.get(
"https://example.com/public-detail",
timeout=(5, 15),
)
detail_page.raise_for_status()
return detail_page.text要是隧道设置成每次请求换出口,上面两次请求就可能来自不同 IP。程序本身没写错,代理也正常工作,但会话就是接不上。
所以我通常先把任务分成两类。
请求彼此独立,优先测试按请求切换;一组请求需要保持状态,就看能不能按固定周期切换。如果产品只能每次请求更换,而业务偏偏依赖连续会话,我一般不会继续研究套餐,直接换产品形态更省时间。
以前自己维护 IP 池时,节点失败以后要手动替换。换成隧道代理,服务端确实能承担一部分异常出口切换,但应用侧的超时和重试还是不能删。
我比较反感把所有异常都塞进一个 except Exception,然后统一重试。这样写起来省事,真出问题时几乎没有排查线索。
至少要把代理错误、连接超时和读取超时分开。
import time
from typing import Any
import requests
from requests.exceptions import (
ConnectTimeout,
ProxyError,
ReadTimeout,
RequestException,
)
def request_once(
session: requests.Session,
url: str,
) -> dict[str, Any]:
started_at = time.perf_counter()
try:
response = session.get(
url,
timeout=(5, 15),
)
elapsed_ms = (
time.perf_counter() - started_at
) * 1000
return {
"type": "response",
"status_code": response.status_code,
"elapsed_ms": round(elapsed_ms, 2),
"response_bytes": len(response.content),
}
except ProxyError as exc:
return {
"type": "proxy_error",
"message": str(exc),
}
except ConnectTimeout:
return {
"type": "connect_timeout",
}
except ReadTimeout:
return {
"type": "read_timeout",
}
except RequestException as exc:
return {
"type": "request_error",
"message": str(exc),
}代理连接失败,更接近入口、鉴权或转发问题。读取超时则不一定是代理导致的,目标页面响应慢、内容较大或者超时设得太短,都有可能。
这也是我不太愿意直接拿供应商公布的 IP 可用率判断业务效果的原因。IP 可以建立连接,不代表目标页面一定能按预期返回。两件事最好分开记录。
重试也别写成死循环。我通常只给网络类异常有限的机会:
import time
RETRYABLE_ERRORS = {
"proxy_error",
"connect_timeout",
"read_timeout",
}
def fetch_with_retry(
session,
url,
max_attempts=3,
):
last_result = None
for attempt in range(max_attempts):
result = request_once(session, url)
last_result = result
if result["type"] == "response":
return result
if result["type"] not in RETRYABLE_ERRORS:
return result
if attempt < max_attempts - 1:
time.sleep(0.5 * (2 ** attempt))
return last_result配置错误反复重试没有意义,明确的业务状态也不能一律靠换出口解决。隧道代理可以减少 IP 调度工作,但不能代替程序本身的错误处理。
不少产品会写不限终端。这个条件对企业确实有用,多台服务器不用分别购买独立账号。
但不限终端不等于不限请求。
假设有五台服务器,每台每秒发出两个请求,最后占用的仍然是每秒十次的整体请求频率。采购时如果只拿单台机器的频率去选套餐,上线后很容易碰到容量问题。
我一般会先从日志里拿两个数字:正常运行时平均每秒多少请求,峰值时每秒多少请求。峰值偶尔出现和连续半小时维持在高位,也不是一回事。
测试时,可以用线程池先观察并发下的异常分布:
from concurrent.futures import (
ThreadPoolExecutor,
as_completed,
)
from statistics import median
def percentile(values, ratio):
if not values:
return None
index = int((len(values) - 1) * ratio)
return values[index]
def run_probe(
session_factory,
url,
total_requests=200,
workers=5,
):
def execute():
session = session_factory()
return request_once(session, url)
results = []
with ThreadPoolExecutor(
max_workers=workers,
) as executor:
futures = [
executor.submit(execute)
for _ in range(total_requests)
]
for future in as_completed(futures):
results.append(future.result())
responses = [
item
for item in results
if item["type"] == "response"
]
elapsed = sorted(
item["elapsed_ms"]
for item in responses
)
return {
"total": len(results),
"responses": len(responses),
"proxy_errors": sum(
item["type"] == "proxy_error"
for item in results
),
"connect_timeouts": sum(
item["type"] == "connect_timeout"
for item in results
),
"read_timeouts": sum(
item["type"] == "read_timeout"
for item in results
),
"median_ms": (
median(elapsed)
if elapsed
else None
),
"p95_ms": percentile(elapsed, 0.95),
}线程数不能直接等同于每秒请求数,但这段测试能先看出,多任务一起运行时异常会不会明显增加。
正式测套餐时,我会再加一个限速器,让请求稳定在计划频率附近,不会一上来就把所有请求瞬间发出去。
import threading
import time
class RateLimiter:
def __init__(self, requests_per_second):
self.interval = 1 / requests_per_second
self.lock = threading.Lock()
self.next_time = time.perf_counter()
def wait(self):
with self.lock:
now = time.perf_counter()
if now < self.next_time:
time.sleep(self.next_time - now)
self.next_time = (
max(now, self.next_time)
+ self.interval
)在真正发出请求前调用:
limiter = RateLimiter(
requests_per_second=5,
)
def limited_request(session, url):
limiter.wait()
return request_once(session, url)平均响应时间我也不会单独看。
大部分请求很快、少量请求特别慢时,平均数很容易把问题盖住。中位数、P95 和超时次数更有参考价值。常用地区是否能够正常分配、出口变化是否符合会话要求,也应该单独记录。
如果本地 IP 池已经占用了不少维护时间,而程序本身并不需要精确控制每一条出口,隧道代理通常值得测试。
测试也不用做得特别复杂。我会拿现有程序最常跑的几个公开页面,保持地区、请求频率和运行时间不变,换成隧道入口连续跑一段时间。重点看连接超时、读取超时、P95 响应时间,以及出口切换会不会影响会话。
测试结果如果与原来的本地 IP 池接近,同时代码和日常维护明显简单了,切换就有意义。
如果程序依赖固定出口、长时间会话,或者需要自己精确决定每条 IP 的使用方式,我不会为了省几段管理代码强行换隧道。
说到底,统一入口帮我省掉的是 IP 调度,不是整个程序的稳定性设计。超时、有限重试、日志和容量统计,该保留的一个也不能少。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。