9405 lines
154 KiB
Markdown
9405 lines
154 KiB
Markdown
可以做,但先把目标拆清楚:“1 万并发连接”不等于“每秒 1 万请求”。
|
||
|
||
你截图里的上游看起来支持直连池、转发池、混合池,以及固定时长和短效 IP。聚合代理池不要让业务端直接调用这些供应商 API,而是在中间做一层统一代理网关。
|
||
|
||
推荐架构
|
||
业务客户端
|
||
│
|
||
│ HTTP / HTTPS CONNECT / SOCKS5
|
||
▼
|
||
负载均衡层
|
||
LVS / HAProxy / Envoy
|
||
│
|
||
├───────────────┐
|
||
▼ ▼
|
||
Proxy Worker 1 Proxy Worker N
|
||
│ │
|
||
└──── 本地可用代理缓存 ────┐
|
||
│
|
||
Proxy Control Plane
|
||
├─ 上游供应商适配器
|
||
├─ IP 获取与续期
|
||
├─ 健康检查
|
||
├─ 评分与淘汰
|
||
├─ 会话绑定
|
||
└─ Redis / PostgreSQL
|
||
│
|
||
┌───────────────┼───────────────┐
|
||
▼ ▼ ▼
|
||
上游代理商 A 上游代理商 B 自建节点
|
||
|
||
整个系统分成两个部分:
|
||
|
||
1. 数据面 Proxy Worker
|
||
|
||
真正转发流量,要求:
|
||
|
||
无状态或弱状态
|
||
不在每个请求中查询数据库
|
||
不在每个请求中调用上游提取 API
|
||
使用内存中的代理列表完成选择
|
||
支持 HTTP、HTTPS CONNECT,按需要增加 SOCKS5
|
||
使用异步网络模型
|
||
连接池复用上游连接
|
||
支持失败快速切换
|
||
|
||
推荐使用:
|
||
|
||
Go:开发速度快,处理数万连接比较轻松
|
||
Rust + Tokio:性能和资源控制更强,开发成本较高
|
||
前置负载均衡:HAProxy、Envoy 或 LVS
|
||
|
||
不建议用普通 Python Web 框架直接承担核心转发流量。
|
||
|
||
2. 控制面 Control Plane
|
||
|
||
负责管理上游代理资源:
|
||
|
||
定时从多个供应商拉取代理
|
||
统一不同供应商的 API 格式
|
||
测试代理是否可用
|
||
判断出口国家、地区、运营商
|
||
维护过期时间
|
||
管理失败次数和封禁情况
|
||
将变更推送给 Proxy Worker
|
||
做供应商额度和费用统计
|
||
|
||
控制面可以使用 Go、Java、Python,压力通常远低于代理转发层。
|
||
|
||
核心数据结构
|
||
|
||
不要只保存 IP:端口,至少要保存:
|
||
|
||
{
|
||
"id": "provider-a-123456",
|
||
"provider": "provider-a",
|
||
"host": "1.2.3.4",
|
||
"port": 8080,
|
||
"protocol": "http",
|
||
"username": "user",
|
||
"password": "pass",
|
||
"region": "jp-tokyo",
|
||
"carrier": "mobile",
|
||
"pool_type": "rotating",
|
||
"expires_at": 1785220000,
|
||
"last_check_at": 1785219900,
|
||
"latency_ms": 143,
|
||
"active_connections": 28,
|
||
"success_count": 2395,
|
||
"failure_count": 61,
|
||
"consecutive_failures": 0,
|
||
"score": 87.4,
|
||
"status": "healthy"
|
||
}
|
||
|
||
代理状态建议:
|
||
|
||
NEW
|
||
CHECKING
|
||
HEALTHY
|
||
DEGRADED
|
||
COOLDOWN
|
||
DEAD
|
||
EXPIRED
|
||
代理选择算法
|
||
|
||
不要纯随机。纯随机很容易把大量请求压到已经变慢的节点上。
|
||
|
||
可以使用加权最少连接:
|
||
|
||
effective_score =
|
||
基础成功率权重
|
||
+ 延迟权重
|
||
+ 剩余有效期权重
|
||
- 当前连接数权重
|
||
- 连续失败惩罚
|
||
- 最近封禁惩罚
|
||
|
||
例如:
|
||
|
||
score =
|
||
success_rate * 50
|
||
+ max(0, 30 - latency_ms / 50)
|
||
+ expiry_score
|
||
- active_connections * 0.2
|
||
- consecutive_failures * 10
|
||
|
||
选择时,从当前得分最高的一组代理中随机挑选,而不是永远选第一名:
|
||
|
||
候选代理 Top 20
|
||
↓
|
||
按 score 加权随机
|
||
↓
|
||
选中代理
|
||
|
||
这样既能均衡,也避免热点。
|
||
|
||
短效代理和固定时长代理怎么处理
|
||
|
||
截图里有“固定 1 分钟”“有效 1~2 分钟”等模式,这两类不能混着用。
|
||
|
||
固定时长代理
|
||
|
||
适合:
|
||
|
||
登录态
|
||
Cookie 会话
|
||
连续分页
|
||
同一个任务要求固定出口 IP
|
||
WebSocket 或长连接
|
||
|
||
创建一个会话绑定:
|
||
|
||
session_id -> upstream_proxy_id
|
||
|
||
例如客户端使用:
|
||
|
||
Proxy-Authorization: Basic xxx
|
||
X-Proxy-Session: order-182736
|
||
|
||
或者把 session 放进代理用户名:
|
||
|
||
username-session-order182736
|
||
|
||
会话绑定不能只存在 Redis。Proxy Worker 应有本地缓存,Redis 作为共享和恢复层。
|
||
|
||
短效代理
|
||
|
||
适合:
|
||
|
||
无状态抓取
|
||
单次请求
|
||
高频轮换
|
||
不要求保持 Cookie 会话
|
||
|
||
短效代理应在过期前提前停止分配。例如代理还有 10 秒过期,而请求预计可能运行 30 秒,就不能继续使用。
|
||
|
||
可分配条件:
|
||
|
||
expires_at - now > expected_request_time + safety_margin
|
||
|
||
安全余量可以设置为 15~30 秒。
|
||
|
||
上游代理不要“一请求一提取”
|
||
|
||
这是很多代理池最容易踩的坑。
|
||
|
||
错误流程:
|
||
|
||
客户端请求
|
||
→ 调供应商 API 提取一个 IP
|
||
→ 等待返回
|
||
→ 使用代理
|
||
|
||
这会造成:
|
||
|
||
上游 API 成为瓶颈
|
||
请求延迟显著增加
|
||
供应商接口被限频
|
||
上游暂时故障时整个系统停摆
|
||
|
||
正确流程:
|
||
|
||
后台提前获取代理
|
||
→ 健康检查
|
||
→ 放进内存池
|
||
→ 请求直接挑选
|
||
→ 代理数量低于阈值时补充
|
||
|
||
例如:
|
||
|
||
目标池容量:5000
|
||
低水位:3000
|
||
高水位:6000
|
||
|
||
低于 3000 时批量补充到 6000
|
||
|
||
固定时长代理还需要控制购买速度,避免一次买太多后同时过期。
|
||
|
||
健康检查设计
|
||
|
||
不要只检查“能不能访问百度”。不同目标网站的可用性可能完全不同。
|
||
|
||
建议分三层。
|
||
|
||
第一层:基础连接检查
|
||
|
||
检查:
|
||
|
||
TCP 是否能建立
|
||
代理认证是否成功
|
||
HTTP 请求是否成功
|
||
HTTPS CONNECT 是否成功
|
||
第二层:出口检查
|
||
|
||
访问自有检测接口,返回:
|
||
|
||
{
|
||
"ip": "1.2.3.4",
|
||
"country": "JP",
|
||
"asn": 12345,
|
||
"protocol": "https",
|
||
"timestamp": 1785219900
|
||
}
|
||
|
||
最好自己部署检测服务,避免公共 IP 查询服务成为瓶颈。
|
||
|
||
第三层:目标域名检查
|
||
|
||
针对不同业务建立独立评分:
|
||
|
||
proxy_id + target_group
|
||
|
||
例如:
|
||
|
||
代理 1:
|
||
通用网站成功率 99%
|
||
Google 成功率 96%
|
||
某业务站点成功率 32%
|
||
|
||
代理本身没有坏,只是被特定目标限制,因此不应直接全局淘汰。
|
||
|
||
失败切换
|
||
|
||
一次请求最多重试 1~2 次,不能无限重试。
|
||
|
||
请求代理 A
|
||
├─ 连接失败:立刻切换
|
||
├─ 407:代理认证或额度异常,不重试该供应商
|
||
├─ 429:目标限流,切换 IP,并降低该目标评分
|
||
├─ 403:根据业务规则决定是否切换
|
||
├─ 5xx:区分目标站异常和代理异常
|
||
└─ 超时:切换,并增加代理失败计数
|
||
|
||
POST 等非幂等请求不能默认自动重试,否则可能造成重复提交。
|
||
|
||
HTTPS CONNECT 建立后,如果中间连接断开,也不能透明重放里面的数据。
|
||
|
||
1 万并发的容量估算
|
||
|
||
假设:
|
||
|
||
10,000 个同时在线连接
|
||
平均每个连接 100 KB/s
|
||
流量需要一进一出
|
||
不考虑额外协议开销
|
||
|
||
则总转发量大约:
|
||
|
||
10,000 × 100 KB/s = 1 GB/s
|
||
|
||
也就是约:
|
||
|
||
8 Gbps 入站 + 8 Gbps 出站
|
||
|
||
因此代理系统真正的瓶颈往往不是 CPU,而是:
|
||
|
||
网卡带宽
|
||
上游带宽
|
||
NAT 端口
|
||
文件描述符
|
||
内核连接跟踪
|
||
TLS 和 CONNECT 建连
|
||
上游供应商并发限制
|
||
|
||
如果每个连接平均只有 10 KB/s,则总流量约 800 Mbps,要求会低很多。
|
||
|
||
初始部署建议
|
||
|
||
先以如下规模压测:
|
||
|
||
负载均衡:
|
||
2 台,4~8 核,主备或 ECMP
|
||
|
||
Proxy Worker:
|
||
4 台起步
|
||
每台 8~16 核
|
||
16~32 GB 内存
|
||
10 Gbps 网卡
|
||
|
||
控制面:
|
||
2 台,4~8 核
|
||
|
||
Redis:
|
||
3 节点或哨兵模式
|
||
|
||
PostgreSQL:
|
||
主从或云数据库
|
||
|
||
单台 Go Proxy Worker 承担 5,000~20,000 个空闲或低流量连接通常不难,但实际吞吐量必须按响应大小、TLS、上游延迟和带宽压测。不要根据“连接数”直接估算机器数量。
|
||
|
||
Linux 参数
|
||
|
||
至少检查这些设置:
|
||
|
||
ulimit -n 1000000
|
||
|
||
常用内核参数示例:
|
||
|
||
fs.file-max = 2000000
|
||
|
||
net.core.somaxconn = 65535
|
||
net.core.netdev_max_backlog = 65535
|
||
|
||
net.ipv4.ip_local_port_range = 10240 65535
|
||
net.ipv4.tcp_fin_timeout = 15
|
||
net.ipv4.tcp_max_syn_backlog = 65535
|
||
|
||
net.ipv4.tcp_keepalive_time = 60
|
||
net.ipv4.tcp_keepalive_intvl = 15
|
||
net.ipv4.tcp_keepalive_probes = 4
|
||
|
||
不要盲目启用网上流传的所有 tcp_tw_reuse、conntrack 优化参数。具体设置取决于机器承担的是入口代理、出口代理还是 NAT 网关。
|
||
|
||
如果 Proxy Worker 通过单一出口 IP 主动连接大量相同目标,可能出现临时端口耗尽。解决方法包括:
|
||
|
||
多个源 IP
|
||
多台 Worker
|
||
上游连接复用
|
||
避免无意义短连接
|
||
扩大目标代理数量
|
||
合理控制 TIME_WAIT
|
||
Redis 应该存什么
|
||
|
||
Redis 可以保存:
|
||
|
||
Session 到代理的绑定
|
||
代理状态快照
|
||
上游额度
|
||
限流计数
|
||
黑名单
|
||
分布式锁
|
||
Worker 注册信息
|
||
|
||
但请求转发时不要做:
|
||
|
||
每个请求 → Redis 查询全部代理 → 排序 → 选一个
|
||
|
||
正确方式:
|
||
|
||
控制面更新 Redis
|
||
↓
|
||
通过 Pub/Sub、Stream 或 gRPC 推送变化
|
||
↓
|
||
Worker 在内存中维护可用代理表
|
||
↓
|
||
请求只访问本机内存
|
||
|
||
Redis 临时故障时,现有 Worker 仍应能使用已有代理继续工作。
|
||
|
||
上游供应商适配层
|
||
|
||
每个供应商写一个 Adapter:
|
||
|
||
type Provider interface {
|
||
Fetch(ctx context.Context, req FetchRequest) ([]Proxy, error)
|
||
Balance(ctx context.Context) (Balance, error)
|
||
ParseError(err error) ProviderError
|
||
Supports() Capabilities
|
||
}
|
||
|
||
统一请求参数:
|
||
|
||
type FetchRequest struct {
|
||
Count int
|
||
Region string
|
||
Protocol string
|
||
PoolType string
|
||
Lifetime time.Duration
|
||
}
|
||
|
||
供应商返回格式即使是 TXT、JSON、HTML,也在 Adapter 中转换成统一结构。
|
||
|
||
身份认证和租户控制
|
||
|
||
对下游客户端至少需要:
|
||
|
||
API Key 或用户名密码
|
||
单租户并发限制
|
||
每秒请求限制
|
||
每日流量限制
|
||
可访问目标限制
|
||
来源 IP 白名单
|
||
独立会话数限制
|
||
|
||
例如:
|
||
|
||
租户 A:
|
||
最大并发 500
|
||
新建连接速率 1000/s
|
||
流量 200 Mbps
|
||
最大固定会话 100
|
||
|
||
否则某一个客户就可能把整个池打满。
|
||
|
||
还应禁止访问:
|
||
|
||
127.0.0.0/8
|
||
私有网段
|
||
云平台元数据地址
|
||
本机管理端口
|
||
内部数据库
|
||
未授权的内网服务
|
||
|
||
特别是:
|
||
|
||
169.254.169.254
|
||
|
||
否则开放代理很容易变成 SSRF 和内网攻击入口。
|
||
|
||
监控指标
|
||
|
||
Prometheus 至少采集:
|
||
|
||
proxy_active_connections
|
||
proxy_new_connections_total
|
||
proxy_requests_total
|
||
proxy_request_duration_seconds
|
||
proxy_bytes_in_total
|
||
proxy_bytes_out_total
|
||
proxy_connect_errors_total
|
||
proxy_upstream_timeout_total
|
||
proxy_retry_total
|
||
proxy_pool_healthy
|
||
proxy_pool_degraded
|
||
proxy_pool_expired
|
||
proxy_provider_fetch_errors_total
|
||
proxy_provider_balance
|
||
proxy_target_status_total
|
||
|
||
日志中记录:
|
||
|
||
request_id
|
||
tenant_id
|
||
session_id
|
||
provider
|
||
proxy_id
|
||
target_host
|
||
connect_time
|
||
first_byte_time
|
||
total_time
|
||
bytes_in
|
||
bytes_out
|
||
status
|
||
error_type
|
||
retry_count
|
||
|
||
不要默认记录完整 URL 参数、Authorization、Cookie 和请求体,避免泄露敏感信息。
|
||
|
||
推荐的第一版技术栈
|
||
|
||
比较务实的一套是:
|
||
|
||
入口:
|
||
HAProxy
|
||
|
||
核心代理:
|
||
Go
|
||
net/http + 自定义 CONNECT 转发
|
||
或基于成熟代理组件二次开发
|
||
|
||
控制面:
|
||
Go
|
||
|
||
配置和元数据:
|
||
PostgreSQL
|
||
|
||
运行状态:
|
||
Redis
|
||
|
||
配置推送:
|
||
gRPC Stream 或 Redis Stream
|
||
|
||
监控:
|
||
Prometheus + Grafana
|
||
|
||
日志:
|
||
Loki / Elasticsearch
|
||
|
||
部署:
|
||
Docker Compose 起步
|
||
规模扩大后再上 Kubernetes
|
||
|
||
第一版不用急着做特别复杂的微服务,可以先拆成三个程序:
|
||
|
||
proxy-gateway
|
||
proxy-controller
|
||
proxy-checker
|
||
一个合理的请求流程
|
||
1. 客户端连接 Proxy Gateway
|
||
2. 验证客户端身份和并发额度
|
||
3. 根据租户、地区、协议、目标域名查找本地代理池
|
||
4. 如果存在 session_id,优先获取绑定代理
|
||
5. 根据评分和当前连接数选择上游
|
||
6. 建立上游连接
|
||
7. 转发数据
|
||
8. 记录成功率、延迟、字节数
|
||
9. 失败时按规则切换一次代理
|
||
10. 异步更新代理评分
|
||
|
||
最重要的原则是:
|
||
|
||
转发热路径只做认证、内存选路和网络转发;提取代理、健康检查、数据库操作、评分计算全部放在后台。
|
||
|
||
按照你截图里的上游模式,我会优先设计成“固定会话池”和“短效轮换池”两个完全独立的资源池,再通过统一网关对外提供服务。这样比把所有 IP 混在一个列表里稳定得多。
|
||
|
||
可以,而且这正是控制面应该做的核心能力。建议每个上游代理商独立配置一个“有时效的弹性缓存池”。
|
||
|
||
每个代理商的配置
|
||
providers:
|
||
provider_a:
|
||
enabled: true
|
||
|
||
pool:
|
||
min_size: 1000 # 最少保持的可分配 IP
|
||
max_size: 3000 # 最多缓存的未过期 IP
|
||
target_size: 2000 # 正常情况下希望维持的数量
|
||
|
||
fetch:
|
||
batch_size: 200 # 每次向代理商提取多少个
|
||
min_interval: 2s # 两次提取的最小间隔
|
||
timeout: 5s
|
||
max_retries: 2
|
||
max_qps: 1 # 代理商接口限速
|
||
|
||
lifetime:
|
||
default_ttl: 120s # 上游未返回有效期时使用
|
||
safety_margin: 20s # 到期前 20 秒停止分配
|
||
delete_margin: 5s # 到期后清理
|
||
min_remaining_ttl: 30s
|
||
|
||
health_check:
|
||
interval: 20s
|
||
timeout: 3s
|
||
max_failures: 3
|
||
concurrency: 100
|
||
|
||
capacity:
|
||
max_connections_per_ip: 50
|
||
max_requests_per_second_per_ip: 10
|
||
|
||
其中三个值含义不同:
|
||
|
||
minSize 触发补充的最低水位
|
||
targetSize 每次补充后希望达到的数量
|
||
maxSize 绝对不能超过的缓存上限
|
||
|
||
例如:
|
||
|
||
minSize = 1000
|
||
targetSize = 2000
|
||
maxSize = 3000
|
||
|
||
当可用代理降到 1000 以下时,补充到 2000,而不是直接补到 3000。
|
||
|
||
不能只看缓存总数
|
||
|
||
因为 IP 有时效,池容量至少要分为:
|
||
|
||
total_count 当前缓存总量
|
||
available_count 当前可分配数量
|
||
reserved_count 已被固定会话绑定的数量
|
||
expiring_count 即将过期数量
|
||
checking_count 正在检测数量
|
||
degraded_count 降级数量
|
||
dead_count 已失效数量
|
||
|
||
真正决定是否补充的是 available_count,而不是 Redis 或内存中一共有多少条记录。
|
||
|
||
可用代理应满足:
|
||
|
||
status == HEALTHY
|
||
并且 expires_at - now > safety_margin
|
||
并且 active_connections < max_connections_per_ip
|
||
并且没有进入 cooldown
|
||
补池计算
|
||
|
||
建议不要简单写成:
|
||
|
||
available < minSize 时获取 batchSize 个
|
||
|
||
应该预测即将过期的代理:
|
||
|
||
effectiveAvailable =
|
||
availableCount
|
||
- expiringSoonCount
|
||
- reservedDemand
|
||
|
||
需要补充的数量:
|
||
|
||
need = targetSize - effectiveAvailable
|
||
|
||
然后限制在允许范围内:
|
||
|
||
func calculateFetchCount(
|
||
total int,
|
||
available int,
|
||
expiringSoon int,
|
||
minSize int,
|
||
targetSize int,
|
||
maxSize int,
|
||
batchSize int,
|
||
) int {
|
||
effective := available - expiringSoon
|
||
|
||
if effective >= minSize {
|
||
return 0
|
||
}
|
||
|
||
need := targetSize - effective
|
||
room := maxSize - total
|
||
|
||
if need > room {
|
||
need = room
|
||
}
|
||
if need > batchSize {
|
||
need = batchSize
|
||
}
|
||
if need < 0 {
|
||
return 0
|
||
}
|
||
|
||
return need
|
||
}
|
||
|
||
例如:
|
||
|
||
minSize = 1000
|
||
targetSize = 2000
|
||
maxSize = 3000
|
||
|
||
当前缓存总数 = 1800
|
||
当前可用数 = 1100
|
||
未来 30 秒过期 = 300
|
||
|
||
则:
|
||
|
||
effectiveAvailable = 1100 - 300 = 800
|
||
need = 2000 - 800 = 1200
|
||
maxSize 剩余空间 = 3000 - 1800 = 1200
|
||
|
||
最终需要补充 1200 个,可以按每批 200 个分 6 次获取。
|
||
|
||
时效性处理
|
||
|
||
代理商可能用三种方式表达有效期:
|
||
|
||
1. 返回绝对过期时间
|
||
{
|
||
"ip": "1.2.3.4",
|
||
"port": 8000,
|
||
"expire_time": "2026-07-28 16:30:00"
|
||
}
|
||
|
||
直接转换为:
|
||
|
||
expires_at = 绝对时间
|
||
2. 返回 TTL
|
||
{
|
||
"ip": "1.2.3.4",
|
||
"ttl": 120
|
||
}
|
||
|
||
保存:
|
||
|
||
expires_at = received_at + ttl
|
||
3. 只声明区间
|
||
|
||
例如截图里的:
|
||
|
||
有效 1~2 分钟
|
||
有效 5~10 分钟
|
||
|
||
这种不能按最大值算,应该保守处理。例如 1~2 分钟:
|
||
|
||
estimated_ttl = 60 秒
|
||
safety_margin = 15 秒
|
||
|
||
也就是收到约 45 秒后就停止给新请求分配,但已经建立的连接不强制立即断开。
|
||
|
||
代理生命周期
|
||
FETCHED
|
||
│
|
||
▼
|
||
CHECKING
|
||
│
|
||
├── 检测失败 ──► DEAD
|
||
│
|
||
▼
|
||
HEALTHY
|
||
│
|
||
├── 即将过期 ──► DRAINING
|
||
│
|
||
├── 连续失败 ──► COOLDOWN
|
||
│
|
||
└── 已过期 ──► EXPIRED
|
||
▼
|
||
DRAINING
|
||
│
|
||
└── 无活动连接 ──► EXPIRED
|
||
|
||
DRAINING 很重要:
|
||
|
||
不再分配给新请求
|
||
已经建立的连接允许继续使用
|
||
固定会话需要提前迁移或等待结束
|
||
活跃连接归零后删除
|
||
定时补充 + 事件触发补充
|
||
|
||
不要只依赖定时任务,建议两种机制一起用。
|
||
|
||
定时检查
|
||
|
||
例如每秒检查一次池容量:
|
||
|
||
ticker := time.NewTicker(time.Second)
|
||
|
||
检查:
|
||
|
||
当前可用数
|
||
未来一段时间将过期数
|
||
正在提取数
|
||
代理商 API 限额
|
||
当前缓存总数
|
||
事件触发
|
||
|
||
以下情况可以立即触发补池:
|
||
|
||
代理批量过期
|
||
健康检查批量失败
|
||
流量突然升高
|
||
固定会话大量创建
|
||
availableCount 低于 emergencySize
|
||
|
||
但多个 Worker 不能同时直接去调用供应商 API。提取操作必须集中在控制面,并加单飞或分布式锁:
|
||
|
||
provider_id -> 一个时刻只能有一个 fetch 任务
|
||
防止重复提取和超出 maxSize
|
||
|
||
提取是异步的,所以必须统计“正在获取但尚未入池”的数量:
|
||
|
||
projectedSize = totalCount + fetchingCount
|
||
|
||
判断空间:
|
||
|
||
room = maxSize - projectedSize
|
||
|
||
否则会出现:
|
||
|
||
当前 2800
|
||
Worker A 判断还能拿 200
|
||
Worker B 判断还能拿 200
|
||
最终变成 3200
|
||
|
||
建议控制面维护:
|
||
|
||
type ProviderRuntime struct {
|
||
TotalCount int64
|
||
AvailableCount int64
|
||
FetchingCount int64
|
||
ExpiringCount int64
|
||
LastFetchAt time.Time
|
||
ConsecutiveError int
|
||
}
|
||
|
||
使用数据库锁、Redis 锁或单控制器 Leader 保证获取操作串行化。
|
||
|
||
高并发时不要按 IP 数量盲目配置
|
||
|
||
minSize/maxSize 最好还要结合每个代理可承载的并发量。
|
||
|
||
例如:
|
||
|
||
目标总并发:10000
|
||
每个 IP 最大并发:20
|
||
安全系数:1.5
|
||
|
||
理论最少代理数量:
|
||
|
||
10000 ÷ 20 × 1.5 = 750
|
||
|
||
所以可以设置:
|
||
|
||
minSize = 800
|
||
targetSize = 1200
|
||
maxSize = 2000
|
||
|
||
但代理商可能限制单 IP QPS、带宽或目标站请求频率,因此真正容量应按下面三项中的最小值计算:
|
||
|
||
连接容量
|
||
QPS 容量
|
||
带宽容量
|
||
|
||
例如某个代理允许:
|
||
|
||
最大并发:20
|
||
最大 QPS:5
|
||
最大带宽:2 MB/s
|
||
|
||
即使连接没有达到 20,也可能先达到 QPS 或带宽限制。
|
||
|
||
多代理商独立池
|
||
|
||
建议每家供应商各自维护池,不要一开始就混成一张无差别列表:
|
||
|
||
Provider A Pool
|
||
Provider B Pool
|
||
Provider C Pool
|
||
|
||
每个池都有自己的:
|
||
|
||
minSize
|
||
targetSize
|
||
maxSize
|
||
TTL
|
||
获取频率
|
||
获取批次
|
||
单 IP 并发
|
||
余额
|
||
错误率
|
||
优先级
|
||
成本
|
||
|
||
在其上再做一个聚合调度层:
|
||
|
||
routing:
|
||
strategy: weighted_score
|
||
|
||
providers:
|
||
provider_a:
|
||
weight: 50
|
||
priority: 1
|
||
provider_b:
|
||
weight: 30
|
||
priority: 2
|
||
provider_c:
|
||
weight: 20
|
||
priority: 3
|
||
|
||
调度时可以考虑:
|
||
|
||
可用率
|
||
延迟
|
||
剩余有效时间
|
||
当前负载
|
||
供应商成本
|
||
供应商余额
|
||
目标站成功率
|
||
建议增加动态水位
|
||
|
||
固定 minSize 能工作,但遇到流量波动会比较迟钝。可以根据最近几分钟的使用速率动态计算:
|
||
|
||
required =
|
||
当前活跃代理需求
|
||
+ 未来 refillWindow 内预计消耗量
|
||
+ 安全冗余
|
||
|
||
例如:
|
||
|
||
最近每秒消耗 10 个短效代理
|
||
代理商获取并验证需要 15 秒
|
||
安全冗余 300 个
|
||
|
||
动态 minSize = 10 × 15 + 300 = 450
|
||
|
||
最终水位:
|
||
|
||
effectiveMinSize = max(configuredMinSize, dynamicMinSize)
|
||
|
||
这样流量增加时会提前补充,流量下降时又不会一直购买过多 IP。
|
||
|
||
推荐的配置模型
|
||
type ProviderConfig struct {
|
||
ID string
|
||
Enabled bool
|
||
|
||
MinSize int
|
||
TargetSize int
|
||
MaxSize int
|
||
|
||
BatchSize int
|
||
FetchInterval time.Duration
|
||
FetchTimeout time.Duration
|
||
MaxFetchRetries int
|
||
FetchQPS float64
|
||
|
||
DefaultTTL time.Duration
|
||
SafetyMargin time.Duration
|
||
ExpiringWindow time.Duration
|
||
|
||
MaxConnectionsPerIP int
|
||
MaxQPSPerIP int
|
||
|
||
CheckInterval time.Duration
|
||
CheckTimeout time.Duration
|
||
MaxFailures int
|
||
}
|
||
|
||
核心补池逻辑可以概括为:
|
||
|
||
func reconcileProvider(ctx context.Context, p *ProviderRuntime) {
|
||
p.RemoveExpired()
|
||
p.MarkExpiringAsDraining()
|
||
p.ScheduleHealthChecks()
|
||
|
||
effectiveAvailable :=
|
||
p.AvailableCount() -
|
||
p.ExpiringWithin(p.Config.ExpiringWindow)
|
||
|
||
if effectiveAvailable >= p.Config.MinSize {
|
||
return
|
||
}
|
||
|
||
projectedTotal := p.TotalCount() + p.FetchingCount()
|
||
room := p.Config.MaxSize - projectedTotal
|
||
need := p.Config.TargetSize - effectiveAvailable
|
||
|
||
count := min(need, room, p.Config.BatchSize)
|
||
if count <= 0 {
|
||
return
|
||
}
|
||
|
||
p.FetchAsync(ctx, count)
|
||
}
|
||
|
||
所以答案是:完全可以配置 minSize/maxSize,但最好再加 targetSize、fetchingCount、expiringCount 和安全过期窗口。 单纯使用最大、最小两个值,在短效 IP 大批量同时过期时很容易补池不及时。
|
||
|
||
|
||
对,这里你说得对。batch_size 不应该被当作“系统要求上游一次返回多少个”,而应该拆成两类概念:
|
||
|
||
fetch:
|
||
request_count_supported: false # 上游是否支持指定提取数量
|
||
upstream_batch_size: 200 # 上游每次实际固定返回数量
|
||
max_fetch_calls_per_cycle: 5 # 一轮最多调用几次
|
||
min_interval: 2s
|
||
|
||
如果上游接口每次固定返回 200 个,那系统只能决定:
|
||
|
||
调用几次
|
||
什么时候调用
|
||
返回后保留多少
|
||
超出 maxSize 怎么处理
|
||
|
||
而不能决定单次提取数量。
|
||
|
||
正确的补池计算
|
||
|
||
假设:
|
||
|
||
minSize = 1000
|
||
targetSize = 1500
|
||
maxSize = 2000
|
||
上游每次固定返回 200 个
|
||
当前有效可用 = 850
|
||
当前缓存总量 = 1200
|
||
|
||
希望补到 1500:
|
||
|
||
need = 1500 - 850 = 650
|
||
|
||
因为每次固定返回 200:
|
||
|
||
调用次数 = ceil(650 / 200) = 4 次
|
||
预计获取 = 800 个
|
||
|
||
但是还要检查 maxSize:
|
||
|
||
剩余容量 = 2000 - 1200 = 800
|
||
|
||
所以刚好可以调用 4 次。
|
||
|
||
计算逻辑:
|
||
|
||
need := targetSize - effectiveAvailable
|
||
room := maxSize - totalCount - fetchingCount
|
||
|
||
maxReceivable := min(need, room)
|
||
|
||
calls := int(math.Ceil(
|
||
float64(maxReceivable) / float64(upstreamBatchSize),
|
||
))
|
||
|
||
不过这里有个坑:上游按固定批次返回时,不能只检查 need,必须检查整个批次能否放下。
|
||
|
||
例如:
|
||
|
||
剩余空间 room = 150
|
||
上游固定返回 = 200
|
||
|
||
此时不能再调用,否则会超过 maxSize。
|
||
|
||
if room < upstreamBatchSize {
|
||
return
|
||
}
|
||
|
||
完整一点:
|
||
|
||
func calculateFetchCalls(
|
||
effectiveAvailable int,
|
||
totalCount int,
|
||
fetchingCount int,
|
||
minSize int,
|
||
targetSize int,
|
||
maxSize int,
|
||
upstreamBatchSize int,
|
||
maxCallsPerCycle int,
|
||
) int {
|
||
if effectiveAvailable >= minSize {
|
||
return 0
|
||
}
|
||
|
||
room := maxSize - totalCount - fetchingCount
|
||
if room < upstreamBatchSize {
|
||
return 0
|
||
}
|
||
|
||
need := targetSize - effectiveAvailable
|
||
if need <= 0 {
|
||
return 0
|
||
}
|
||
|
||
callsByNeed := int(math.Ceil(
|
||
float64(need) / float64(upstreamBatchSize),
|
||
))
|
||
|
||
callsByRoom := room / upstreamBatchSize
|
||
|
||
calls := min(callsByNeed, callsByRoom, maxCallsPerCycle)
|
||
return max(calls, 0)
|
||
}
|
||
更合适的配置方式
|
||
|
||
每家供应商单独描述它的提取能力:
|
||
|
||
providers:
|
||
provider_a:
|
||
pool:
|
||
min_size: 1000
|
||
target_size: 1500
|
||
max_size: 2000
|
||
|
||
fetch:
|
||
mode: fixed_batch
|
||
fixed_batch_size: 200
|
||
max_calls_per_cycle: 5
|
||
min_call_interval: 2s
|
||
max_calls_per_minute: 20
|
||
|
||
支持指定数量的供应商:
|
||
|
||
fetch:
|
||
mode: variable_batch
|
||
min_request_size: 1
|
||
max_request_size: 500
|
||
preferred_request_size: 200
|
||
|
||
一次只返回一个的供应商:
|
||
|
||
fetch:
|
||
mode: single
|
||
fixed_batch_size: 1
|
||
max_calls_per_cycle: 100
|
||
concurrency: 10
|
||
建议定义统一能力模型
|
||
type FetchMode string
|
||
|
||
const (
|
||
FetchModeFixedBatch FetchMode = "fixed_batch"
|
||
FetchModeVariableBatch FetchMode = "variable_batch"
|
||
FetchModeSingle FetchMode = "single"
|
||
)
|
||
|
||
type FetchCapability struct {
|
||
Mode FetchMode
|
||
|
||
FixedBatchSize int
|
||
|
||
MinRequestSize int
|
||
MaxRequestSize int
|
||
|
||
MaxCallsPerCycle int
|
||
MaxCallsPerMinute int
|
||
MinCallInterval time.Duration
|
||
}
|
||
|
||
调用接口也不要强制每家供应商都接收 count:
|
||
|
||
type Provider interface {
|
||
Capability() FetchCapability
|
||
Fetch(ctx context.Context, req FetchRequest) ([]Proxy, error)
|
||
}
|
||
type FetchRequest struct {
|
||
RequestedCount int
|
||
}
|
||
|
||
对于固定批次供应商,RequestedCount 可以忽略:
|
||
|
||
func (p *ProviderA) Fetch(
|
||
ctx context.Context,
|
||
req FetchRequest,
|
||
) ([]Proxy, error) {
|
||
// 上游固定返回 200 个,不使用 req.RequestedCount
|
||
return p.callUpstream(ctx)
|
||
}
|
||
超出 maxSize 怎么办
|
||
|
||
实际接口偶尔可能:
|
||
|
||
固定说返回 200,实际返回 203
|
||
返回重复 IP
|
||
一次调用返回数量不稳定
|
||
请求重试导致重复返回
|
||
|
||
因此入池时还要做二次限制:
|
||
|
||
remaining := maxSize - currentTotal
|
||
|
||
for _, proxy := range fetched {
|
||
if remaining <= 0 {
|
||
break
|
||
}
|
||
|
||
if pool.Exists(proxy.UniqueKey()) {
|
||
continue
|
||
}
|
||
|
||
pool.Add(proxy)
|
||
remaining--
|
||
}
|
||
|
||
但如果这些 IP 是按次收费,拿到后丢掉就浪费了。所以更好的做法是预留一个“软上限”和“硬上限”:
|
||
|
||
pool:
|
||
min_size: 1000
|
||
target_size: 1500
|
||
soft_max_size: 2000
|
||
hard_max_size: 2200
|
||
|
||
含义:
|
||
|
||
softMaxSize:正常情况下不再主动提取
|
||
hardMaxSize:允许固定批次造成的小幅溢出
|
||
|
||
例如上游固定每次 200 个:
|
||
|
||
softMaxSize = 2000
|
||
hardMaxSize = 2200
|
||
|
||
当前 1900 个时,若确实低于最低可用水位,可以再提取一批,最终达到 2100,而不是把多出的 100 个直接丢掉。
|
||
|
||
所以这里建议把原来的:
|
||
|
||
batch_size: 200
|
||
|
||
改成:
|
||
|
||
fixed_batch_size: 200
|
||
|
||
并明确它表示:
|
||
|
||
上游单次固定返回量,而不是本系统要求的提取数量。
|
||
|
||
|
||
对,这两个必须进入核心调度模型,不能当普通配置项:
|
||
|
||
单个代理 IP 的并发容量限制
|
||
上游代理商提取 API 的调用频率限制
|
||
|
||
否则会出现“池里明明有很多 IP,但全部已满载”以及“多个控制器同时补池,把上游 API 打到限流”的情况。
|
||
|
||
一、单 IP 并发容量
|
||
|
||
每个代理记录容量和实时负载:
|
||
|
||
type ProxyNode struct {
|
||
ID string
|
||
Provider string
|
||
|
||
Host string
|
||
Port int
|
||
ExpireAt time.Time
|
||
Status ProxyStatus
|
||
|
||
MaxConcurrency int64
|
||
ActiveConcurrency atomic.Int64
|
||
|
||
ReservedConcurrency atomic.Int64
|
||
}
|
||
|
||
是否可分配不能只判断健康状态:
|
||
|
||
func (p *ProxyNode) Allocatable(now time.Time, safetyMargin time.Duration) bool {
|
||
return p.Status == StatusHealthy &&
|
||
p.ExpireAt.Sub(now) > safetyMargin &&
|
||
p.ActiveConcurrency.Load()+p.ReservedConcurrency.Load() <
|
||
p.MaxConcurrency
|
||
}
|
||
|
||
剩余容量为:
|
||
|
||
remainingCapacity =
|
||
maxConcurrency
|
||
- activeConcurrency
|
||
- reservedConcurrency
|
||
|
||
例如:
|
||
|
||
代理 IP 数量:1000
|
||
每个 IP 最大并发:10
|
||
理论总容量:10000
|
||
|
||
但若其中:
|
||
|
||
200 个即将过期
|
||
100 个健康检查失败
|
||
剩余 700 个
|
||
|
||
实际总容量只有:
|
||
|
||
700 × 10 = 7000 并发
|
||
|
||
所以补池触发条件不能只看:
|
||
|
||
availableIPCount < minSize
|
||
|
||
还要看:
|
||
|
||
availableConcurrency < minConcurrency
|
||
二、池水位改成双水位
|
||
|
||
每个供应商同时配置 IP 数量水位和并发容量水位:
|
||
|
||
providers:
|
||
provider_a:
|
||
pool:
|
||
min_ip_size: 1000
|
||
target_ip_size: 1500
|
||
soft_max_ip_size: 2000
|
||
hard_max_ip_size: 2200
|
||
|
||
min_concurrency: 8000
|
||
target_concurrency: 12000
|
||
|
||
proxy:
|
||
default_max_concurrency: 10
|
||
allocation_safety_ratio: 0.8
|
||
|
||
allocation_safety_ratio: 0.8 表示上游声称单 IP 支持 10 并发,内部只按 8 并发分配。
|
||
|
||
这样可以给延迟回收、统计误差和突发流量留余地:
|
||
|
||
effectiveMaxConcurrency =
|
||
upstreamMaxConcurrency × safetyRatio
|
||
|
||
例如:
|
||
|
||
上游限制 20
|
||
安全系数 0.8
|
||
内部最大并发 16
|
||
三、补池条件
|
||
|
||
建议满足任意一个条件就补池:
|
||
|
||
有效 IP 数量 < minIpSize
|
||
|
||
或者
|
||
|
||
可用并发容量 < minConcurrency
|
||
|
||
计算:
|
||
|
||
type PoolSnapshot struct {
|
||
TotalIPs int
|
||
AvailableIPs int
|
||
ExpiringIPs int
|
||
FetchingExpectedIPs int
|
||
|
||
TotalConcurrency int64
|
||
AvailableConcurrency int64
|
||
}
|
||
func shouldRefill(
|
||
snapshot PoolSnapshot,
|
||
cfg ProviderPoolConfig,
|
||
) bool {
|
||
return snapshot.AvailableIPs < cfg.MinIPSize ||
|
||
snapshot.AvailableConcurrency < cfg.MinConcurrency
|
||
}
|
||
|
||
可用并发容量应该逐个代理计算:
|
||
|
||
func availableConcurrency(proxies []*ProxyNode) int64 {
|
||
var total int64
|
||
|
||
for _, proxy := range proxies {
|
||
if !proxy.Allocatable(time.Now(), 20*time.Second) {
|
||
continue
|
||
}
|
||
|
||
remaining :=
|
||
proxy.MaxConcurrency -
|
||
proxy.ActiveConcurrency.Load() -
|
||
proxy.ReservedConcurrency.Load()
|
||
|
||
if remaining > 0 {
|
||
total += remaining
|
||
}
|
||
}
|
||
|
||
return total
|
||
}
|
||
四、并发槽位必须原子预占
|
||
|
||
不能先读:
|
||
|
||
active = 9
|
||
max = 10
|
||
|
||
然后多个请求同时认为还有一个位置。需要使用原子 CAS 预占:
|
||
|
||
func (p *ProxyNode) TryAcquire() bool {
|
||
for {
|
||
current := p.ActiveConcurrency.Load()
|
||
|
||
if current >= p.MaxConcurrency {
|
||
return false
|
||
}
|
||
|
||
if p.ActiveConcurrency.CompareAndSwap(current, current+1) {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
|
||
func (p *ProxyNode) Release() {
|
||
value := p.ActiveConcurrency.Add(-1)
|
||
|
||
if value < 0 {
|
||
p.ActiveConcurrency.Store(0)
|
||
// 记录严重告警
|
||
}
|
||
}
|
||
|
||
请求流程:
|
||
|
||
proxy := selector.Select()
|
||
|
||
if !proxy.TryAcquire() {
|
||
// 代理刚刚被其他请求占满,重新选择
|
||
continue
|
||
}
|
||
|
||
conn, err := connect(proxy)
|
||
|
||
if err != nil {
|
||
proxy.Release()
|
||
markFailure(proxy)
|
||
continue
|
||
}
|
||
|
||
defer proxy.Release()
|
||
|
||
“选择代理”和“占用槽位”不能完全分开,否则高并发下会产生超卖。
|
||
|
||
五、多个 Proxy Worker 的全局并发问题
|
||
|
||
如果同一个 IP 能同时被多个 Worker 使用,仅使用进程内 atomic 不够。
|
||
|
||
例如:
|
||
|
||
IP 最大并发 10
|
||
|
||
Worker A 认为用了 7
|
||
Worker B 认为用了 6
|
||
|
||
真实并发变成 13
|
||
|
||
有三种方案。
|
||
|
||
方案 A:IP 归属固定 Worker
|
||
proxyID 哈希到指定 Worker
|
||
owner := hash(proxy.ID) % workerCount
|
||
|
||
一个代理 IP 只由一个 Worker 使用。
|
||
|
||
优点:
|
||
|
||
不需要每请求访问 Redis
|
||
原子计数只在本机
|
||
性能最好
|
||
最适合一万以上并发
|
||
|
||
缺点:
|
||
|
||
Worker 下线后需要重新分配
|
||
各 Worker 负载可能不完全均衡
|
||
|
||
这是我最推荐的方案。
|
||
|
||
方案 B:按容量切片分配
|
||
|
||
控制面把同一个 IP 的容量拆给不同 Worker:
|
||
|
||
IP 最大并发:20
|
||
|
||
Worker A:8 个槽位
|
||
Worker B:6 个槽位
|
||
Worker C:6 个槽位
|
||
|
||
每个 Worker 只管理自己的本地额度:
|
||
|
||
type ProxyLease struct {
|
||
ProxyID string
|
||
WorkerID string
|
||
CapacityQuota int
|
||
LeaseExpireAt time.Time
|
||
}
|
||
|
||
控制面定期调整配额,Worker 宕机后租约自动过期。
|
||
|
||
这个方案性能和均衡性都不错,但实现比固定归属复杂。
|
||
|
||
方案 C:每次请求操作 Redis 计数
|
||
INCR proxy:{id}:active
|
||
|
||
超过上限则回滚。
|
||
|
||
不建议热路径使用。每秒一万次以上获取和释放会产生大量 Redis 操作,还会把网络延迟放进代理转发路径。
|
||
|
||
六、提取 API 每秒最多 5 次
|
||
|
||
这个限制必须在整个集群范围内生效,而不是每个控制器各自 5 次。
|
||
|
||
配置:
|
||
|
||
providers:
|
||
provider_a:
|
||
fetch:
|
||
rate_limit:
|
||
requests_per_second: 5
|
||
burst: 5
|
||
|
||
max_in_flight: 2
|
||
request_timeout: 5s
|
||
retry:
|
||
max_attempts: 3
|
||
base_delay: 500ms
|
||
max_delay: 10s
|
||
|
||
这里有两个不同限制:
|
||
|
||
requests_per_second:单位时间内最多发起多少次
|
||
max_in_flight:同一时刻最多有多少个请求尚未完成
|
||
|
||
即使每秒允许请求 5 次,也不代表允许积压 100 个未完成请求。
|
||
|
||
七、使用令牌桶限流
|
||
|
||
Go 可以使用 golang.org/x/time/rate:
|
||
|
||
type ProviderFetcher struct {
|
||
limiter *rate.Limiter
|
||
semaphore chan struct{}
|
||
provider Provider
|
||
}
|
||
|
||
func NewProviderFetcher(provider Provider) *ProviderFetcher {
|
||
return &ProviderFetcher{
|
||
// 每秒 5 个令牌,最多积攒 5 个
|
||
limiter: rate.NewLimiter(rate.Limit(5), 5),
|
||
|
||
// 同时最多两个提取请求
|
||
semaphore: make(chan struct{}, 2),
|
||
|
||
provider: provider,
|
||
}
|
||
}
|
||
|
||
调用上游前:
|
||
|
||
func (f *ProviderFetcher) Fetch(ctx context.Context) ([]Proxy, error) {
|
||
if err := f.limiter.Wait(ctx); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
select {
|
||
case f.semaphore <- struct{}{}:
|
||
defer func() { <-f.semaphore }()
|
||
case <-ctx.Done():
|
||
return nil, ctx.Err()
|
||
}
|
||
|
||
return f.provider.Fetch(ctx)
|
||
}
|
||
|
||
但它只限制单进程。为了保证全局每秒最多 5 次,最好让每个供应商只有一个负责提取的逻辑实例。
|
||
|
||
八、推荐单 Provider 单 Fetch Leader
|
||
|
||
不要让所有 Worker 自己补池:
|
||
|
||
错误结构:
|
||
|
||
Worker 1 ──调用供应商
|
||
Worker 2 ──调用供应商
|
||
Worker 3 ──调用供应商
|
||
|
||
正确结构:
|
||
|
||
所有 Proxy Worker
|
||
│
|
||
▼
|
||
报告容量和需求
|
||
│
|
||
▼
|
||
Provider Controller Leader
|
||
│
|
||
├─ 全局限流器
|
||
├─ 补池计划
|
||
├─ 请求去重
|
||
└─ 调用供应商 API
|
||
|
||
每个代理商一个逻辑调度器:
|
||
|
||
type ProviderController struct {
|
||
Config ProviderConfig
|
||
Fetcher *ProviderFetcher
|
||
Pool *ProviderPool
|
||
|
||
reconcileSignal chan struct{}
|
||
}
|
||
|
||
即使控制面部署多实例,也通过 Leader Election 保证同一供应商只有一个实例执行 Fetch。
|
||
|
||
可选方式:
|
||
|
||
Kubernetes Lease
|
||
etcd 租约
|
||
PostgreSQL advisory lock
|
||
Redis 带租约锁
|
||
九、不能因为缺口大就瞬间请求 5 次
|
||
|
||
上游限制是每秒 5 次,但最好不要总是在一秒开始时瞬间打满。
|
||
|
||
例如缺 1000 个 IP、上游每次返回 100 个,需要请求 10 次:
|
||
|
||
错误:
|
||
00ms 连续请求 5 次
|
||
1000ms 再连续请求 5 次
|
||
|
||
更平滑:
|
||
|
||
0ms
|
||
200ms
|
||
400ms
|
||
600ms
|
||
800ms
|
||
1000ms
|
||
...
|
||
|
||
也就是:
|
||
|
||
5 次/秒 = 平均每 200ms 一次
|
||
|
||
令牌桶可以允许突发。如果上游严格采用滑动窗口,应配置:
|
||
|
||
requests_per_second: 5
|
||
burst: 1
|
||
|
||
对应:
|
||
|
||
rate.NewLimiter(rate.Limit(5), 1)
|
||
|
||
这样基本会平滑成每 200ms 一次。
|
||
|
||
十、429 或限流错误的处理
|
||
|
||
上游报错后不能立即疯狂重试:
|
||
|
||
func retryDelay(attempt int) time.Duration {
|
||
base := 500 * time.Millisecond
|
||
maxDelay := 30 * time.Second
|
||
|
||
delay := base * time.Duration(1<<attempt)
|
||
if delay > maxDelay {
|
||
delay = maxDelay
|
||
}
|
||
|
||
jitter := time.Duration(rand.Int63n(
|
||
int64(delay / 4),
|
||
))
|
||
|
||
return delay + jitter
|
||
}
|
||
|
||
例如:
|
||
|
||
第 1 次:约 500ms
|
||
第 2 次:约 1s
|
||
第 3 次:约 2s
|
||
第 4 次:约 4s
|
||
|
||
对于上游 429:
|
||
|
||
读取 Retry-After
|
||
暂停该供应商 Fetch
|
||
不计为代理 IP 本身失败
|
||
提高补池紧急度,但不能绕过限流器
|
||
必要时从其他供应商补容量
|
||
十一、补池计划要考虑获取速度
|
||
|
||
假设:
|
||
|
||
上游每次返回 20 个 IP
|
||
最多 5 次/秒
|
||
|
||
最大获取速度:
|
||
|
||
20 × 5 = 100 个 IP/秒
|
||
|
||
每个 IP 可用并发为 10,则理论容量补充速度:
|
||
|
||
100 × 10 = 1000 并发槽位/秒
|
||
|
||
如果当前容量缺口是 5000:
|
||
|
||
最快需要约 5 秒
|
||
|
||
还没有算:
|
||
|
||
API 响应时间
|
||
IP 健康检查
|
||
重复 IP
|
||
无效 IP
|
||
提前过期 IP
|
||
|
||
因此不能等容量跌破最低值才开始获取,必须预测获取提前量。
|
||
|
||
十二、动态补池触发线
|
||
|
||
假设:
|
||
|
||
当前可用并发容量:12000
|
||
当前并发:9000
|
||
最近增长速度:每秒增加 500
|
||
获取并验证新容量需要:5 秒
|
||
安全冗余:2000
|
||
|
||
未来 5 秒需求:
|
||
|
||
500 × 5 = 2500
|
||
|
||
触发补池所需容量:
|
||
|
||
预计需求 + 安全冗余
|
||
= 2500 + 2000
|
||
= 4500
|
||
|
||
当前剩余:
|
||
|
||
12000 - 9000 = 3000
|
||
|
||
由于:
|
||
|
||
3000 < 4500
|
||
|
||
应立即开始补池,即使 IP 数量还没有低于 minSize。
|
||
|
||
可以使用:
|
||
|
||
requiredReserve =
|
||
concurrencyGrowthRate × refillLeadTime
|
||
+ safetyConcurrency
|
||
|
||
补池条件:
|
||
|
||
availableConcurrency - activeConcurrency
|
||
< requiredReserve
|
||
十三、完整配置示例
|
||
providers:
|
||
provider_a:
|
||
enabled: true
|
||
|
||
pool:
|
||
min_ip_size: 1000
|
||
target_ip_size: 1500
|
||
soft_max_ip_size: 2000
|
||
hard_max_ip_size: 2200
|
||
|
||
min_available_concurrency: 8000
|
||
target_available_concurrency: 12000
|
||
safety_concurrency: 2000
|
||
|
||
proxy_capacity:
|
||
default_max_concurrency: 10
|
||
safety_ratio: 0.8
|
||
|
||
lifetime:
|
||
default_ttl: 120s
|
||
allocation_safety_margin: 20s
|
||
expiring_window: 30s
|
||
|
||
fetch:
|
||
mode: fixed_response
|
||
|
||
estimated_ips_per_call: 100
|
||
|
||
rate_limit:
|
||
requests_per_second: 5
|
||
burst: 1
|
||
|
||
max_in_flight: 2
|
||
max_calls_per_reconcile: 10
|
||
|
||
timeout: 5s
|
||
cooldown_after_429: 10s
|
||
|
||
retry:
|
||
max_attempts: 3
|
||
base_delay: 500ms
|
||
max_delay: 30s
|
||
|
||
health_check:
|
||
timeout: 3s
|
||
concurrency: 100
|
||
max_failures: 3
|
||
|
||
注意这里用了:
|
||
|
||
estimated_ips_per_call: 100
|
||
|
||
因为上游不一定保证每次准确返回多少个。实际补池数量必须根据历史平均值动态估算:
|
||
|
||
最近 100 次调用平均返回 93 个
|
||
重复率 5%
|
||
健康检查通过率 90%
|
||
|
||
每次有效新增约:
|
||
93 × 0.95 × 0.90 ≈ 79 个
|
||
|
||
调度器应该按约 79 个有效 IP/次计算,而不是按接口宣称的 100 个。
|
||
|
||
十四、最终补池计算
|
||
type RefillPlan struct {
|
||
Calls int
|
||
ExpectedIPs int
|
||
}
|
||
|
||
func calculateRefillPlan(
|
||
capacityDeficit int64,
|
||
avgConcurrencyPerIP float64,
|
||
effectiveIPsPerCall float64,
|
||
maxCalls int,
|
||
) RefillPlan {
|
||
if capacityDeficit <= 0 ||
|
||
avgConcurrencyPerIP <= 0 ||
|
||
effectiveIPsPerCall <= 0 {
|
||
return RefillPlan{}
|
||
}
|
||
|
||
neededIPs := int(math.Ceil(
|
||
float64(capacityDeficit) / avgConcurrencyPerIP,
|
||
))
|
||
|
||
calls := int(math.Ceil(
|
||
float64(neededIPs) / effectiveIPsPerCall,
|
||
))
|
||
|
||
if calls > maxCalls {
|
||
calls = maxCalls
|
||
}
|
||
|
||
return RefillPlan{
|
||
Calls: calls,
|
||
ExpectedIPs: int(
|
||
float64(calls) * effectiveIPsPerCall,
|
||
),
|
||
}
|
||
}
|
||
|
||
不过 Calls 只是加入提取队列,真正执行仍必须经过:
|
||
|
||
全局限流器
|
||
→ 并发请求限制
|
||
→ 上游冷却状态
|
||
→ soft/hard maxSize 检查
|
||
→ Leader 权限检查
|
||
|
||
核心设计可以概括成一句话:
|
||
|
||
代理池管理的不是 IP 数量,而是“未过期、健康且尚未占用的并发槽位”;上游提取管理的也不是调用次数,而是“受全局速率限制的容量补充通道”。
|
||
|
||
|
||
对,所以限流一定要做成“每个上游代理商独立能力配置”,不能全局写死 5 次/秒。
|
||
|
||
建议把每个 Provider 的提取限制建模为以下几种模式:
|
||
|
||
UNLIMITED 上游声明不限制调用频率
|
||
RATE_LIMITED 按每秒/每分钟限制
|
||
INTERVAL 两次调用之间必须间隔固定时间
|
||
WINDOW_LIMITED 固定窗口或滑动窗口内最多 N 次
|
||
配置示例
|
||
上游 A:每秒最多 5 次
|
||
providers:
|
||
provider_a:
|
||
fetch:
|
||
rate_limit:
|
||
mode: rate_limited
|
||
requests: 5
|
||
period: 1s
|
||
burst: 1
|
||
|
||
max_in_flight: 2
|
||
timeout: 5s
|
||
上游 B:每秒最多 1 次
|
||
providers:
|
||
provider_b:
|
||
fetch:
|
||
rate_limit:
|
||
mode: rate_limited
|
||
requests: 1
|
||
period: 1s
|
||
burst: 1
|
||
|
||
max_in_flight: 1
|
||
timeout: 5s
|
||
上游 C:每分钟最多 20 次
|
||
providers:
|
||
provider_c:
|
||
fetch:
|
||
rate_limit:
|
||
mode: rate_limited
|
||
requests: 20
|
||
period: 1m
|
||
burst: 1
|
||
|
||
max_in_flight: 1
|
||
上游 D:最少间隔 3 秒
|
||
providers:
|
||
provider_d:
|
||
fetch:
|
||
rate_limit:
|
||
mode: interval
|
||
min_interval: 3s
|
||
|
||
max_in_flight: 1
|
||
上游 E:不限制
|
||
providers:
|
||
provider_e:
|
||
fetch:
|
||
rate_limit:
|
||
mode: unlimited
|
||
|
||
max_in_flight: 10
|
||
|
||
不过“无限”不能真的无限制发送。系统仍然要有自己的保护值:
|
||
|
||
providers:
|
||
provider_e:
|
||
fetch:
|
||
rate_limit:
|
||
mode: unlimited
|
||
|
||
max_in_flight: 10
|
||
internal_max_qps: 100
|
||
|
||
避免程序异常时进入死循环,把上游和自己都打挂。
|
||
|
||
Go 配置结构
|
||
type RateLimitMode string
|
||
|
||
const (
|
||
RateLimitUnlimited RateLimitMode = "unlimited"
|
||
RateLimitRequests RateLimitMode = "rate_limited"
|
||
RateLimitInterval RateLimitMode = "interval"
|
||
)
|
||
|
||
type FetchRateLimitConfig struct {
|
||
Mode RateLimitMode
|
||
|
||
// rate_limited 模式使用
|
||
Requests int
|
||
Period time.Duration
|
||
Burst int
|
||
|
||
// interval 模式使用
|
||
MinInterval time.Duration
|
||
|
||
// unlimited 模式下的内部保护上限
|
||
InternalMaxQPS float64
|
||
}
|
||
|
||
type ProviderFetchConfig struct {
|
||
RateLimit RateLimitConfig
|
||
|
||
MaxInFlight int
|
||
Timeout time.Duration
|
||
|
||
MaxQueueSize int
|
||
}
|
||
每个 Provider 都有独立限流器
|
||
|
||
不能共用一个全局 limiter:
|
||
|
||
type ProviderFetcher struct {
|
||
providerID string
|
||
|
||
limiter FetchLimiter
|
||
|
||
semaphore chan struct{}
|
||
queue chan FetchTask
|
||
}
|
||
|
||
控制面内部结构:
|
||
|
||
Provider A → 独立队列 → 5 次/秒限流器
|
||
Provider B → 独立队列 → 1 次/秒限流器
|
||
Provider C → 独立队列 → 20 次/分钟限流器
|
||
Provider D → 独立队列 → 3 秒最小间隔
|
||
Provider E → 独立队列 → 内部保护限制
|
||
|
||
这样 A 被限流不会阻塞 B,B 响应慢也不会影响 C。
|
||
|
||
统一限流接口
|
||
type FetchLimiter interface {
|
||
Wait(ctx context.Context) error
|
||
}
|
||
不限流实现
|
||
type UnlimitedLimiter struct{}
|
||
|
||
func (l *UnlimitedLimiter) Wait(ctx context.Context) error {
|
||
return ctx.Err()
|
||
}
|
||
|
||
这里要注意,ctx.Err() 在正常情况下是 nil,所以可以直接通过。
|
||
|
||
更清晰地写:
|
||
|
||
func (l *UnlimitedLimiter) Wait(ctx context.Context) error {
|
||
select {
|
||
case <-ctx.Done():
|
||
return ctx.Err()
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
令牌桶实现
|
||
type TokenBucketLimiter struct {
|
||
limiter *rate.Limiter
|
||
}
|
||
|
||
func NewTokenBucketLimiter(
|
||
requests int,
|
||
period time.Duration,
|
||
burst int,
|
||
) *TokenBucketLimiter {
|
||
tokensPerSecond :=
|
||
float64(requests) / period.Seconds()
|
||
|
||
return &TokenBucketLimiter{
|
||
limiter: rate.NewLimiter(
|
||
rate.Limit(tokensPerSecond),
|
||
burst,
|
||
),
|
||
}
|
||
}
|
||
|
||
func (l *TokenBucketLimiter) Wait(ctx context.Context) error {
|
||
return l.limiter.Wait(ctx)
|
||
}
|
||
|
||
例如:
|
||
|
||
5 次/秒:
|
||
requests = 5
|
||
period = 1s
|
||
|
||
1 次/秒:
|
||
requests = 1
|
||
period = 1s
|
||
|
||
20 次/分钟:
|
||
requests = 20
|
||
period = 1m
|
||
固定间隔实现
|
||
|
||
对于“每次调用必须至少间隔 3 秒”,用普通令牌桶不一定完全符合上游规则,可以单独实现:
|
||
|
||
type IntervalLimiter struct {
|
||
mu sync.Mutex
|
||
nextAllowed time.Time
|
||
interval time.Duration
|
||
}
|
||
|
||
func (l *IntervalLimiter) Wait(ctx context.Context) error {
|
||
l.mu.Lock()
|
||
|
||
now := time.Now()
|
||
wait := time.Duration(0)
|
||
|
||
if now.Before(l.nextAllowed) {
|
||
wait = l.nextAllowed.Sub(now)
|
||
}
|
||
|
||
reservedAt := now.Add(wait)
|
||
l.nextAllowed = reservedAt.Add(l.interval)
|
||
|
||
l.mu.Unlock()
|
||
|
||
timer := time.NewTimer(wait)
|
||
defer timer.Stop()
|
||
|
||
select {
|
||
case <-timer.C:
|
||
return nil
|
||
case <-ctx.Done():
|
||
return ctx.Err()
|
||
}
|
||
}
|
||
限流和并发限制是两回事
|
||
|
||
例如上游配置:
|
||
|
||
每秒最多请求 5 次
|
||
每次响应耗时 10 秒
|
||
|
||
如果只限制 QPS,最终可能同时堆积 50 个未完成请求。
|
||
|
||
所以还需要:
|
||
|
||
rate_limit:
|
||
requests: 5
|
||
period: 1s
|
||
|
||
max_in_flight: 2
|
||
|
||
执行顺序建议是:
|
||
|
||
任务进入 Provider 队列
|
||
↓
|
||
等待并发槽位
|
||
↓
|
||
等待速率令牌
|
||
↓
|
||
调用上游 API
|
||
↓
|
||
释放并发槽位
|
||
|
||
示例:
|
||
|
||
func (f *ProviderFetcher) execute(
|
||
ctx context.Context,
|
||
) ([]Proxy, error) {
|
||
select {
|
||
case f.semaphore <- struct{}{}:
|
||
defer func() { <-f.semaphore }()
|
||
case <-ctx.Done():
|
||
return nil, ctx.Err()
|
||
}
|
||
|
||
if err := f.limiter.Wait(ctx); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
callCtx, cancel := context.WithTimeout(
|
||
ctx,
|
||
f.config.Timeout,
|
||
)
|
||
defer cancel()
|
||
|
||
return f.provider.Fetch(callCtx)
|
||
}
|
||
不要让补池任务无限排队
|
||
|
||
假设上游每秒只能调用一次,但容量计算不断产生补池任务:
|
||
|
||
第 1 秒:需要调用 10 次
|
||
第 2 秒:又添加 10 次
|
||
第 3 秒:又添加 10 次
|
||
|
||
很快就会积压几千个过时任务。
|
||
|
||
更合理的做法不是“每缺一批就添加一个任务”,而是维护每家上游的目标缺口:
|
||
|
||
type ProviderDemand struct {
|
||
DesiredEffectiveIPs int
|
||
CurrentEffectiveIPs int
|
||
PendingExpectedIPs int
|
||
}
|
||
|
||
调度器每次重新计算:
|
||
|
||
实时缺口 =
|
||
目标容量
|
||
- 当前有效容量
|
||
- 正在请求预计获得的容量
|
||
|
||
不要累积历史任务,只保留最新需求。
|
||
|
||
可以给每个 Provider 一个容量为 1 的通知通道:
|
||
|
||
reconcileSignal := make(chan struct{}, 1)
|
||
|
||
触发时:
|
||
|
||
select {
|
||
case reconcileSignal <- struct{}{}:
|
||
default:
|
||
// 已有待处理信号,不重复排队
|
||
}
|
||
|
||
Controller 醒来后读取实时池状态,再决定是否调用上游。
|
||
|
||
429 后动态降速
|
||
|
||
即使配置写的是每秒 5 次,上游实际规则可能发生变化,或者它使用严格滑动窗口。
|
||
|
||
收到限流错误后,应自动进入降速状态:
|
||
|
||
正常:5 次/秒
|
||
收到 429:降到 2.5 次/秒
|
||
再次 429:降到 1.25 次/秒
|
||
稳定一段时间后逐步恢复
|
||
|
||
可以维护:
|
||
|
||
type AdaptiveRateState struct {
|
||
ConfiguredRate float64
|
||
EffectiveRate float64
|
||
|
||
Consecutive429 int
|
||
CooldownUntil time.Time
|
||
}
|
||
|
||
但配置值仍是绝对上限:
|
||
|
||
effectiveRate <= configuredRate
|
||
|
||
恢复可以采用缓慢增加:
|
||
|
||
每 30 秒无 429:
|
||
effectiveRate += configuredRate × 10%
|
||
|
||
遇到 429:
|
||
|
||
effectiveRate *= 0.5
|
||
|
||
如果响应带有 Retry-After,优先遵守。
|
||
|
||
“无限制供应商”的处理
|
||
|
||
即使上游说不限制,建议仍配置:
|
||
|
||
rate_limit:
|
||
mode: unlimited
|
||
|
||
safety:
|
||
internal_max_qps: 100
|
||
max_in_flight: 10
|
||
max_calls_per_reconcile: 20
|
||
max_queue_wait: 30s
|
||
|
||
因为实际瓶颈可能是:
|
||
|
||
上游服务器连接数
|
||
本机文件描述符
|
||
DNS
|
||
出口端口
|
||
提取费用
|
||
上游突然增加隐藏限制
|
||
程序 Bug 造成死循环
|
||
|
||
所以 unlimited 应理解为:
|
||
|
||
没有供应商明确限制,但仍接受本系统保护限制。
|
||
|
||
最终配置模型
|
||
providers:
|
||
provider_a:
|
||
pool:
|
||
min_ip_size: 500
|
||
target_ip_size: 800
|
||
soft_max_ip_size: 1000
|
||
hard_max_ip_size: 1100
|
||
|
||
min_available_concurrency: 8000
|
||
target_available_concurrency: 12000
|
||
|
||
proxy_capacity:
|
||
default_max_concurrency: 16
|
||
|
||
fetch:
|
||
response_mode: fixed
|
||
estimated_ips_per_call: 100
|
||
|
||
limit:
|
||
mode: rate_limited
|
||
requests: 5
|
||
period: 1s
|
||
burst: 1
|
||
|
||
max_in_flight: 2
|
||
max_calls_per_reconcile: 10
|
||
timeout: 5s
|
||
|
||
provider_b:
|
||
pool:
|
||
min_ip_size: 200
|
||
target_ip_size: 400
|
||
soft_max_ip_size: 500
|
||
hard_max_ip_size: 550
|
||
|
||
proxy_capacity:
|
||
default_max_concurrency: 8
|
||
|
||
fetch:
|
||
response_mode: fixed
|
||
estimated_ips_per_call: 20
|
||
|
||
limit:
|
||
mode: rate_limited
|
||
requests: 1
|
||
period: 1s
|
||
burst: 1
|
||
|
||
max_in_flight: 1
|
||
max_calls_per_reconcile: 5
|
||
|
||
provider_c:
|
||
fetch:
|
||
limit:
|
||
mode: unlimited
|
||
|
||
safety:
|
||
internal_max_qps: 100
|
||
|
||
max_in_flight: 10
|
||
max_calls_per_reconcile: 20
|
||
|
||
还需要一个校验规则,防止错误配置:
|
||
|
||
func validate(cfg ProviderFetchConfig) error {
|
||
switch cfg.Limit.Mode {
|
||
case RateLimitRequests:
|
||
if cfg.Limit.Requests <= 0 {
|
||
return errors.New("requests must be greater than zero")
|
||
}
|
||
if cfg.Limit.Period <= 0 {
|
||
return errors.New("period must be greater than zero")
|
||
}
|
||
|
||
case RateLimitInterval:
|
||
if cfg.Limit.MinInterval <= 0 {
|
||
return errors.New("min_interval must be greater than zero")
|
||
}
|
||
|
||
case RateLimitUnlimited:
|
||
if cfg.Safety.InternalMaxQPS <= 0 {
|
||
return errors.New(
|
||
"unlimited provider requires internal_max_qps",
|
||
)
|
||
}
|
||
|
||
default:
|
||
return errors.New("unsupported rate limit mode")
|
||
}
|
||
|
||
if cfg.MaxInFlight <= 0 {
|
||
return errors.New("max_in_flight must be greater than zero")
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
因此最终原则就是:
|
||
|
||
每家上游都有独立的获取队列、独立限流器、独立并发限制和独立冷却状态;配置写的是该供应商能力,不是整个代理池的统一规则。
|
||
|
||
|
||
这种配置思路是可行的,而且比前面那种复杂模型更适合先做第一版。不过按你现在这份配置,能跑,但扛不住高并发代理池场景。主要缺几个关键能力:
|
||
|
||
只有 maxSize,没有 minSize
|
||
没有单 IP 最大并发
|
||
requestInterval 只能表达固定间隔,不能很好表达“每秒 5 次、允许突发 2 次”
|
||
没有区分正在提取、正在检测、可分配、即将过期
|
||
apiRetryNumber 和业务请求重试概念容易混淆
|
||
多个代理商写成 proxy: proxy1,proxy2,但没有明确选择策略
|
||
提取 API 返回多个 IP 时,模板可能把地址黏在一起
|
||
上游失败后直接直连,对部分业务可能有安全风险
|
||
你当前配置表达的含义
|
||
|
||
例如:
|
||
|
||
upstream:
|
||
proxy:
|
||
lifecycle: 20
|
||
maxSize: 1
|
||
requestInterval: 100ms
|
||
|
||
大致表示:
|
||
|
||
最多缓存 1 个代理 IP
|
||
代理使用 20 秒后主动失效
|
||
两次请求代理 API 至少间隔 100ms
|
||
|
||
从理论上说,100ms 对应最高:
|
||
|
||
1000ms ÷ 100ms = 10 次/秒
|
||
|
||
所以:
|
||
|
||
requestInterval: 1000ms
|
||
|
||
相当于每秒最多一次。
|
||
|
||
requestInterval: 200ms
|
||
|
||
相当于每秒最多五次。
|
||
|
||
这个设计本身没有问题,甚至很直观。
|
||
|
||
建议保留 requestInterval
|
||
|
||
对于你的配置风格,不一定要引入复杂的:
|
||
|
||
requests: 5
|
||
period: 1s
|
||
|
||
直接这样就够清晰:
|
||
|
||
requestInterval: 200ms
|
||
|
||
含义:
|
||
|
||
同一个上游代理商的两次 API 调用至少间隔 200ms。
|
||
|
||
对应关系:
|
||
|
||
无限制:0s
|
||
每秒 10 次:100ms
|
||
每秒 5 次:200ms
|
||
每秒 2 次:500ms
|
||
每秒 1 次:1s
|
||
每 3 秒一次:3s
|
||
|
||
但 0s 最好不要真无限,建议增加系统保护值:
|
||
|
||
requestInterval: 0s
|
||
internalRequestInterval: 10ms
|
||
|
||
或者规定:
|
||
|
||
requestInterval <= 0 时,内部默认最低间隔 10ms
|
||
|
||
否则程序 Bug 可能瞬间疯狂调用 API。
|
||
|
||
需要加入 minSize
|
||
|
||
你目前只有:
|
||
|
||
maxSize: 5
|
||
|
||
这只能表示“最多缓存几个”,无法表示什么时候补充。
|
||
|
||
建议:
|
||
|
||
minSize: 3
|
||
maxSize: 5
|
||
|
||
含义:
|
||
|
||
可用 IP 少于 3 个时开始补充
|
||
最多缓存 5 个有效 IP
|
||
|
||
不过由于你的代理 API 一次只提取一个,这个模型刚好很适合。
|
||
|
||
补池逻辑:
|
||
|
||
availableSize < minSize
|
||
↓
|
||
按 requestInterval 调用 API
|
||
↓
|
||
直到 availableSize 达到 maxSize
|
||
|
||
例如:
|
||
|
||
minSize: 3
|
||
maxSize: 5
|
||
requestInterval: 1000ms
|
||
|
||
当前只剩 1 个可用代理,需要补到 5 个:
|
||
|
||
第 0 秒请求一次
|
||
第 1 秒请求一次
|
||
第 2 秒请求一次
|
||
第 3 秒请求一次
|
||
|
||
最少约 3~4 秒完成。
|
||
|
||
必须增加单 IP 并发限制
|
||
|
||
这是你当前配置里最重要的缺失项。
|
||
|
||
建议增加:
|
||
|
||
maxConcurrencyPerIP: 10
|
||
|
||
完整一点:
|
||
|
||
maxConcurrencyPerIP: 10
|
||
concurrencySafetyRatio: 0.8
|
||
|
||
如果上游说单个代理支持 10 并发,内部只使用:
|
||
|
||
10 × 0.8 = 8
|
||
|
||
也可以直接只配置最终限制:
|
||
|
||
maxConcurrencyPerIP: 8
|
||
|
||
代理选择时需要判断:
|
||
|
||
activeConcurrency < maxConcurrencyPerIP
|
||
|
||
如果全部代理都满了,则:
|
||
|
||
等待空闲槽位
|
||
使用其他上游代理商
|
||
根据策略直连
|
||
返回“代理容量不足”
|
||
|
||
不能继续把流量塞给已经满载的 IP。
|
||
|
||
maxSize 不等于最大并发
|
||
|
||
例如:
|
||
|
||
maxSize: 5
|
||
maxConcurrencyPerIP: 10
|
||
|
||
理论最大并发:
|
||
|
||
5 × 10 = 50
|
||
|
||
如果目标是 10,000 并发,而每个代理 IP 最大 10 并发,则至少需要:
|
||
|
||
10000 ÷ 10 = 1000 个代理 IP
|
||
|
||
考虑失效、检测、网络波动,实际可能要配置:
|
||
|
||
minSize: 1200
|
||
maxSize: 1500
|
||
maxConcurrencyPerIP: 10
|
||
|
||
当然前提是上游允许你同时保有这么多 IP。
|
||
|
||
你的模板有一个潜在问题
|
||
|
||
第一段模板是:
|
||
|
||
template: '{{$x := regexFindAll "\\d{1,3}(\\.\\d{1,3}){3}:\\d{2,5}" . -1}}{{range $s := $x}}{{printf "http://%s" $s}}{{end}}'
|
||
|
||
如果 API 返回多个 IP,最终可能变成:
|
||
|
||
http://1.1.1.1:8000http://2.2.2.2:8000
|
||
|
||
中间没有换行。
|
||
|
||
建议始终输出换行:
|
||
|
||
template: |
|
||
{{$x := regexFindAll "\d{1,3}(\.\d{1,3}){3}:\d{2,5}" . -1}}
|
||
{{range $s := $x}}{{printf "http://%s\n" $s}}{{end}}
|
||
|
||
不过既然注释明确写了“提取 1 个”,可以不依赖循环,最好直接解析第一个:
|
||
|
||
template: |
|
||
{{$x := regexFind "\d{1,3}(\.\d{1,3}){3}:\d{2,5}" .}}
|
||
{{if $x}}{{printf "http://%s" $x}}{{end}}
|
||
|
||
这样更明确。
|
||
|
||
但正则只能匹配 IPv4,不支持:
|
||
|
||
用户名密码认证
|
||
HTTPS 代理
|
||
SOCKS5
|
||
域名代理地址
|
||
IPv6
|
||
|
||
建议允许上游模板直接输出完整 URI:
|
||
|
||
http://username:password@1.2.3.4:8000
|
||
socks5://username:password@example.com:1080
|
||
checkRetryNumber 的语义需要重新定义
|
||
|
||
现在注释:
|
||
|
||
checkRetryNumber: 2 #检测代理次数,超过次数则获取新代理
|
||
|
||
这里容易有歧义:
|
||
|
||
总共检查两次?
|
||
第一次失败后再重试两次,总共三次?
|
||
代理生命周期内累计失败两次?
|
||
连续失败两次?
|
||
|
||
建议改为:
|
||
|
||
checkMaxAttempts: 2
|
||
|
||
表示一次健康检查任务最多尝试两次。
|
||
|
||
再增加:
|
||
|
||
maxConsecutiveFailures: 2
|
||
|
||
表示代理连续多少轮健康检查失败后淘汰。
|
||
|
||
例如:
|
||
|
||
check:
|
||
urls:
|
||
- http://connect.rom.miui.com/generate_204
|
||
- https://www.baidu.com/
|
||
timeout: 2s
|
||
maxAttempts: 2
|
||
retryWait: 500ms
|
||
maxConsecutiveFailures: 2
|
||
interval: 10s
|
||
|
||
这样含义更清楚。
|
||
|
||
apiRetryNumber 也容易混淆
|
||
|
||
当前:
|
||
|
||
apiRetryNumber: 5 #上游重试次数,超过次数则采用直连
|
||
|
||
这里至少混合了两个逻辑:
|
||
|
||
调用代理提取 API 失败后的重试
|
||
业务请求通过代理失败后的重试
|
||
|
||
应该分开。
|
||
|
||
fetch:
|
||
maxAttempts: 5
|
||
|
||
表示获取代理 API 最多尝试几次。
|
||
|
||
request:
|
||
proxyMaxAttempts: 2
|
||
fallback: direct
|
||
|
||
表示业务请求最多尝试几个代理,失败后是否直连。
|
||
|
||
建议不要让“代理 API 获取失败”直接决定“业务请求直连”。这两者不是同一层。
|
||
|
||
正确逻辑:
|
||
|
||
提取 API 失败
|
||
↓
|
||
已有代理池仍可继续使用
|
||
↓
|
||
池耗尽后根据 fallback 决定
|
||
直连降级要谨慎
|
||
|
||
目前是:
|
||
|
||
超过次数则采用直连
|
||
|
||
对于京东域名,如果业务的目的就是必须使用代理,那么自动直连可能会:
|
||
|
||
暴露服务器真实出口 IP
|
||
破坏 IP 隔离
|
||
导致账号或会话环境变化
|
||
让原本应代理的请求绕过代理
|
||
产生不可预期的风控行为
|
||
|
||
建议做成显式配置:
|
||
|
||
fallback:
|
||
mode: reject
|
||
|
||
支持:
|
||
|
||
reject 无代理时直接返回错误
|
||
direct 无代理时直连
|
||
next 尝试下一个代理池
|
||
wait 等待代理槽位
|
||
|
||
更合理的组合:
|
||
|
||
fallback:
|
||
order:
|
||
- next
|
||
- wait
|
||
- reject
|
||
waitTimeout: 3s
|
||
|
||
如果确实允许直连:
|
||
|
||
fallback:
|
||
order:
|
||
- next
|
||
- direct
|
||
|
||
不要把直连写死在 apiRetryNumber 里面。
|
||
|
||
多代理池的选择策略
|
||
|
||
你现在写:
|
||
|
||
proxy: proxy1,proxy2
|
||
|
||
需要明确它表示什么:
|
||
|
||
按顺序尝试
|
||
随机
|
||
轮询
|
||
加权轮询
|
||
优先使用 proxy1,失败才使用 proxy2
|
||
选择剩余并发最多的池
|
||
|
||
建议不要使用逗号字符串,改成数组:
|
||
|
||
changeRequest:
|
||
- hostRegex: '(^|\.)jd\.com$|(^|\.)isvjd\.com$|(^|\.)isvjcloud\.com$'
|
||
proxies:
|
||
- name: proxy1
|
||
weight: 80
|
||
- name: proxy2
|
||
weight: 20
|
||
strategy: weighted
|
||
|
||
或者主备:
|
||
|
||
changeRequest:
|
||
- hostRegex: '(^|\.)jd\.com$'
|
||
proxies:
|
||
- proxy1
|
||
- proxy2
|
||
strategy: failover
|
||
|
||
策略支持:
|
||
|
||
roundRobin
|
||
weighted
|
||
random
|
||
leastConnections
|
||
failover
|
||
capacityWeighted
|
||
|
||
对于代理池,最推荐:
|
||
|
||
strategy: leastConnections
|
||
|
||
或者:
|
||
|
||
strategy: capacityWeighted
|
||
|
||
根据剩余并发槽位选择。
|
||
|
||
你的域名正则也需要调整
|
||
|
||
当前:
|
||
|
||
(.+\.jd\.com)|(.+\.isvjd\.com)|(.+\.isvjcloud\.com)
|
||
|
||
它能匹配:
|
||
|
||
api.jd.com
|
||
www.jd.com
|
||
|
||
但不匹配裸域名:
|
||
|
||
jd.com
|
||
isvjd.com
|
||
isvjcloud.com
|
||
|
||
而且如果程序使用的是“查找匹配”而不是“完整匹配”,可能出现不够严格的问题。
|
||
|
||
建议:
|
||
|
||
hostRegex: '(^|\.)jd\.com$|(^|\.)isvjd\.com$|(^|\.)isvjcloud\.com$'
|
||
|
||
它可以匹配:
|
||
|
||
jd.com
|
||
www.jd.com
|
||
api.m.jd.com
|
||
isvjd.com
|
||
xxx.isvjd.com
|
||
|
||
但不会匹配:
|
||
|
||
fakejd.com
|
||
jd.com.example.org
|
||
|
||
如果请求里的 host 包含端口,比如:
|
||
|
||
www.jd.com:443
|
||
|
||
最好程序先使用 net.SplitHostPort 去掉端口,再做正则匹配,而不是让正则兼容端口。
|
||
|
||
checkUrl 不建议用逗号字符串
|
||
|
||
现在:
|
||
|
||
checkUrl: http://jd.com/,http://baidu.com/,http://bilibili.com/
|
||
|
||
建议改数组:
|
||
|
||
checkUrls:
|
||
- https://www.jd.com/
|
||
- http://connect.rom.miui.com/generate_204
|
||
|
||
逗号字符串存在问题:
|
||
|
||
URL 参数中可能包含逗号
|
||
需要额外切割和去空格
|
||
YAML 可读性差
|
||
不方便为不同检测地址配置超时和预期状态码
|
||
|
||
更完善的写法:
|
||
|
||
check:
|
||
targets:
|
||
- url: http://connect.rom.miui.com/generate_204
|
||
expectedStatus:
|
||
- 204
|
||
|
||
- url: https://www.jd.com/
|
||
expectedStatus:
|
||
- 200
|
||
- 301
|
||
- 302
|
||
|
||
不过不要每次都同时检查所有 URL,否则 1000 个代理 × 3 个 URL 会产生大量额外请求。
|
||
|
||
建议:
|
||
|
||
check:
|
||
strategy: any
|
||
|
||
含义是任意一个检测成功就算通过。
|
||
|
||
或:
|
||
|
||
check:
|
||
strategy: targetFirst
|
||
|
||
优先检查目标业务域名,失败后再检查通用地址,以判断:
|
||
|
||
代理完全不可用
|
||
仅目标站不可用
|
||
lifecycle: -1 有风险
|
||
|
||
如果上游 IP 是有时效的,但配置成:
|
||
|
||
lifecycle: -1
|
||
|
||
意味着只依赖健康检查淘汰。可能出现:
|
||
|
||
IP 已经过期
|
||
尚未到下一轮检测
|
||
新业务请求仍被分配给它
|
||
请求失败后才发现过期
|
||
|
||
最好支持三种生命周期来源:
|
||
|
||
lifecycle:
|
||
mode: fixed
|
||
ttl: 20s
|
||
lifecycle:
|
||
mode: response
|
||
|
||
表示从 API 响应中解析过期时间。
|
||
|
||
lifecycle:
|
||
mode: healthCheckOnly
|
||
|
||
表示确实没有有效期信息,只靠检测。
|
||
|
||
兼容旧配置也可以:
|
||
|
||
lifecycle: 20s
|
||
|
||
但不要让 -1 同时表达太多含义。
|
||
|
||
更适合你当前项目的配置版本
|
||
|
||
可以改成下面这样:
|
||
|
||
defaultCheck:
|
||
targets:
|
||
- url: http://connect.rom.miui.com/generate_204
|
||
expectedStatus: [204]
|
||
- url: https://www.baidu.com/
|
||
expectedStatus: [200, 301, 302]
|
||
strategy: any
|
||
timeout: 2s
|
||
maxAttempts: 2
|
||
retryWait: 500ms
|
||
interval: 10s
|
||
maxConsecutiveFailures: 2
|
||
|
||
changeRequest:
|
||
- hostRegex: '(^|\.)jd\.com$|(^|\.)isvjd\.com$|(^|\.)isvjcloud\.com$'
|
||
|
||
upstreams:
|
||
- proxy1
|
||
- proxy2
|
||
|
||
strategy: leastConnections
|
||
|
||
fallback:
|
||
order:
|
||
- next
|
||
- wait
|
||
- reject
|
||
waitTimeout: 3s
|
||
|
||
upstream:
|
||
proxy1:
|
||
apiUrl: ''
|
||
|
||
template: |
|
||
{{$x := regexFind "\d{1,3}(\.\d{1,3}){3}:\d{2,5}" .}}
|
||
{{if $x}}{{printf "http://%s" $x}}{{end}}
|
||
|
||
pool:
|
||
minSize: 3
|
||
maxSize: 5
|
||
|
||
capacity:
|
||
maxConcurrencyPerIP: 10
|
||
|
||
lifecycle:
|
||
mode: fixed
|
||
ttl: 20s
|
||
allocationSafetyMargin: 3s
|
||
|
||
fetch:
|
||
requestInterval: 1s
|
||
timeout: 3s
|
||
maxAttempts: 5
|
||
maxInFlight: 1
|
||
|
||
check:
|
||
targets:
|
||
- url: https://www.jd.com/
|
||
expectedStatus: [200, 301, 302]
|
||
timeout: 2s
|
||
maxAttempts: 2
|
||
retryWait: 500ms
|
||
interval: 10s
|
||
maxConsecutiveFailures: 2
|
||
|
||
proxy2:
|
||
apiUrl: ''
|
||
|
||
template: |
|
||
{{$x := regexFind "\d{1,3}(\.\d{1,3}){3}:\d{2,5}" .}}
|
||
{{if $x}}{{printf "http://%s" $x}}{{end}}
|
||
|
||
pool:
|
||
minSize: 1
|
||
maxSize: 2
|
||
|
||
capacity:
|
||
maxConcurrencyPerIP: 5
|
||
|
||
lifecycle:
|
||
mode: healthCheckOnly
|
||
|
||
fetch:
|
||
requestInterval: 200ms
|
||
timeout: 3s
|
||
maxAttempts: 5
|
||
maxInFlight: 1
|
||
如果想尽量保持你原来的配置格式
|
||
|
||
最少只需要增加这些字段:
|
||
|
||
upstream:
|
||
proxy:
|
||
template: |
|
||
{{$x := regexFind "\d{1,3}(\.\d{1,3}){3}:\d{2,5}" .}}
|
||
{{if $x}}{{printf "http://%s" $x}}{{end}}
|
||
|
||
apiUrl: ''
|
||
|
||
lifecycle: 20s
|
||
|
||
minSize: 3
|
||
maxSize: 5
|
||
|
||
maxConcurrencyPerIP: 10
|
||
|
||
requestInterval: 1s
|
||
maxFetchInFlight: 1
|
||
fetchTimeout: 3s
|
||
apiRetryNumber: 5
|
||
|
||
checkUrls:
|
||
- https://www.jd.com/
|
||
- http://connect.rom.miui.com/generate_204
|
||
|
||
checkRetryNumber: 2
|
||
checkTimeout: 2s
|
||
checkRetryWait: 500ms
|
||
checkInterval: 10s
|
||
|
||
fallback: reject
|
||
|
||
核心补池判断应是:
|
||
|
||
可分配 IP 数量 < minSize
|
||
或者
|
||
可用并发槽位不足
|
||
|
||
而不是只有:
|
||
|
||
当前缓存数量 < maxSize
|
||
|
||
你这套配置方向没问题,关键是把它从“维护几个代理地址”,升级成“维护多少个可分配并发槽位”。requestInterval 也完全可以继续用,简单、直观,而且天然适配每家供应商不同的 API 限速。
|
||
|
||
|
||
那这样的话,maxSize 的语义就完全变了,它不是"缓存数量",而是允许从该上游代理商维护的最大代理数量。我觉得这个定义更合理。
|
||
|
||
那我建议整个配置改成下面这种语义,会非常清晰。
|
||
|
||
upstream:
|
||
proxy1:
|
||
apiUrl: xxx
|
||
|
||
lifecycle: 120s
|
||
|
||
maxSize: 100 # 最多维护100个来自该代理商的IP
|
||
|
||
requestInterval: 200ms # 两次请求API最小间隔
|
||
|
||
maxConcurrencyPerIP: 10
|
||
|
||
checkRetryNumber: 2
|
||
apiRetryNumber: 5
|
||
|
||
这里 maxSize 的意思就是:
|
||
|
||
这个 Provider 最终允许存在多少个可用 IP。
|
||
|
||
例如:
|
||
|
||
Provider A
|
||
maxSize = 100
|
||
|
||
当前:
|
||
Healthy = 82
|
||
Checking = 5
|
||
Fetching = 3
|
||
Expired = 10
|
||
|
||
实际总维护数量:
|
||
82 + 5 + 3 = 90
|
||
|
||
那么还能继续获取:
|
||
|
||
100 - 90 = 10
|
||
|
||
而不是:
|
||
|
||
100 - 82
|
||
|
||
因为正在检测、正在获取的也占用了容量。
|
||
|
||
我建议不要叫 minSize
|
||
|
||
因为你的补池逻辑和很多代理池不一样。
|
||
|
||
更建议叫:
|
||
|
||
targetSize: 100
|
||
|
||
或者继续保留
|
||
|
||
maxSize: 100
|
||
|
||
然后系统永远尝试保持:
|
||
|
||
当前数量 < maxSize
|
||
↓
|
||
继续获取
|
||
|
||
例如:
|
||
|
||
maxSize = 100
|
||
|
||
当前 97
|
||
|
||
继续获取3个
|
||
|
||
如果:
|
||
|
||
当前95
|
||
|
||
上游一次只能返回5个
|
||
|
||
最终100
|
||
|
||
如果:
|
||
|
||
当前98
|
||
|
||
上游一次固定返回5个
|
||
|
||
最终103
|
||
|
||
这种情况下可以:
|
||
|
||
获取5个
|
||
↓
|
||
|
||
保留2个
|
||
|
||
剩余3个丢弃
|
||
|
||
或者更好的:
|
||
|
||
maxSize:100
|
||
allowOverflow:5
|
||
|
||
允许轻微溢出。
|
||
|
||
但是我觉得还缺一个参数
|
||
|
||
因为现在只有:
|
||
|
||
maxSize
|
||
|
||
并不知道什么时候开始补。
|
||
|
||
例如:
|
||
|
||
maxSize=100
|
||
|
||
当前99
|
||
|
||
是不是立即获取?
|
||
|
||
如果每次都等少一个就获取,会不停请求API。
|
||
|
||
我建议增加:
|
||
|
||
refillThreshold: 80
|
||
|
||
表示:
|
||
|
||
当可用IP <=80
|
||
|
||
开始补
|
||
|
||
一直补到100
|
||
|
||
这样:
|
||
|
||
100
|
||
99
|
||
98
|
||
97
|
||
...
|
||
|
||
81
|
||
|
||
80 ← 开始补
|
||
|
||
81
|
||
82
|
||
...
|
||
100
|
||
|
||
API调用次数会少很多。
|
||
|
||
更推荐的命名
|
||
|
||
我会设计成:
|
||
|
||
upstream:
|
||
proxy1:
|
||
|
||
capacity:
|
||
|
||
targetSize: 100 # 最终维护多少个
|
||
|
||
refillThreshold: 80 # 少于80开始补
|
||
|
||
maxConcurrencyPerIP: 10
|
||
|
||
fetch:
|
||
|
||
requestInterval: 200ms
|
||
|
||
apiRetryNumber: 5
|
||
|
||
timeout: 3s
|
||
|
||
lifecycle:
|
||
|
||
ttl: 120s
|
||
|
||
check:
|
||
|
||
retryNumber: 2
|
||
timeout: 2s
|
||
|
||
这样职责很清晰:
|
||
|
||
capacity
|
||
管数量
|
||
|
||
fetch
|
||
管怎么获取
|
||
|
||
check
|
||
管怎么检测
|
||
|
||
lifecycle
|
||
管生命周期
|
||
我还有一个建议(这是我觉得整个代理池最重要的一点)
|
||
|
||
不要让 maxSize 代表"缓存数量",而应该代表"许可证(License)"。
|
||
|
||
什么意思?
|
||
|
||
假设:
|
||
|
||
proxy1
|
||
|
||
maxSize = 100
|
||
|
||
实际上系统里面应该维护:
|
||
|
||
Healthy = 60
|
||
|
||
Checking = 5
|
||
|
||
Fetching = 10
|
||
|
||
Reserved = 15
|
||
|
||
Expired = 10
|
||
|
||
其中:
|
||
|
||
Healthy:可分配
|
||
Checking:健康检查中
|
||
Fetching:已经向上游请求,还没返回
|
||
Reserved:已经分配给固定会话,但暂时不可复用
|
||
Expired:等待清理
|
||
|
||
真正参与容量计算的是:
|
||
|
||
Healthy
|
||
+ Checking
|
||
+ Fetching
|
||
+ Reserved
|
||
<= maxSize
|
||
|
||
而不是只有 Healthy。
|
||
|
||
这样不会出现多个协程同时判断:
|
||
|
||
当前60
|
||
|
||
还能拉40
|
||
|
||
最后一下子拉成140个。
|
||
|
||
如果是我来设计这个代理池(目标 1 万+ 并发),我甚至不会再用 maxSize 这个名字,而会改成:
|
||
|
||
capacity:
|
||
desired: 100 # 希望维持100个
|
||
lowWatermark: 80 # 低水位
|
||
highWatermark:100 # 高水位
|
||
|
||
这是大型连接池(数据库连接池、Kafka Consumer、Nginx Upstream 等)普遍采用的"水位线"设计,比单独一个 maxSize 更容易扩展,以后加多个代理商、动态扩容都不用改配置结构。
|
||
|
||
对,这就说明不能把 maxSize 理解成“平时一直维持这么多”,而应该只是绝对上限。
|
||
|
||
空闲时应该缩池,忙时再扩池。关键还要区分代理商的计费模式。
|
||
|
||
两类代理商要用不同策略
|
||
1. 提取即计费 / 提取即消耗 IP
|
||
|
||
这种最怕预拉太多。
|
||
|
||
建议:
|
||
|
||
billingMode: fetch
|
||
|
||
策略:
|
||
|
||
空闲时只保留很少,甚至 0 个
|
||
有请求时按需拉取
|
||
只保留必要的安全余量
|
||
不主动补到 maxSize
|
||
|
||
例如:
|
||
|
||
minIdleSize: 0
|
||
warmSize: 2
|
||
maxSize: 100
|
||
|
||
含义:
|
||
|
||
空闲时允许 0 个
|
||
有少量流量时维持 2 个热代理
|
||
高峰时最多扩到 100 个
|
||
2. 使用时计费 / 建连或流量计费
|
||
|
||
这种提前获取本身不花钱,可以适当预热。
|
||
|
||
建议:
|
||
|
||
billingMode: usage
|
||
|
||
策略:
|
||
|
||
可以维持一定空闲代理
|
||
提前完成健康检查
|
||
降低首个请求延迟
|
||
但仍不要无脑维持到 maxSize
|
||
|
||
例如:
|
||
|
||
minIdleSize: 5
|
||
warmSize: 20
|
||
maxSize: 100
|
||
maxSize 应该只表示硬上限
|
||
|
||
建议最终语义是:
|
||
|
||
maxSize: 100
|
||
|
||
表示:
|
||
|
||
该代理商最多允许同时维护 100 个尚未淘汰的代理 IP。
|
||
|
||
它不表示平时要维持 100 个。
|
||
|
||
真正决定当前应该维护多少的是动态目标:
|
||
|
||
desiredSize
|
||
|
||
并且满足:
|
||
|
||
0 <= desiredSize <= maxSize
|
||
动态目标数量怎么计算
|
||
|
||
可以按当前并发和每个 IP 的并发容量算:
|
||
|
||
neededByLoad =
|
||
ceil(
|
||
当前并发 × 安全系数
|
||
÷ 单IP有效并发
|
||
)
|
||
|
||
例如:
|
||
|
||
当前并发:240
|
||
单 IP 最大并发:10
|
||
安全系数:1.25
|
||
|
||
则:
|
||
|
||
ceil(240 × 1.25 ÷ 10)
|
||
= 30 个代理
|
||
|
||
再加上少量预热数量:
|
||
|
||
desiredSize =
|
||
max(minIdleSize, neededByLoad + warmSpareSize)
|
||
|
||
最后限制:
|
||
|
||
desiredSize =
|
||
min(desiredSize, maxSize)
|
||
更适合你的配置
|
||
upstream:
|
||
proxy1:
|
||
billingMode: fetch
|
||
|
||
pool:
|
||
minIdleSize: 0
|
||
warmSpareSize: 1
|
||
maxSize: 100
|
||
scaleDownDelay: 30s
|
||
|
||
capacity:
|
||
maxConcurrencyPerIP: 10
|
||
utilizationTarget: 0.8
|
||
|
||
fetch:
|
||
requestInterval: 1s
|
||
maxInFlight: 1
|
||
|
||
解释:
|
||
|
||
maxConcurrencyPerIP = 10
|
||
utilizationTarget = 0.8
|
||
|
||
内部按每个 IP 只承载 8 并发计算。
|
||
|
||
如果当前有 80 并发:
|
||
|
||
80 ÷ 8 = 10 个代理
|
||
|
||
再加一个备用:
|
||
|
||
desiredSize = 11
|
||
|
||
而不是直接拉满 100 个。
|
||
|
||
提取即计费的代理商,建议更保守
|
||
billingMode: fetch
|
||
|
||
pool:
|
||
minIdleSize: 0
|
||
warmSpareSize: 0
|
||
maxSize: 100
|
||
scaleUpStep: 1
|
||
scaleDownDelay: 10s
|
||
|
||
它的策略应该是:
|
||
|
||
当前容量不够
|
||
↓
|
||
只补最少需要的数量
|
||
|
||
不要一次补很多。
|
||
|
||
例如当前缺 3 个 IP:
|
||
|
||
就拉 3 个
|
||
|
||
而不是补到某个高水位。
|
||
|
||
不过这里还受 requestInterval 限制。如果每秒只能拉一个,就需要提前预测,不能等全部占满才开始拉。
|
||
|
||
使用时计费的代理商,可以积极一点
|
||
billingMode: usage
|
||
|
||
pool:
|
||
minIdleSize: 5
|
||
warmSpareSize: 10
|
||
maxSize: 100
|
||
scaleDownDelay: 120s
|
||
|
||
这种可以让一些代理提前处于健康状态,因为单纯获取不产生费用。
|
||
|
||
最好再区分第三种:包量或周期计费
|
||
|
||
有些代理商可能是:
|
||
|
||
按天套餐
|
||
按月套餐
|
||
固定 IP 数量套餐
|
||
已购买额度内提取不额外计费
|
||
|
||
可以定义:
|
||
|
||
billingMode: prepaid
|
||
|
||
策略类似 usage,甚至可以更积极预热。
|
||
|
||
最终三种:
|
||
|
||
fetch 提取即计费
|
||
usage 使用、流量或成功请求计费
|
||
prepaid 套餐或已付费额度
|
||
缩池也很重要
|
||
|
||
代理空闲后不能立刻删除,否则流量轻微波动就会反复:
|
||
|
||
拉取 → 删除 → 拉取 → 删除
|
||
|
||
这会浪费 IP 和 API 配额。
|
||
|
||
应该设置:
|
||
|
||
scaleDownDelay: 30s
|
||
|
||
只有代理持续空闲 30 秒才淘汰。
|
||
|
||
对于提取即计费而且 IP 生命周期很短的供应商,可以不主动删除,直接停止补充,让它自然过期:
|
||
|
||
scaleDownMode: expireOnly
|
||
|
||
这通常更合理。
|
||
|
||
可支持:
|
||
|
||
expireOnly 不主动删,等待自然过期
|
||
immediate 立即释放
|
||
delayed 空闲一段时间后释放
|
||
推荐的完整配置模型
|
||
upstream:
|
||
proxy1:
|
||
billingMode: fetch
|
||
|
||
lifecycle:
|
||
ttl: 60s
|
||
safetyMargin: 10s
|
||
|
||
pool:
|
||
minIdleSize: 0
|
||
warmSpareSize: 1
|
||
maxSize: 100
|
||
|
||
scaleUpStep: 1
|
||
scaleDownMode: expireOnly
|
||
scaleDownDelay: 30s
|
||
|
||
capacity:
|
||
maxConcurrencyPerIP: 10
|
||
utilizationTarget: 0.8
|
||
|
||
fetch:
|
||
requestInterval: 1s
|
||
maxInFlight: 1
|
||
timeout: 3s
|
||
|
||
另一个按使用计费的供应商:
|
||
|
||
upstream:
|
||
proxy2:
|
||
billingMode: usage
|
||
|
||
lifecycle:
|
||
ttl: 300s
|
||
safetyMargin: 20s
|
||
|
||
pool:
|
||
minIdleSize: 5
|
||
warmSpareSize: 10
|
||
maxSize: 200
|
||
|
||
scaleUpStep: 10
|
||
scaleDownMode: delayed
|
||
scaleDownDelay: 120s
|
||
|
||
capacity:
|
||
maxConcurrencyPerIP: 20
|
||
utilizationTarget: 0.75
|
||
|
||
fetch:
|
||
requestInterval: 200ms
|
||
maxInFlight: 2
|
||
核心结论
|
||
|
||
你这里需要把三个概念彻底分开:
|
||
|
||
maxSize
|
||
= 最多允许维护多少个代理
|
||
|
||
desiredSize
|
||
= 当前流量下应该维护多少个代理
|
||
|
||
minIdleSize
|
||
= 空闲时至少保留多少个代理
|
||
|
||
对于“提取即计费”的代理商,minIdleSize 可以是 0,desiredSize 随流量增长;对于“使用才计费”的代理商,可以保留一定热代理,减少首次请求等待。
|
||
|
||
这个 changeRequest 我觉得还可以再设计一下,因为它本质上不是代理配置,而是"路由规则(Routing Rule)"。
|
||
|
||
你现在的意思是:
|
||
|
||
changeRequest:
|
||
- hostRegex: .+\.xxx\.com
|
||
proxy: proxy
|
||
|
||
- hostRegex: .*
|
||
proxy: proxy
|
||
|
||
实际上表达的是:
|
||
|
||
请求
|
||
↓
|
||
匹配 host
|
||
↓
|
||
找到对应代理池
|
||
↓
|
||
转发
|
||
|
||
所以它应该和 upstream 解耦。
|
||
|
||
我建议先定义规则
|
||
|
||
例如:
|
||
|
||
rules:
|
||
- match:
|
||
hostRegex: '(^|\.)jd\.com$'
|
||
upstream: proxy1
|
||
|
||
- match:
|
||
hostRegex: '(^|\.)taobao\.com$'
|
||
upstream: proxy2
|
||
|
||
- match:
|
||
hostRegex: '.*'
|
||
upstream: direct
|
||
|
||
这样很清楚:
|
||
|
||
Rule
|
||
↓
|
||
决定走哪个 upstream
|
||
|
||
而不是:
|
||
|
||
Rule
|
||
↓
|
||
直接决定 proxy
|
||
|
||
因为以后可能还有:
|
||
|
||
direct
|
||
block
|
||
proxy group
|
||
load balance
|
||
failover
|
||
如果还想兼容现在的写法
|
||
|
||
完全可以:
|
||
|
||
changeRequest:
|
||
- hostRegex: '(^|\.)jd\.com$'
|
||
proxy: proxy1
|
||
|
||
- hostRegex: '.*'
|
||
proxy: direct
|
||
|
||
程序内部转换成:
|
||
|
||
Rule{
|
||
Match: HostRegex,
|
||
Target: Upstream,
|
||
}
|
||
我更关心的是多个 Proxy
|
||
|
||
你现在已经支持:
|
||
|
||
proxy: proxy1,proxy2
|
||
|
||
那规则应该约束什么?
|
||
|
||
例如:
|
||
|
||
changeRequest:
|
||
- hostRegex: '(^|\.)jd\.com$'
|
||
proxy: proxy1,proxy2
|
||
|
||
到底表示:
|
||
|
||
proxy1 优先
|
||
|
||
还是
|
||
|
||
随机
|
||
|
||
还是
|
||
|
||
轮询
|
||
|
||
还是
|
||
|
||
按权重
|
||
|
||
还是
|
||
|
||
剩余容量最多
|
||
|
||
这里必须配置。
|
||
|
||
例如:
|
||
|
||
changeRequest:
|
||
- hostRegex: '(^|\.)jd\.com$'
|
||
|
||
strategy: failover
|
||
|
||
proxies:
|
||
- proxy1
|
||
- proxy2
|
||
|
||
或者
|
||
|
||
strategy: roundRobin
|
||
|
||
或者
|
||
|
||
strategy: weighted
|
||
我甚至建议 Rule 支持条件
|
||
|
||
以后很多人都会提这种需求:
|
||
|
||
同一个域名
|
||
|
||
POST 走代理
|
||
|
||
GET 不走
|
||
|
||
或者:
|
||
|
||
/api
|
||
|
||
走代理A
|
||
|
||
/login
|
||
|
||
走代理B
|
||
|
||
所以 Rule 最好设计成:
|
||
|
||
rules:
|
||
|
||
- match:
|
||
hostRegex: '(^|\.)jd\.com$'
|
||
method:
|
||
- GET
|
||
- POST
|
||
pathRegex: '^/api'
|
||
|
||
upstream: proxy1
|
||
|
||
以后不用改结构。
|
||
|
||
再往后可以支持 Header
|
||
|
||
例如:
|
||
|
||
match:
|
||
|
||
hostRegex: xxx
|
||
|
||
headers:
|
||
|
||
X-Test: abc
|
||
|
||
User-Agent: Chrome.*
|
||
|
||
甚至:
|
||
|
||
clientIP:
|
||
|
||
- 192.168.1.0/24
|
||
|
||
所以 Rule 应该是:
|
||
|
||
Rule
|
||
|
||
↓
|
||
|
||
Match
|
||
|
||
↓
|
||
|
||
Action
|
||
Action 不一定只有 Proxy
|
||
|
||
例如:
|
||
|
||
action:
|
||
|
||
type: proxy
|
||
|
||
upstream: proxy1
|
||
|
||
以后还能:
|
||
|
||
action:
|
||
|
||
type: direct
|
||
action:
|
||
|
||
type: reject
|
||
action:
|
||
|
||
type: rewrite
|
||
我建议增加优先级
|
||
|
||
例如:
|
||
|
||
rules:
|
||
|
||
- priority: 100
|
||
|
||
match:
|
||
hostRegex: '^api\.jd\.com$'
|
||
|
||
upstream: proxy1
|
||
|
||
- priority: 10
|
||
|
||
match:
|
||
hostRegex: '.*'
|
||
|
||
upstream: direct
|
||
|
||
否则以后只能依赖配置顺序。
|
||
|
||
最后还有一个容易忽略的点:规则匹配方式
|
||
|
||
建议明确规定:
|
||
|
||
规则从上往下匹配
|
||
|
||
命中第一条立即停止
|
||
|
||
例如:
|
||
|
||
changeRequest:
|
||
|
||
- hostRegex: '^api\.jd\.com$'
|
||
proxy: proxy1
|
||
|
||
- hostRegex: '(^|\.)jd\.com$'
|
||
proxy: proxy2
|
||
|
||
- hostRegex: '.*'
|
||
proxy: direct
|
||
|
||
请求:
|
||
|
||
api.jd.com
|
||
|
||
应该:
|
||
|
||
命中第一条
|
||
|
||
使用 proxy1
|
||
|
||
后面的不再判断
|
||
|
||
这一点一定要写进文档,否则用户会疑惑为什么配置了两条都能匹配,但最终只走了第一条。
|
||
|
||
所以如果让我设计,我会把整个配置拆成两个完全独立的部分:
|
||
|
||
rules:负责"哪些请求走哪里",支持 Host、Path、Method、Header 等匹配,并采用"自上而下、首条命中"的规则。
|
||
upstream:负责"代理池如何维护",包括 API、生命周期、最大数量、请求间隔、健康检查、单 IP 并发等。
|
||
|
||
这样路由和代理池各司其职,以后扩展也不会互相影响。
|
||
|
||
我会把它设计成两层:
|
||
|
||
Routing(请求路由):决定请求走哪个代理池。
|
||
Upstream(代理池):决定如何维护代理。
|
||
|
||
两层完全独立,这样以后加 SOCKS5、直连、多个代理商都不用改 Routing。
|
||
|
||
下面是我觉得比较完整的 V1 配置设计。
|
||
|
||
# Proxy Pool Configuration
|
||
|
||
## 配置结构
|
||
|
||
```yaml
|
||
version: 1
|
||
|
||
defaults:
|
||
check:
|
||
urls:
|
||
- http://connect.rom.miui.com/generate_204
|
||
timeout: 2s
|
||
retry: 2
|
||
retryInterval: 500ms
|
||
|
||
routing: []
|
||
|
||
upstreams: {}
|
||
```
|
||
|
||
---
|
||
|
||
# Routing(请求路由)
|
||
|
||
用于决定某个请求应该使用哪个 Upstream。
|
||
|
||
按照**从上到下**匹配。
|
||
|
||
命中第一条规则后立即停止。
|
||
|
||
## 示例
|
||
|
||
```yaml
|
||
routing:
|
||
|
||
- name: JD
|
||
|
||
match:
|
||
hostRegex: '(^|\.)jd\.com$|(^|\.)isvjd\.com$|(^|\.)isvjcloud\.com$'
|
||
|
||
upstreams:
|
||
- proxy-jd
|
||
|
||
- name: Github
|
||
|
||
match:
|
||
hostRegex: '(^|\.)github\.com$'
|
||
|
||
upstreams:
|
||
- github
|
||
|
||
- name: Default
|
||
|
||
match:
|
||
hostRegex: '.*'
|
||
|
||
action: direct
|
||
```
|
||
|
||
---
|
||
|
||
## Match
|
||
|
||
目前支持:
|
||
|
||
```yaml
|
||
match:
|
||
|
||
hostRegex:
|
||
|
||
method:
|
||
|
||
pathRegex:
|
||
|
||
headers:
|
||
```
|
||
|
||
例如:
|
||
|
||
```yaml
|
||
match:
|
||
|
||
hostRegex: '(^|\.)jd\.com$'
|
||
|
||
method:
|
||
|
||
- GET
|
||
|
||
- POST
|
||
|
||
pathRegex: '^/api'
|
||
```
|
||
|
||
---
|
||
|
||
## Action
|
||
|
||
一个规则只能执行一个 Action。
|
||
|
||
支持:
|
||
|
||
```yaml
|
||
action: direct
|
||
```
|
||
|
||
```yaml
|
||
action: reject
|
||
```
|
||
|
||
或者:
|
||
|
||
```yaml
|
||
upstreams:
|
||
|
||
- proxy-jd
|
||
```
|
||
|
||
多个 Upstream:
|
||
|
||
```yaml
|
||
upstreams:
|
||
|
||
- proxy-a
|
||
|
||
- proxy-b
|
||
```
|
||
|
||
---
|
||
|
||
## Upstream Selection
|
||
|
||
当存在多个 Upstream 时,需要指定选择策略。
|
||
|
||
```yaml
|
||
strategy: random
|
||
```
|
||
|
||
支持:
|
||
|
||
```text
|
||
random
|
||
|
||
roundRobin
|
||
|
||
weighted
|
||
|
||
leastConnections
|
||
|
||
failover
|
||
```
|
||
|
||
例如:
|
||
|
||
```yaml
|
||
routing:
|
||
|
||
- match:
|
||
hostRegex: '(^|\.)jd\.com$'
|
||
|
||
upstreams:
|
||
|
||
- proxy-a
|
||
|
||
- proxy-b
|
||
|
||
strategy: leastConnections
|
||
```
|
||
|
||
---
|
||
|
||
# Upstream
|
||
|
||
每个 Upstream 表示一个代理池。
|
||
|
||
```yaml
|
||
upstreams:
|
||
|
||
proxy-jd:
|
||
|
||
api:
|
||
url:
|
||
|
||
template:
|
||
|
||
pool:
|
||
|
||
capacity:
|
||
|
||
lifecycle:
|
||
|
||
fetch:
|
||
|
||
check:
|
||
```
|
||
|
||
---
|
||
|
||
# API
|
||
|
||
代理提取接口。
|
||
|
||
```yaml
|
||
api:
|
||
|
||
url: https://xxx/api
|
||
|
||
template: |
|
||
{{$x := regexFind "\d{1,3}(\.\d{1,3}){3}:\d{2,5}" .}}
|
||
{{printf "http://%s" $x}}
|
||
```
|
||
|
||
---
|
||
|
||
# Pool
|
||
|
||
代理池维护策略。
|
||
|
||
```yaml
|
||
pool:
|
||
|
||
maxSize: 100
|
||
|
||
shrinkDelay: 30s
|
||
```
|
||
|
||
说明:
|
||
|
||
| 参数 | 说明 |
|
||
|------|------|
|
||
|maxSize|允许同时维护的最大代理数量|
|
||
|shrinkDelay|代理空闲多久开始释放|
|
||
|
||
---
|
||
|
||
# Capacity
|
||
|
||
代理容量。
|
||
|
||
```yaml
|
||
capacity:
|
||
|
||
maxConcurrencyPerIP: 10
|
||
```
|
||
|
||
说明:
|
||
|
||
一个代理最多同时承载多少请求。
|
||
|
||
超过后不会继续分配。
|
||
|
||
---
|
||
|
||
# Lifecycle
|
||
|
||
代理生命周期。
|
||
|
||
```yaml
|
||
lifecycle:
|
||
|
||
ttl: 120s
|
||
|
||
allocationSafetyMargin: 15s
|
||
```
|
||
|
||
说明:
|
||
|
||
```text
|
||
ttl
|
||
```
|
||
|
||
主动失效时间。
|
||
|
||
```text
|
||
allocationSafetyMargin
|
||
```
|
||
|
||
距离失效不足该时间,不再分配新请求。
|
||
|
||
例如:
|
||
|
||
```text
|
||
TTL = 120s
|
||
|
||
Safety = 15s
|
||
```
|
||
|
||
代理使用:
|
||
|
||
```text
|
||
0~105 秒
|
||
|
||
105~120 秒
|
||
|
||
仅允许已有连接
|
||
|
||
120 秒
|
||
|
||
删除
|
||
```
|
||
|
||
---
|
||
|
||
# Fetch
|
||
|
||
控制如何向代理商提取代理。
|
||
|
||
```yaml
|
||
fetch:
|
||
|
||
requestInterval: 200ms
|
||
|
||
timeout: 5s
|
||
|
||
retry: 5
|
||
|
||
maxInFlight: 1
|
||
```
|
||
|
||
说明:
|
||
|
||
| 参数 | 说明 |
|
||
|------|------|
|
||
|requestInterval|两次请求 API 的最小时间间隔|
|
||
|timeout|API 超时时间|
|
||
|retry|API 最大重试次数|
|
||
|maxInFlight|同时最多几个请求正在提取|
|
||
|
||
例如:
|
||
|
||
```yaml
|
||
requestInterval: 1s
|
||
```
|
||
|
||
表示:
|
||
|
||
```text
|
||
最多约 1 次/秒
|
||
```
|
||
|
||
```yaml
|
||
requestInterval: 200ms
|
||
```
|
||
|
||
表示:
|
||
|
||
```text
|
||
最多约 5 次/秒
|
||
```
|
||
|
||
---
|
||
|
||
# Health Check
|
||
|
||
代理健康检查。
|
||
|
||
```yaml
|
||
check:
|
||
|
||
urls:
|
||
|
||
- http://connect.rom.miui.com/generate_204
|
||
|
||
- https://www.baidu.com/
|
||
|
||
timeout: 2s
|
||
|
||
retry: 2
|
||
|
||
retryInterval: 500ms
|
||
|
||
interval: 10s
|
||
```
|
||
|
||
---
|
||
|
||
# 完整示例
|
||
|
||
```yaml
|
||
version: 1
|
||
|
||
defaults:
|
||
|
||
check:
|
||
|
||
urls:
|
||
|
||
- http://connect.rom.miui.com/generate_204
|
||
|
||
timeout: 2s
|
||
|
||
retry: 2
|
||
|
||
retryInterval: 500ms
|
||
|
||
routing:
|
||
|
||
- name: JD
|
||
|
||
match:
|
||
|
||
hostRegex: '(^|\.)jd\.com$|(^|\.)isvjd\.com$|(^|\.)isvjcloud\.com$'
|
||
|
||
upstreams:
|
||
|
||
- jd-primary
|
||
|
||
- jd-backup
|
||
|
||
strategy: leastConnections
|
||
|
||
- name: Default
|
||
|
||
match:
|
||
|
||
hostRegex: '.*'
|
||
|
||
action: direct
|
||
|
||
upstreams:
|
||
|
||
jd-primary:
|
||
|
||
api:
|
||
|
||
url: https://provider-a/api
|
||
|
||
template: |
|
||
{{$x := regexFind "\d{1,3}(\.\d{1,3}){3}:\d{2,5}" .}}
|
||
{{printf "http://%s" $x}}
|
||
|
||
pool:
|
||
|
||
maxSize: 100
|
||
|
||
shrinkDelay: 30s
|
||
|
||
capacity:
|
||
|
||
maxConcurrencyPerIP: 10
|
||
|
||
lifecycle:
|
||
|
||
ttl: 120s
|
||
|
||
allocationSafetyMargin: 15s
|
||
|
||
fetch:
|
||
|
||
requestInterval: 1s
|
||
|
||
timeout: 5s
|
||
|
||
retry: 5
|
||
|
||
maxInFlight: 1
|
||
|
||
check:
|
||
|
||
urls:
|
||
|
||
- http://connect.rom.miui.com/generate_204
|
||
|
||
timeout: 2s
|
||
|
||
retry: 2
|
||
|
||
retryInterval: 500ms
|
||
|
||
interval: 10s
|
||
|
||
jd-backup:
|
||
|
||
api:
|
||
|
||
url: https://provider-b/api
|
||
|
||
template: |
|
||
{{$x := regexFind "\d{1,3}(\.\d{1,3}){3}:\d{2,5}" .}}
|
||
{{printf "http://%s" $x}}
|
||
|
||
pool:
|
||
|
||
maxSize: 20
|
||
|
||
capacity:
|
||
|
||
maxConcurrencyPerIP: 5
|
||
|
||
lifecycle:
|
||
|
||
ttl: 60s
|
||
|
||
fetch:
|
||
|
||
requestInterval: 200ms
|
||
|
||
retry: 3
|
||
|
||
maxInFlight: 2
|
||
```
|
||
我还建议增加一个 Provider Metadata(代理商能力) 节点
|
||
|
||
我们前面讨论了很多,其实不同代理商最大的区别不是 API,而是能力。
|
||
|
||
例如:
|
||
|
||
provider:
|
||
|
||
billingMode: fetch # fetch / usage / prepaid
|
||
|
||
supportsSticky: false
|
||
|
||
supportsIPv6: true
|
||
|
||
supportsUsernamePassword: true
|
||
|
||
supportsHTTPS: true
|
||
|
||
甚至可以再加:
|
||
|
||
provider:
|
||
|
||
maxReturnPerRequest: 100
|
||
|
||
fixedReturn: false
|
||
|
||
ttlSource: response # fixed / response
|
||
|
||
duplicatePolicy: allow # allow / reject
|
||
|
||
这样调度器可以根据不同代理商能力自动调整补池策略,而不是把这些逻辑硬编码在代码里。
|
||
|
||
我认为这一层比继续增加 pool 参数更有价值,也是后续扩展多家代理商时最容易受益的地方。
|
||
|
||
我觉得可以,而且我会再往前走一步,把它设计成一个真正可扩展的代理池配置,而不是一个"配置项堆积"。
|
||
|
||
我会遵循几个原则:
|
||
|
||
职责单一(一个节点只负责一件事)
|
||
支持未来扩展(SOCKS5、HTTP、固定IP、住宅IP等)
|
||
配置名尽量语义化(不用看源码也知道是什么意思)
|
||
不让配置决定实现细节(例如不要暴露 Redis、Worker 这些概念)
|
||
|
||
我建议目录结构如下:
|
||
|
||
# Configuration
|
||
|
||
```yaml
|
||
version: 1
|
||
|
||
defaults: {}
|
||
|
||
routing: []
|
||
|
||
upstreams: {}
|
||
```
|
||
|
||
---
|
||
|
||
# defaults
|
||
|
||
全局默认配置。
|
||
|
||
所有 Upstream 可继承,支持覆盖。
|
||
|
||
```yaml
|
||
defaults:
|
||
|
||
fetch:
|
||
|
||
timeout: 5s
|
||
|
||
retry: 3
|
||
|
||
check:
|
||
|
||
interval: 10s
|
||
|
||
timeout: 2s
|
||
|
||
retry: 2
|
||
|
||
urls:
|
||
|
||
- http://connect.rom.miui.com/generate_204
|
||
```
|
||
|
||
---
|
||
|
||
# routing
|
||
|
||
请求路由规则。
|
||
|
||
负责:
|
||
|
||
> 一个请求应该使用哪个 Upstream。
|
||
|
||
不负责代理维护。
|
||
|
||
按照**从上到下**匹配。
|
||
|
||
第一条命中立即停止。
|
||
|
||
```yaml
|
||
routing:
|
||
|
||
- name: JD
|
||
|
||
match:
|
||
|
||
hostRegex: '(^|\.)jd\.com$'
|
||
|
||
upstreams:
|
||
|
||
- jd-primary
|
||
|
||
- jd-backup
|
||
|
||
strategy: failover
|
||
|
||
- name: Github
|
||
|
||
match:
|
||
|
||
hostRegex: '(^|\.)github\.com$'
|
||
|
||
upstreams:
|
||
|
||
- github
|
||
|
||
- name: Default
|
||
|
||
match:
|
||
|
||
hostRegex: '.*'
|
||
|
||
action: direct
|
||
```
|
||
|
||
---
|
||
|
||
## match
|
||
|
||
目前支持:
|
||
|
||
```yaml
|
||
match:
|
||
|
||
hostRegex:
|
||
|
||
method:
|
||
|
||
pathRegex:
|
||
|
||
headers:
|
||
```
|
||
|
||
以后还能扩展:
|
||
|
||
```yaml
|
||
query:
|
||
|
||
clientIP:
|
||
|
||
scheme:
|
||
|
||
port:
|
||
```
|
||
|
||
---
|
||
|
||
## strategy
|
||
|
||
当有多个 Upstream 时。
|
||
|
||
支持:
|
||
|
||
```text
|
||
random
|
||
|
||
roundRobin
|
||
|
||
weighted
|
||
|
||
leastConnections
|
||
|
||
leastLoad
|
||
|
||
failover
|
||
```
|
||
|
||
---
|
||
|
||
## action
|
||
|
||
支持:
|
||
|
||
```yaml
|
||
action: direct
|
||
```
|
||
|
||
```yaml
|
||
action: reject
|
||
```
|
||
|
||
或者:
|
||
|
||
```yaml
|
||
upstreams:
|
||
|
||
- proxy-a
|
||
```
|
||
|
||
---
|
||
|
||
# upstreams
|
||
|
||
每个 Upstream 对应一个代理池。
|
||
|
||
```yaml
|
||
upstreams:
|
||
|
||
jd-primary:
|
||
|
||
provider:
|
||
|
||
api:
|
||
|
||
pool:
|
||
|
||
capacity:
|
||
|
||
lifecycle:
|
||
|
||
fetch:
|
||
|
||
check:
|
||
```
|
||
|
||
---
|
||
|
||
# provider
|
||
|
||
描述代理商能力。
|
||
|
||
不是运行参数。
|
||
|
||
```yaml
|
||
provider:
|
||
|
||
billingMode: usage
|
||
|
||
protocol:
|
||
|
||
- http
|
||
|
||
- https
|
||
|
||
stickySession: false
|
||
|
||
ipv6: true
|
||
|
||
auth:
|
||
|
||
usernamePassword: true
|
||
|
||
duplicatePolicy: allow
|
||
```
|
||
|
||
---
|
||
|
||
## billingMode
|
||
|
||
支持:
|
||
|
||
```text
|
||
fetch
|
||
|
||
usage
|
||
|
||
prepaid
|
||
```
|
||
|
||
说明:
|
||
|
||
|模式|说明|
|
||
|------|------|
|
||
|fetch|提取即计费|
|
||
|usage|使用时计费|
|
||
|prepaid|套餐计费|
|
||
|
||
---
|
||
|
||
# api
|
||
|
||
代理提取接口。
|
||
|
||
```yaml
|
||
api:
|
||
|
||
url:
|
||
|
||
method: GET
|
||
|
||
headers: {}
|
||
|
||
body: {}
|
||
|
||
template:
|
||
```
|
||
|
||
template:
|
||
|
||
```yaml
|
||
template: |
|
||
{{$x := regexFind "\d{1,3}(\.\d{1,3}){3}:\d{2,5}" .}}
|
||
{{printf "http://%s" $x}}
|
||
```
|
||
|
||
---
|
||
|
||
# pool
|
||
|
||
代理池策略。
|
||
|
||
```yaml
|
||
pool:
|
||
|
||
maxSize: 100
|
||
|
||
shrinkDelay: 60s
|
||
```
|
||
|
||
说明:
|
||
|
||
|maxSize|允许维护的最大代理数量|
|
||
|
||
不是:
|
||
|
||
> 启动立即拉满。
|
||
|
||
而是:
|
||
|
||
> 系统最多允许维护多少个代理。
|
||
|
||
---
|
||
|
||
# capacity
|
||
|
||
代理容量。
|
||
|
||
```yaml
|
||
capacity:
|
||
|
||
maxConcurrencyPerProxy: 10
|
||
```
|
||
|
||
表示:
|
||
|
||
每个代理允许同时承载多少请求。
|
||
|
||
超过以后不再分配。
|
||
|
||
---
|
||
|
||
# lifecycle
|
||
|
||
生命周期。
|
||
|
||
```yaml
|
||
lifecycle:
|
||
|
||
ttl: 120s
|
||
|
||
allocationSafetyMargin: 15s
|
||
```
|
||
|
||
说明:
|
||
|
||
```text
|
||
TTL
|
||
|
||
代理生命周期
|
||
```
|
||
|
||
```text
|
||
allocationSafetyMargin
|
||
|
||
距离过期多少秒停止分配新请求
|
||
```
|
||
|
||
---
|
||
|
||
# fetch
|
||
|
||
代理获取策略。
|
||
|
||
```yaml
|
||
fetch:
|
||
|
||
timeout: 5s
|
||
|
||
retry: 3
|
||
|
||
requestInterval: 1s
|
||
|
||
maxInFlight: 1
|
||
```
|
||
|
||
说明:
|
||
|
||
|参数|说明|
|
||
|------|------|
|
||
|timeout|API超时|
|
||
|retry|API重试次数|
|
||
|requestInterval|最小请求间隔|
|
||
|maxInFlight|最大同时提取数|
|
||
|
||
---
|
||
|
||
# check
|
||
|
||
健康检查。
|
||
|
||
```yaml
|
||
check:
|
||
|
||
interval: 10s
|
||
|
||
timeout: 2s
|
||
|
||
retry: 2
|
||
|
||
retryInterval: 500ms
|
||
|
||
urls:
|
||
|
||
- http://connect.rom.miui.com/generate_204
|
||
|
||
- https://www.baidu.com/
|
||
```
|
||
|
||
---
|
||
|
||
# Runtime State(非配置)
|
||
|
||
运行期间每个 Upstream 内部维护:
|
||
|
||
```text
|
||
Healthy
|
||
|
||
Checking
|
||
|
||
Fetching
|
||
|
||
Reserved
|
||
|
||
Expired
|
||
```
|
||
|
||
调度器仅从:
|
||
|
||
```text
|
||
Healthy
|
||
```
|
||
|
||
选择代理。
|
||
|
||
---
|
||
|
||
# Scheduling
|
||
|
||
代理选择流程:
|
||
|
||
```text
|
||
Request
|
||
│
|
||
▼
|
||
Routing
|
||
│
|
||
▼
|
||
Select Upstream
|
||
│
|
||
▼
|
||
Healthy Proxy
|
||
│
|
||
▼
|
||
Capacity Check
|
||
│
|
||
▼
|
||
Allocate
|
||
```
|
||
|
||
---
|
||
|
||
# Fetch Flow
|
||
|
||
```text
|
||
Pool Need Proxy
|
||
│
|
||
▼
|
||
Check requestInterval
|
||
│
|
||
▼
|
||
Acquire Fetch Slot
|
||
│
|
||
▼
|
||
Call Provider API
|
||
│
|
||
▼
|
||
Template Parse
|
||
│
|
||
▼
|
||
Health Check
|
||
│
|
||
▼
|
||
Join Healthy Pool
|
||
```
|
||
|
||
---
|
||
|
||
# Health Flow
|
||
|
||
```text
|
||
Healthy
|
||
│
|
||
Periodic Check
|
||
│
|
||
├────Success──────────────┐
|
||
│ │
|
||
▼ │
|
||
Keep Healthy │
|
||
│
|
||
Failure │
|
||
│ │
|
||
Retry │
|
||
│ │
|
||
Still Fail │
|
||
▼ │
|
||
Remove Proxy──────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
# Complete Example
|
||
|
||
```yaml
|
||
version: 1
|
||
|
||
defaults:
|
||
|
||
fetch:
|
||
|
||
timeout: 5s
|
||
|
||
retry: 3
|
||
|
||
check:
|
||
|
||
interval: 10s
|
||
|
||
timeout: 2s
|
||
|
||
retry: 2
|
||
|
||
urls:
|
||
|
||
- http://connect.rom.miui.com/generate_204
|
||
|
||
routing:
|
||
|
||
- name: JD
|
||
|
||
match:
|
||
|
||
hostRegex: '(^|\.)jd\.com$'
|
||
|
||
upstreams:
|
||
|
||
- jd-primary
|
||
|
||
- jd-backup
|
||
|
||
strategy: failover
|
||
|
||
- name: Default
|
||
|
||
match:
|
||
|
||
hostRegex: '.*'
|
||
|
||
action: direct
|
||
|
||
upstreams:
|
||
|
||
jd-primary:
|
||
|
||
provider:
|
||
|
||
billingMode: usage
|
||
|
||
protocol:
|
||
|
||
- http
|
||
|
||
- https
|
||
|
||
api:
|
||
|
||
url: https://provider-a/api
|
||
|
||
template: |
|
||
{{$x := regexFind "\d{1,3}(\.\d{1,3}){3}:\d{2,5}" .}}
|
||
{{printf "http://%s" $x}}
|
||
|
||
pool:
|
||
|
||
maxSize: 100
|
||
|
||
shrinkDelay: 60s
|
||
|
||
capacity:
|
||
|
||
maxConcurrencyPerProxy: 10
|
||
|
||
lifecycle:
|
||
|
||
ttl: 120s
|
||
|
||
allocationSafetyMargin: 15s
|
||
|
||
fetch:
|
||
|
||
requestInterval: 1s
|
||
|
||
maxInFlight: 1
|
||
|
||
check:
|
||
|
||
urls:
|
||
|
||
- http://connect.rom.miui.com/generate_204
|
||
```
|
||
我会再改两个地方(我认为这是 V2 最值得做的)
|
||
① pool 可以改名为 policy
|
||
|
||
因为它配置的是策略,不仅仅是数量。
|
||
|
||
以后可以自然加入:
|
||
|
||
policy:
|
||
maxSize: 100
|
||
shrinkDelay: 60s
|
||
selection: leastLoad
|
||
② provider 和 api 可以合并
|
||
|
||
实际上一个 upstream 就对应一个代理供应商,大多数情况下:
|
||
|
||
provider:
|
||
billingMode: usage
|
||
protocol: [http]
|
||
|
||
api:
|
||
url: ...
|
||
|
||
完全可以整合为:
|
||
|
||
provider:
|
||
billingMode: usage
|
||
protocol: [http]
|
||
|
||
api:
|
||
url: ...
|
||
method: GET
|
||
template: ...
|
||
|
||
这样 provider 同时描述供应商能力和供应商接口,结构会更紧凑,也更符合语义:
|
||
|
||
一个 Upstream = 一个 Provider + 一套运行策略。
|
||
|
||
我个人更倾向于这个版本作为最终配置架构。
|
||
|
||
对,配置存在 ≠ 默认启用。这里最好把两个概念彻底拆开:
|
||
|
||
enabled:这个上游代理商是否参与运行
|
||
routing / strategy:启用后,按什么顺序参与调度
|
||
|
||
建议把启停状态放在每个 upstream 自身:
|
||
|
||
upstreams:
|
||
provider-a:
|
||
enabled: true
|
||
|
||
provider-b:
|
||
enabled: false
|
||
|
||
这样:
|
||
|
||
enabled: false
|
||
|
||
表示它仍然保留在配置里,但:
|
||
|
||
不拉取代理
|
||
不做健康检查
|
||
不参与路由
|
||
不占用运行资源
|
||
可以随时重新启用
|
||
上游代理商运行状态
|
||
|
||
除了配置里的 enabled,运行时还需要状态:
|
||
|
||
DISABLED
|
||
READY
|
||
DEGRADED
|
||
EXHAUSTED
|
||
COOLDOWN
|
||
ERROR
|
||
|
||
含义可以定义为:
|
||
|
||
状态 含义
|
||
DISABLED 配置禁用
|
||
READY 正常可用
|
||
DEGRADED 可用,但容量不足或错误率较高
|
||
EXHAUSTED 额度、IP 数量或套餐已耗尽
|
||
COOLDOWN 因 429、限流或连续错误暂时停用
|
||
ERROR 配置错误或长期无法连接
|
||
|
||
真正可被调度的条件:
|
||
|
||
enabled == true
|
||
&& runtimeState in [READY, DEGRADED]
|
||
&& availableCapacity > 0
|
||
自动切换到下一个代理商
|
||
|
||
这其实是一种明确的调度策略,建议叫:
|
||
|
||
strategy: sequentialFailover
|
||
|
||
或者简单一点:
|
||
|
||
strategy: failover
|
||
|
||
例如:
|
||
|
||
routing:
|
||
- name: jd
|
||
match:
|
||
hostRegex: '(^|\.)jd\.com$'
|
||
|
||
upstreams:
|
||
- provider-a
|
||
- provider-b
|
||
- provider-c
|
||
|
||
strategy: failover
|
||
|
||
语义:
|
||
|
||
优先使用 provider-a
|
||
|
||
provider-a 不可用或耗尽
|
||
↓
|
||
切换 provider-b
|
||
|
||
provider-b 不可用或耗尽
|
||
↓
|
||
切换 provider-c
|
||
什么叫“耗尽”
|
||
|
||
这里必须定义清楚,不能只看“当前没有可用 IP”。
|
||
|
||
建议把耗尽拆成几种:
|
||
|
||
quotaExhausted
|
||
fetchLimitReached
|
||
noAvailableProxy
|
||
providerReturnedExhausted
|
||
budgetReached
|
||
|
||
例如代理商 API 返回:
|
||
|
||
{
|
||
"code": "QUOTA_EXHAUSTED"
|
||
}
|
||
|
||
就可以把运行状态设为:
|
||
|
||
EXHAUSTED
|
||
|
||
如果只是暂时没拉到 IP:
|
||
|
||
DEGRADED
|
||
|
||
或者:
|
||
|
||
COOLDOWN
|
||
|
||
不要误判为永久耗尽。
|
||
|
||
建议增加 exhaustedPolicy
|
||
|
||
每个上游代理商可以定义自己耗尽后的行为:
|
||
|
||
upstreams:
|
||
provider-a:
|
||
enabled: true
|
||
|
||
exhaustion:
|
||
mode: auto
|
||
retryAfter: 10m
|
||
|
||
支持:
|
||
|
||
auto
|
||
manual
|
||
dailyReset
|
||
monthlyReset
|
||
responseDriven
|
||
|
||
例如:
|
||
|
||
exhaustion:
|
||
mode: dailyReset
|
||
resetAt: "00:00"
|
||
timezone: Asia/Shanghai
|
||
|
||
或者:
|
||
|
||
exhaustion:
|
||
mode: responseDriven
|
||
retryAfter: 30m
|
||
|
||
含义是上游明确返回“额度耗尽”后,30 分钟再尝试。
|
||
|
||
需要区分“切换”和“回切”
|
||
|
||
这是非常重要的一点。
|
||
|
||
假设:
|
||
|
||
provider-a 耗尽
|
||
↓
|
||
切换 provider-b
|
||
|
||
后来 provider-a 又恢复了,是不是立刻切回?
|
||
|
||
建议在路由规则里配置:
|
||
|
||
failover:
|
||
fallbackMode: sticky
|
||
recoveryCheckInterval: 5m
|
||
switchBack: true
|
||
switchBackDelay: 2m
|
||
|
||
支持两种模式:
|
||
|
||
自动回切
|
||
switchBack: true
|
||
|
||
主代理商恢复后,等待一段时间再切回。
|
||
|
||
不自动回切
|
||
switchBack: false
|
||
|
||
切到下一个以后,继续使用,直到人工干预或当前代理商也耗尽。
|
||
|
||
这个模式适合“按顺序消耗套餐”。
|
||
|
||
你说的“一个用完再换下一个”
|
||
|
||
这个不是普通 failover,而是更像:
|
||
|
||
顺序消费
|
||
|
||
我建议单独定义策略:
|
||
|
||
strategy: sequential
|
||
|
||
例如:
|
||
|
||
routing:
|
||
- name: jd
|
||
match:
|
||
hostRegex: '(^|\.)jd\.com$'
|
||
|
||
upstreams:
|
||
- provider-a
|
||
- provider-b
|
||
- provider-c
|
||
|
||
strategy: sequential
|
||
|
||
sequential:
|
||
switchOn:
|
||
- exhausted
|
||
- disabled
|
||
- error
|
||
|
||
switchBack: false
|
||
|
||
含义:
|
||
|
||
始终只使用列表中的一个代理商
|
||
|
||
provider-a 用完后切 provider-b
|
||
|
||
provider-b 用完后切 provider-c
|
||
|
||
默认不自动切回
|
||
|
||
这和 failover 有区别:
|
||
|
||
策略 说明
|
||
failover 主代理商暂时异常时切备用,恢复后可以回切
|
||
sequential 当前代理商消耗完后永久推进到下一个
|
||
loadBalance 多个代理商同时使用
|
||
weighted 按权重分配
|
||
leastLoad 选择负载最低的
|
||
推荐的完整结构
|
||
routing:
|
||
- name: jd
|
||
match:
|
||
hostRegex: '(^|\.)jd\.com$'
|
||
|
||
upstreams:
|
||
- provider-a
|
||
- provider-b
|
||
- provider-c
|
||
|
||
strategy: sequential
|
||
|
||
sequential:
|
||
switchOn:
|
||
- exhausted
|
||
- disabled
|
||
- unavailable
|
||
|
||
switchBack: false
|
||
|
||
onAllUnavailable: reject
|
||
|
||
upstreams:
|
||
provider-a:
|
||
enabled: true
|
||
priority: 10
|
||
|
||
provider:
|
||
billingMode: fetch
|
||
|
||
exhaustion:
|
||
detection:
|
||
httpStatus:
|
||
- 402
|
||
- 429
|
||
responseCodes:
|
||
- QUOTA_EXHAUSTED
|
||
- NO_BALANCE
|
||
|
||
retryAfter: 1h
|
||
|
||
provider-b:
|
||
enabled: true
|
||
priority: 20
|
||
|
||
provider:
|
||
billingMode: usage
|
||
|
||
exhaustion:
|
||
retryAfter: 10m
|
||
|
||
provider-c:
|
||
enabled: false
|
||
onAllUnavailable
|
||
|
||
所有上游都不可用时,也必须明确:
|
||
|
||
onAllUnavailable: reject
|
||
|
||
支持:
|
||
|
||
reject
|
||
direct
|
||
wait
|
||
|
||
例如:
|
||
|
||
onAllUnavailable: wait
|
||
waitTimeout: 5s
|
||
|
||
不要默认直连,尤其代理是业务强约束时。
|
||
|
||
最终建议
|
||
|
||
配置里加这几个字段就够了:
|
||
|
||
upstreams:
|
||
provider-a:
|
||
enabled: true
|
||
|
||
路由里加:
|
||
|
||
strategy: sequential
|
||
|
||
以及:
|
||
|
||
sequential:
|
||
switchOn:
|
||
- exhausted
|
||
- disabled
|
||
- unavailable
|
||
switchBack: false
|
||
|
||
这样就能同时解决:
|
||
|
||
配置了但不启用
|
||
手动禁用某个代理商
|
||
当前代理商额度耗尽后自动切换
|
||
控制是否恢复后自动切回
|
||
所有代理商不可用时如何处理
|
||
|
||
其中最关键的是:“暂时不可用”和“真正耗尽”必须分开判断,否则上游偶尔超时一次,系统就会错误地永久切到下一家。
|
||
|
||
我反而不建议把"运行状态"和"判定条件"绑死。
|
||
|
||
你说的这个:
|
||
|
||
连续多少次获取这个上游代理商都没有 IP 返回了
|
||
|
||
它其实是一种判定规则(Detection),而不是状态本身。
|
||
|
||
例如:
|
||
|
||
API 连续返回空IP
|
||
│
|
||
▼
|
||
判定:
|
||
NoAvailableProxy
|
||
│
|
||
▼
|
||
Runtime State = EXHAUSTED
|
||
|
||
这样以后就可以扩展很多种判定方式,而不是只有一种。
|
||
|
||
我建议把运行状态保持简单
|
||
|
||
例如:
|
||
|
||
DISABLED
|
||
READY
|
||
UNAVAILABLE
|
||
EXHAUSTED
|
||
|
||
其中:
|
||
|
||
READY
|
||
|
||
正常工作。
|
||
|
||
UNAVAILABLE
|
||
|
||
临时不可用,例如:
|
||
|
||
API 超时
|
||
HTTP 500
|
||
网络异常
|
||
健康检查失败
|
||
被限流(429)
|
||
|
||
这种状态是短暂的。
|
||
|
||
EXHAUSTED
|
||
|
||
真正没有资源了。
|
||
|
||
例如:
|
||
|
||
连续 5 次获取都返回空
|
||
API 返回"余额不足"
|
||
API 返回"套餐已用完"
|
||
API 返回"今日额度已用完"
|
||
|
||
这种状态通常需要等待恢复。
|
||
|
||
然后增加 Detection
|
||
|
||
例如:
|
||
|
||
upstreams:
|
||
|
||
provider-a:
|
||
|
||
exhaustion:
|
||
|
||
consecutiveEmptyFetch: 5
|
||
|
||
retryAfter: 10m
|
||
|
||
表示:
|
||
|
||
连续5次获取代理
|
||
|
||
↓
|
||
|
||
全部返回0个IP
|
||
|
||
↓
|
||
|
||
RuntimeState = EXHAUSTED
|
||
|
||
↓
|
||
|
||
10分钟后再尝试
|
||
|
||
这个就比写死在代码里灵活。
|
||
|
||
以后还能增加
|
||
|
||
例如:
|
||
|
||
exhaustion:
|
||
|
||
consecutiveEmptyFetch: 5
|
||
|
||
consecutiveErrors: 20
|
||
|
||
responseCodes:
|
||
|
||
- NO_BALANCE
|
||
|
||
- QUOTA_EXHAUSTED
|
||
|
||
httpStatus:
|
||
|
||
- 402
|
||
|
||
- 403
|
||
|
||
任意满足即可:
|
||
|
||
余额不足
|
||
│
|
||
├────────────┐
|
||
│ │
|
||
HTTP402 连续5次空IP
|
||
│ │
|
||
└──────┬─────┘
|
||
▼
|
||
EXHAUSTED
|
||
还有一种我觉得很有必要
|
||
|
||
有的代理商不会返回:
|
||
|
||
没有IP
|
||
|
||
而是一直返回:
|
||
|
||
success
|
||
|
||
但是:
|
||
|
||
IPA
|
||
|
||
一直重复。
|
||
|
||
例如:
|
||
|
||
1.1.1.1
|
||
|
||
1.1.1.1
|
||
|
||
1.1.1.1
|
||
|
||
1.1.1.1
|
||
|
||
实际上说明:
|
||
|
||
代理池已经没有新的 IP 可以给你了。
|
||
|
||
所以我还会加:
|
||
|
||
exhaustion:
|
||
|
||
consecutiveDuplicateFetch: 20
|
||
|
||
表示:
|
||
|
||
连续20次获取
|
||
|
||
全部都是重复IP
|
||
|
||
↓
|
||
|
||
认为代理池耗尽
|
||
或者直接做成 Detection 规则
|
||
|
||
以后可以扩展任何条件:
|
||
|
||
upstreams:
|
||
|
||
provider-a:
|
||
|
||
exhaustion:
|
||
|
||
detect:
|
||
|
||
consecutiveEmptyFetch: 5
|
||
|
||
consecutiveDuplicateFetch: 20
|
||
|
||
responseCodes:
|
||
|
||
- NO_BALANCE
|
||
|
||
- QUOTA_EXHAUSTED
|
||
|
||
httpStatus:
|
||
|
||
- 402
|
||
|
||
retryAfter: 30m
|
||
|
||
这样:
|
||
|
||
连续空 IP
|
||
连续重复 IP
|
||
返回余额不足
|
||
返回额度耗尽
|
||
|
||
都可以进入:
|
||
|
||
EXHAUSTED
|
||
|
||
代码里就不用写一堆 if provider == xxx 了。
|
||
|
||
我还有一个建议
|
||
|
||
如果你的系统以后支持很多代理商,我甚至不会把这个叫 EXHAUSTED(耗尽)。
|
||
|
||
因为"连续获取不到 IP"并不一定是真的耗尽,有可能只是:
|
||
|
||
当前地区没有 IP
|
||
当前运营商没有 IP
|
||
当前时间段库存为空
|
||
上游正在补货
|
||
|
||
所以更准确一点可以叫:
|
||
|
||
DEPLETED
|
||
|
||
或者:
|
||
|
||
NO_RESOURCE
|
||
|
||
意思是:
|
||
|
||
当前没有可分配资源。
|
||
|
||
然后由 retryAfter 或定时探测决定什么时候恢复,而不是认为它已经永久不可用。
|
||
|
||
我个人更倾向于 DEPLETED,因为它表达的是资源暂时被消耗完,比 EXHAUSTED 更符合代理池这种会周期性恢复库存的场景。
|
||
|
||
我反而觉得你这个思路更符合大多数人的使用习惯。
|
||
|
||
很多代理商实际上根本不会告诉你:
|
||
|
||
额度是否耗尽
|
||
套餐是否用完
|
||
什么时候恢复
|
||
|
||
它只会一直返回:
|
||
|
||
没有可用IP
|
||
|
||
所以没必要搞那么复杂。
|
||
|
||
我建议就设计一个简单的切换策略
|
||
|
||
例如:
|
||
|
||
routing:
|
||
- match:
|
||
hostRegex: '(^|\.)jd\.com$'
|
||
|
||
upstreams:
|
||
- provider-a
|
||
- provider-b
|
||
- provider-c
|
||
|
||
strategy: sequential
|
||
|
||
switchAfterEmptyFetch: 5
|
||
|
||
意思就是:
|
||
|
||
当前代理商连续 5 次获取不到 IP,就切换到下一个代理商。
|
||
|
||
运行流程
|
||
|
||
例如:
|
||
|
||
provider-a
|
||
|
||
第1次 获取
|
||
↓
|
||
|
||
0 IP
|
||
(empty=1)
|
||
|
||
第2次
|
||
↓
|
||
|
||
0 IP
|
||
(empty=2)
|
||
|
||
第3次
|
||
↓
|
||
|
||
获取成功
|
||
(empty=0)
|
||
|
||
第4次
|
||
↓
|
||
|
||
0 IP
|
||
(empty=1)
|
||
|
||
只要获取成功一次,计数就清零。
|
||
|
||
如果:
|
||
|
||
provider-a
|
||
|
||
0IP
|
||
(empty=1)
|
||
|
||
0IP
|
||
(empty=2)
|
||
|
||
0IP
|
||
(empty=3)
|
||
|
||
0IP
|
||
(empty=4)
|
||
|
||
0IP
|
||
(empty=5)
|
||
|
||
达到:
|
||
|
||
switchAfterEmptyFetch: 5
|
||
|
||
立即:
|
||
|
||
provider-a
|
||
│
|
||
▼
|
||
标记跳过
|
||
│
|
||
▼
|
||
切换 provider-b
|
||
什么时候再回来?
|
||
|
||
这个也不用复杂。
|
||
|
||
两种方式即可。
|
||
|
||
第一种:永远不回来(推荐)
|
||
strategy: sequential
|
||
|
||
就是:
|
||
|
||
A
|
||
↓
|
||
|
||
没IP
|
||
|
||
↓
|
||
|
||
B
|
||
↓
|
||
|
||
没IP
|
||
|
||
↓
|
||
|
||
C
|
||
|
||
一直往后走。
|
||
|
||
直到:
|
||
|
||
用户重启
|
||
用户手动切换
|
||
用户重新加载配置
|
||
|
||
很多人就是这么用套餐的。
|
||
|
||
第二种:循环
|
||
strategy: roundRobin
|
||
|
||
例如:
|
||
|
||
A
|
||
|
||
↓
|
||
|
||
没IP
|
||
|
||
↓
|
||
|
||
B
|
||
|
||
↓
|
||
|
||
没IP
|
||
|
||
↓
|
||
|
||
C
|
||
|
||
↓
|
||
|
||
没IP
|
||
|
||
↓
|
||
|
||
A
|
||
|
||
不断循环。
|
||
|
||
我觉得可以把这个配置放到 strategy 下面
|
||
|
||
例如:
|
||
|
||
routing:
|
||
- match:
|
||
hostRegex: '(^|\.)jd\.com$'
|
||
|
||
upstreams:
|
||
- provider-a
|
||
- provider-b
|
||
- provider-c
|
||
|
||
strategy:
|
||
type: sequential
|
||
|
||
switchAfterEmptyFetch: 5
|
||
|
||
以后如果要扩展:
|
||
|
||
strategy:
|
||
type: sequential
|
||
|
||
switchAfterEmptyFetch: 5
|
||
|
||
switchAfterErrors: 10
|
||
|
||
也不会破坏结构。
|
||
|
||
每个 Upstream 不需要状态
|
||
|
||
实际上只维护一个运行时变量就够了:
|
||
|
||
type UpstreamRuntime struct {
|
||
EmptyFetchCount int
|
||
}
|
||
|
||
逻辑非常简单:
|
||
|
||
if fetchSuccess {
|
||
EmptyFetchCount = 0
|
||
}
|
||
|
||
if fetchResult == 0 {
|
||
EmptyFetchCount++
|
||
}
|
||
|
||
if EmptyFetchCount >= SwitchAfterEmptyFetch {
|
||
SwitchNextProvider()
|
||
}
|
||
|
||
不需要什么 READY、EXHAUSTED、DEGRADED 等状态机。
|
||
|
||
我还建议增加一个很实用的小配置
|
||
|
||
因为有些代理商偶尔会返回空 IP,所以可以加一个冷却时间:
|
||
|
||
strategy:
|
||
type: sequential
|
||
|
||
switchAfterEmptyFetch: 5
|
||
|
||
retryCurrentAfter: 30m
|
||
|
||
意思是:
|
||
|
||
provider-a
|
||
↓
|
||
|
||
连续5次没IP
|
||
↓
|
||
|
||
切到provider-b
|
||
↓
|
||
|
||
30分钟后再尝试provider-a
|
||
|
||
如果还是没 IP,再继续使用 provider-b。
|
||
|
||
这样既简单,又避免某个代理商恢复后永远不会再被使用。
|
||
|
||
我觉得整个配置只保留这两个参数就够了:
|
||
|
||
strategy:
|
||
type: sequential
|
||
switchAfterEmptyFetch: 5
|
||
|
||
这是最符合直觉、实现成本也最低的方案。
|
||
|
||
我会按开源项目的设计文档来写,而不是用户使用文档。重点描述为什么这么设计、配置项的含义以及运行逻辑。下面是一个第一版实施文档示例。
|
||
|
||
# Proxy Pool 实施方案
|
||
|
||
## 一、项目简介
|
||
|
||
Proxy Pool 是一个支持多代理供应商(Upstream)的代理池管理组件。
|
||
|
||
主要功能:
|
||
|
||
- 多代理供应商管理
|
||
- 按规则自动路由
|
||
- 自动维护代理池
|
||
- 自动健康检测
|
||
- 自动切换代理供应商
|
||
- 支持 HTTP / HTTPS / SOCKS5(后续扩展)
|
||
- 支持固定 IP、动态 IP 等不同代理类型
|
||
|
||
---
|
||
|
||
## 二、设计目标
|
||
|
||
### 1、解耦
|
||
|
||
整个系统拆分为两部分:
|
||
|
||
```
|
||
Request
|
||
│
|
||
▼
|
||
Routing Rule
|
||
│
|
||
▼
|
||
Select Upstream
|
||
│
|
||
▼
|
||
Proxy Pool
|
||
│
|
||
▼
|
||
Provider API
|
||
```
|
||
|
||
Routing 只负责:
|
||
|
||
> 请求应该走哪个代理池。
|
||
|
||
Proxy Pool 负责:
|
||
|
||
> 如何维护代理。
|
||
|
||
Provider 负责:
|
||
|
||
> 如何获取代理。
|
||
|
||
三者互不影响。
|
||
|
||
---
|
||
|
||
### 2、多供应商支持
|
||
|
||
一个代理池可以配置多个代理供应商。
|
||
|
||
例如:
|
||
|
||
```
|
||
京东
|
||
|
||
Provider A
|
||
Provider B
|
||
Provider C
|
||
```
|
||
|
||
也可以:
|
||
|
||
```
|
||
Github
|
||
|
||
Provider D
|
||
```
|
||
|
||
Routing 不关心代理来源。
|
||
|
||
只关心使用哪个 Upstream。
|
||
|
||
---
|
||
|
||
## 三、配置结构
|
||
|
||
```
|
||
config.yaml
|
||
|
||
├── defaults
|
||
├── routing
|
||
└── upstreams
|
||
```
|
||
|
||
说明:
|
||
|
||
|节点|说明|
|
||
|--------|------------|
|
||
|defaults|全局默认配置|
|
||
|routing|请求路由规则|
|
||
|upstreams|代理供应商配置|
|
||
|
||
---
|
||
|
||
## 四、Routing
|
||
|
||
Routing 用于决定:
|
||
|
||
> 一个请求应该使用哪个 Upstream。
|
||
|
||
例如:
|
||
|
||
```yaml
|
||
routing:
|
||
|
||
- name: JD
|
||
|
||
match:
|
||
|
||
hostRegex: '(^|\.)jd\.com$'
|
||
|
||
upstreams:
|
||
|
||
- jd-a
|
||
|
||
- jd-b
|
||
|
||
- jd-c
|
||
|
||
strategy:
|
||
|
||
type: sequential
|
||
|
||
switchAfterEmptyFetch: 5
|
||
```
|
||
|
||
---
|
||
|
||
### 匹配方式
|
||
|
||
目前支持:
|
||
|
||
```yaml
|
||
match:
|
||
|
||
hostRegex:
|
||
```
|
||
|
||
后续可扩展:
|
||
|
||
```
|
||
method
|
||
|
||
path
|
||
|
||
header
|
||
|
||
clientIP
|
||
```
|
||
|
||
---
|
||
|
||
## 五、Upstream
|
||
|
||
每一个 Upstream 表示一个代理供应商。
|
||
|
||
例如:
|
||
|
||
```yaml
|
||
upstreams:
|
||
|
||
jd-a:
|
||
|
||
enabled: true
|
||
|
||
api:
|
||
|
||
fetch:
|
||
|
||
check:
|
||
|
||
lifecycle:
|
||
|
||
capacity:
|
||
```
|
||
|
||
---
|
||
|
||
## 六、启用状态
|
||
|
||
配置存在,不代表启用。
|
||
|
||
```yaml
|
||
enabled: true
|
||
```
|
||
|
||
表示参与运行。
|
||
|
||
```yaml
|
||
enabled: false
|
||
```
|
||
|
||
表示:
|
||
|
||
- 不获取代理
|
||
- 不健康检查
|
||
- 不参与路由
|
||
|
||
仅保留配置。
|
||
|
||
---
|
||
|
||
## 七、代理获取
|
||
|
||
代理通过 API 获取。
|
||
|
||
例如:
|
||
|
||
```yaml
|
||
api:
|
||
|
||
url: https://xxx/api
|
||
|
||
template: |
|
||
{{$x := regexFind "\d{1,3}(\.\d{1,3}){3}:\d{2,5}" .}}
|
||
{{printf "http://%s" $x}}
|
||
```
|
||
|
||
---
|
||
|
||
## 八、获取控制
|
||
|
||
```yaml
|
||
fetch:
|
||
|
||
requestInterval: 1s
|
||
|
||
retry: 5
|
||
|
||
timeout: 3s
|
||
|
||
maxInFlight: 1
|
||
```
|
||
|
||
参数说明:
|
||
|
||
|参数|说明|
|
||
|------|------|
|
||
|requestInterval|两次请求 API 最小时间间隔|
|
||
|retry|获取失败最大重试次数|
|
||
|timeout|API 超时时间|
|
||
|maxInFlight|同时最多几个获取任务|
|
||
|
||
---
|
||
|
||
## 九、健康检查
|
||
|
||
```yaml
|
||
check:
|
||
|
||
urls:
|
||
|
||
- http://connect.rom.miui.com/generate_204
|
||
|
||
interval: 10s
|
||
|
||
timeout: 2s
|
||
|
||
retry: 2
|
||
```
|
||
|
||
健康检查失败:
|
||
|
||
```
|
||
重新检测
|
||
|
||
↓
|
||
|
||
仍失败
|
||
|
||
↓
|
||
|
||
移出代理池
|
||
```
|
||
|
||
---
|
||
|
||
## 十、生命周期
|
||
|
||
```yaml
|
||
lifecycle:
|
||
|
||
ttl: 120s
|
||
|
||
allocationSafetyMargin: 10s
|
||
```
|
||
|
||
说明:
|
||
|
||
```
|
||
TTL
|
||
|
||
代理生命周期
|
||
```
|
||
|
||
```
|
||
SafetyMargin
|
||
|
||
距离过期多少秒停止分配新请求
|
||
```
|
||
|
||
---
|
||
|
||
## 十一、容量控制
|
||
|
||
```yaml
|
||
capacity:
|
||
|
||
maxConcurrencyPerProxy: 10
|
||
```
|
||
|
||
表示:
|
||
|
||
一个代理同时允许多少请求。
|
||
|
||
达到上限以后:
|
||
|
||
不再继续分配。
|
||
|
||
---
|
||
|
||
## 十二、供应商切换
|
||
|
||
当一个 Routing 配置多个 Upstream 时:
|
||
|
||
```yaml
|
||
upstreams:
|
||
|
||
- jd-a
|
||
|
||
- jd-b
|
||
|
||
- jd-c
|
||
```
|
||
|
||
系统按照 Strategy 调度。
|
||
|
||
目前支持:
|
||
|
||
```
|
||
sequential
|
||
|
||
random
|
||
|
||
roundRobin
|
||
|
||
leastConnections
|
||
|
||
weighted
|
||
```
|
||
|
||
---
|
||
|
||
### Sequential
|
||
|
||
Sequential 表示:
|
||
|
||
按顺序使用代理供应商。
|
||
|
||
例如:
|
||
|
||
```
|
||
jd-a
|
||
|
||
↓
|
||
|
||
jd-b
|
||
|
||
↓
|
||
|
||
jd-c
|
||
```
|
||
|
||
默认始终使用第一个。
|
||
|
||
---
|
||
|
||
### 自动切换
|
||
|
||
```yaml
|
||
strategy:
|
||
|
||
type: sequential
|
||
|
||
switchAfterEmptyFetch: 5
|
||
```
|
||
|
||
表示:
|
||
|
||
连续 5 次获取不到代理。
|
||
|
||
自动切换到下一个 Upstream。
|
||
|
||
例如:
|
||
|
||
```
|
||
jd-a
|
||
|
||
↓
|
||
|
||
第1次
|
||
|
||
无IP
|
||
|
||
↓
|
||
|
||
第2次
|
||
|
||
无IP
|
||
|
||
↓
|
||
|
||
第3次
|
||
|
||
无IP
|
||
|
||
↓
|
||
|
||
第4次
|
||
|
||
无IP
|
||
|
||
↓
|
||
|
||
第5次
|
||
|
||
无IP
|
||
|
||
↓
|
||
|
||
切换 jd-b
|
||
```
|
||
|
||
只要成功获取一次代理。
|
||
|
||
计数立即清零。
|
||
|
||
例如:
|
||
|
||
```
|
||
无IP
|
||
|
||
↓
|
||
|
||
无IP
|
||
|
||
↓
|
||
|
||
获取成功
|
||
|
||
↓
|
||
|
||
计数清零
|
||
```
|
||
|
||
---
|
||
|
||
## 十三、运行流程
|
||
|
||
```
|
||
Request
|
||
│
|
||
▼
|
||
Routing
|
||
│
|
||
▼
|
||
Select Upstream
|
||
│
|
||
▼
|
||
Proxy Pool
|
||
│
|
||
▼
|
||
Allocate Proxy
|
||
│
|
||
▼
|
||
Business Request
|
||
```
|
||
|
||
---
|
||
|
||
## 十四、代理获取流程
|
||
|
||
```
|
||
Pool Need Proxy
|
||
│
|
||
▼
|
||
Request Interval
|
||
│
|
||
▼
|
||
Call API
|
||
│
|
||
▼
|
||
Template Parse
|
||
│
|
||
▼
|
||
Health Check
|
||
│
|
||
▼
|
||
Join Pool
|
||
```
|
||
|
||
---
|
||
|
||
## 十五、切换流程
|
||
|
||
```
|
||
Need Proxy
|
||
│
|
||
▼
|
||
Call Provider API
|
||
│
|
||
├──────────────┐
|
||
│ │
|
||
Get Proxy Empty Result
|
||
│ │
|
||
▼ ▼
|
||
Join Pool Empty Count++
|
||
│
|
||
▼
|
||
>= switchAfterEmptyFetch ?
|
||
│
|
||
Yes ───┘
|
||
│
|
||
▼
|
||
Switch Next Upstream
|
||
```
|
||
|
||
---
|
||
|
||
## 十六、完整示例
|
||
|
||
```yaml
|
||
defaults:
|
||
|
||
check:
|
||
|
||
urls:
|
||
|
||
- http://connect.rom.miui.com/generate_204
|
||
|
||
interval: 10s
|
||
|
||
timeout: 2s
|
||
|
||
retry: 2
|
||
|
||
routing:
|
||
|
||
- name: JD
|
||
|
||
match:
|
||
|
||
hostRegex: '(^|\.)jd\.com$'
|
||
|
||
upstreams:
|
||
|
||
- jd-a
|
||
|
||
- jd-b
|
||
|
||
- jd-c
|
||
|
||
strategy:
|
||
|
||
type: sequential
|
||
|
||
switchAfterEmptyFetch: 5
|
||
|
||
upstreams:
|
||
|
||
jd-a:
|
||
|
||
enabled: true
|
||
|
||
api:
|
||
|
||
url: https://provider-a/api
|
||
|
||
template: |
|
||
{{$x := regexFind "\d{1,3}(\.\d{1,3}){3}:\d{2,5}" .}}
|
||
{{printf "http://%s" $x}}
|
||
|
||
fetch:
|
||
|
||
requestInterval: 1s
|
||
|
||
retry: 5
|
||
|
||
timeout: 3s
|
||
|
||
maxInFlight: 1
|
||
|
||
check:
|
||
|
||
urls:
|
||
|
||
- http://connect.rom.miui.com/generate_204
|
||
|
||
interval: 10s
|
||
|
||
timeout: 2s
|
||
|
||
retry: 2
|
||
|
||
lifecycle:
|
||
|
||
ttl: 120s
|
||
|
||
allocationSafetyMargin: 10s
|
||
|
||
capacity:
|
||
|
||
maxConcurrencyPerProxy: 10
|
||
|
||
jd-b:
|
||
|
||
enabled: true
|
||
|
||
api:
|
||
|
||
url: https://provider-b/api
|
||
|
||
jd-c:
|
||
|
||
enabled: false
|
||
|
||
api:
|
||
|
||
url: https://provider-c/api
|
||
```
|
||
|
||
---
|
||
|
||
## 十七、设计原则
|
||
|
||
- **配置即声明**:配置描述“希望系统如何工作”,而不是实现细节。
|
||
- **职责单一**:Routing 只负责路由,Upstream 只负责代理管理,Provider 只负责代理获取。
|
||
- **按需扩展**:支持多个代理供应商,并可通过策略灵活调度。
|
||
- **自动切换**:连续获取不到代理时,按配置自动切换到下一个 Upstream,无需人工干预。
|
||
- **易于扩展**:后续增加新的调度策略、匹配条件或代理类型,不需要修改整体配置结构。
|
||
|
||
我建议再加一章 「调度策略(Scheduling Strategy)」,把 sequential、random、roundRobin、weighted、leastConnections 等策略分别画成流程图说明。这样文档不仅说明配置怎么写,还能清楚表达系统在不同策略下的运行方式,对后续开发和维护都会更直观。
|
||
|
||
有,而且还不少。你们现在已经把“多上游、路由、按顺序切换、健康检查、生命周期、容量”这些骨架搭起来了,下一步最容易踩坑的是边界条件、并发一致性、失败语义和可观测性。
|
||
|
||
我建议分成“第一版必须补”和“后续优化”两层。
|
||
|
||
第一版必须补的内容
|
||
1. 规则冲突与默认行为
|
||
|
||
现在规则是从上往下匹配,那就必须明确:
|
||
|
||
第一条命中即停止
|
||
|
||
还要定义没有任何规则命中时怎么办:
|
||
|
||
routingDefault:
|
||
action: direct
|
||
|
||
或者:
|
||
|
||
routingDefault:
|
||
action: reject
|
||
|
||
不要让程序自己猜。
|
||
|
||
还要在启动时检测:
|
||
|
||
重复规则
|
||
永远不会命中的规则
|
||
.* 出现在前面导致后续规则失效
|
||
引用了不存在的 upstream
|
||
所有 upstream 都被禁用
|
||
2. enabled 不能只在启动时读取
|
||
|
||
最好支持运行时热更新:
|
||
|
||
enabled: false
|
||
|
||
修改后应做到:
|
||
|
||
停止新代理拉取
|
||
停止分配新请求
|
||
已有连接允许完成
|
||
代理池进入 draining
|
||
最终释放资源
|
||
|
||
不要直接粗暴断开已有连接。
|
||
|
||
3. 顺序切换状态必须持久化
|
||
|
||
例如:
|
||
|
||
provider-a
|
||
→ provider-b
|
||
→ provider-c
|
||
|
||
如果程序重启后又从 provider-a 开始,会重复消耗已经耗尽的供应商。
|
||
|
||
所以至少要保存:
|
||
|
||
当前激活 upstream
|
||
连续空结果次数
|
||
最后切换时间
|
||
|
||
可以放:
|
||
|
||
本地文件
|
||
Redis
|
||
数据库
|
||
|
||
单机可以先用本地持久化,多实例建议放 Redis 或数据库。
|
||
|
||
4. 连续空结果计数必须定义准确
|
||
|
||
“连续多少次获取不到 IP”需要明确什么算空。
|
||
|
||
建议只有下面情况才计数:
|
||
|
||
API 请求成功
|
||
响应解析成功
|
||
最终得到 0 个有效代理
|
||
|
||
这些不应该算空:
|
||
|
||
API 超时
|
||
HTTP 500
|
||
DNS 失败
|
||
模板解析异常
|
||
认证失败
|
||
返回格式变更
|
||
|
||
否则代理商接口偶发故障,也会被误判成“没 IP”。
|
||
|
||
建议分开两个计数:
|
||
|
||
emptyFetchCount
|
||
fetchErrorCount
|
||
|
||
即使第一版不根据错误次数切换,也要至少分开记录。
|
||
|
||
5. 切换时的并发竞争
|
||
|
||
多个协程同时发现:
|
||
|
||
emptyFetchCount >= 5
|
||
|
||
可能同时切换多次:
|
||
|
||
A → B → C
|
||
|
||
本来只该切到 B,结果直接跳到 C。
|
||
|
||
所以切换必须是原子的。
|
||
|
||
逻辑类似:
|
||
|
||
lock()
|
||
if currentUpstream == expected {
|
||
switchNext()
|
||
}
|
||
unlock()
|
||
|
||
或者使用 CAS。
|
||
|
||
6. 当前代理池里还有 IP 时是否切换
|
||
|
||
这是一个很关键的问题。
|
||
|
||
假设:
|
||
|
||
provider-a 连续 5 次拉不到新 IP
|
||
但池里还有 20 个可用 IP
|
||
|
||
应该怎么处理?
|
||
|
||
建议:
|
||
|
||
停止继续从 A 拉取
|
||
已经获取的 A 代理继续用完
|
||
新的补充请求转到 B
|
||
A 进入 draining
|
||
|
||
不要把 A 现有可用代理直接丢掉。
|
||
|
||
这会比“立刻完全切换”节省很多资源。
|
||
|
||
7. 多个 routing 是否共享同一个 upstream
|
||
|
||
例如:
|
||
|
||
routing:
|
||
- name: jd
|
||
upstreams: [provider-a]
|
||
|
||
- name: taobao
|
||
upstreams: [provider-a]
|
||
|
||
那 provider-a 的:
|
||
|
||
maxSize
|
||
并发数
|
||
空结果计数
|
||
切换状态
|
||
|
||
是全局共享,还是每条 routing 独立?
|
||
|
||
建议:
|
||
|
||
upstream 代理池全局共享
|
||
routing 只维护自己的当前选择顺序
|
||
emptyFetchCount 放在 upstream 运行时
|
||
当前激活项放在 routing strategy 运行时
|
||
|
||
否则同一个代理商会被重复维护多个池。
|
||
|
||
8. maxSize 的计数边界
|
||
|
||
必须明确哪些代理算入 maxSize:
|
||
|
||
建议算:
|
||
|
||
checking
|
||
healthy
|
||
busy
|
||
draining
|
||
|
||
不算:
|
||
|
||
expired
|
||
removed
|
||
|
||
正在 fetch 的请求可以单独算:
|
||
|
||
pendingFetch
|
||
|
||
实际限制:
|
||
|
||
currentProxyCount + expectedPending <= maxSize
|
||
|
||
否则多个并发拉取会突破上限。
|
||
|
||
9. API 返回多个 IP 的处理
|
||
|
||
不能假设每次只返回一个。
|
||
|
||
需要明确:
|
||
|
||
返回数量超过剩余容量怎么办
|
||
|
||
建议:
|
||
|
||
去重
|
||
校验格式
|
||
只保留 maxSize 剩余容量以内的代理
|
||
多余代理丢弃并记录指标
|
||
|
||
还要处理:
|
||
|
||
同一个 API 返回重复 IP
|
||
不同供应商返回同一个 IP
|
||
同 IP 不同端口
|
||
同 IP 不同认证信息
|
||
|
||
建议代理唯一键使用:
|
||
|
||
scheme + host + port + username
|
||
|
||
不要只按 IP 去重。
|
||
|
||
10. 代理检测不能只看“能连接”
|
||
|
||
健康检查至少区分:
|
||
|
||
连接成功
|
||
HTTP 响应成功
|
||
目标站可访问
|
||
出口 IP 正确
|
||
|
||
否则可能出现代理能访问百度,但访问目标域名被封。
|
||
|
||
第一版可以简单做两层:
|
||
|
||
check:
|
||
connectivityUrl: http://connect.rom.miui.com/generate_204
|
||
targetUrl: https://目标域名/
|
||
|
||
目标检测失败时,不一定全局删除代理,可以只对该 routing 暂时不可用。
|
||
|
||
11. 请求失败后的代理处理
|
||
|
||
业务请求失败,不应该一律把代理删除。
|
||
|
||
建议区分:
|
||
|
||
连接超时:代理可能有问题
|
||
407:认证错误
|
||
403:可能是目标站封禁
|
||
429:可能是目标站限流
|
||
5xx:可能是目标站问题
|
||
客户端主动取消:不能怪代理
|
||
|
||
至少维护:
|
||
|
||
consecutiveFailures
|
||
lastFailureType
|
||
|
||
不要一次失败就淘汰。
|
||
|
||
12. 是否允许直连必须明确配置
|
||
|
||
所有代理都不可用时:
|
||
|
||
onUnavailable:
|
||
action: reject
|
||
|
||
可选:
|
||
|
||
reject
|
||
direct
|
||
wait
|
||
|
||
不要默认直连。
|
||
|
||
尤其是必须隐藏真实出口 IP 的业务,默认直连很危险。
|
||
|
||
建议优化的配置结构
|
||
|
||
你们现在的结构可以再整理一下:
|
||
|
||
version: 1
|
||
|
||
defaults:
|
||
fetch:
|
||
timeout: 3s
|
||
retry: 3
|
||
|
||
check:
|
||
interval: 10s
|
||
timeout: 2s
|
||
retry: 2
|
||
|
||
routing:
|
||
- name: jd
|
||
enabled: true
|
||
|
||
match:
|
||
hostRegex: '(^|\.)jd\.com$'
|
||
|
||
upstreams:
|
||
- provider-a
|
||
- provider-b
|
||
|
||
strategy:
|
||
type: sequential
|
||
switchAfterEmptyFetch: 5
|
||
|
||
onUnavailable:
|
||
action: reject
|
||
|
||
upstreams:
|
||
provider-a:
|
||
enabled: true
|
||
|
||
api:
|
||
url: https://provider-a.example/api
|
||
method: GET
|
||
template: |
|
||
...
|
||
|
||
pool:
|
||
maxSize: 100
|
||
|
||
capacity:
|
||
maxConcurrencyPerProxy: 10
|
||
|
||
lifecycle:
|
||
ttl: 120s
|
||
allocationSafetyMargin: 10s
|
||
|
||
fetch:
|
||
requestInterval: 1s
|
||
maxInFlight: 1
|
||
|
||
check:
|
||
interval: 10s
|
||
|
||
这里建议 routing 也增加:
|
||
|
||
enabled: true
|
||
|
||
因为有时候不是禁用代理商,而是临时禁用整条业务规则。
|
||
|
||
后续值得做的优化
|
||
1. 延迟拉取
|
||
|
||
不要启动时就拉代理。
|
||
|
||
只有满足下面条件才拉:
|
||
|
||
请求到来
|
||
且没有足够可用容量
|
||
|
||
对于使用计费型供应商,可以提前少量预热。
|
||
|
||
2. 请求合并
|
||
|
||
大量请求同时发现没代理时,不要每个请求都触发一次拉取。
|
||
|
||
应该合并为一个 fetch 任务:
|
||
|
||
100 个请求等待
|
||
↓
|
||
只触发 1 次或少量 API 获取
|
||
↓
|
||
结果广播给等待者
|
||
|
||
这叫 singleflight,很值得做。
|
||
|
||
3. 负载感知
|
||
|
||
不要只按 IP 数量判断是否需要拉取。
|
||
|
||
应该看:
|
||
|
||
可用并发槽位
|
||
|
||
例如:
|
||
|
||
有 10 个 IP
|
||
但全部满载
|
||
|
||
实际上仍然需要拉新代理。
|
||
|
||
4. 防抖
|
||
|
||
流量波动时,避免不停:
|
||
|
||
拉取
|
||
释放
|
||
拉取
|
||
释放
|
||
|
||
可以增加:
|
||
|
||
pool:
|
||
idleReleaseDelay: 60s
|
||
|
||
不过对于有固定 TTL 的代理,通常直接让它自然过期更省事。
|
||
|
||
5. 切换冷却
|
||
|
||
连续空结果切换后,不要马上又尝试旧代理商。
|
||
|
||
可以保留一个简单参数:
|
||
|
||
strategy:
|
||
type: sequential
|
||
switchAfterEmptyFetch: 5
|
||
switchCooldown: 10m
|
||
|
||
不是必须,但很实用。
|
||
|
||
6. 最后一家用完后怎么办
|
||
|
||
顺序策略必须定义:
|
||
|
||
strategy:
|
||
type: sequential
|
||
switchAfterEmptyFetch: 5
|
||
endBehavior: stop
|
||
|
||
支持:
|
||
|
||
stop
|
||
loop
|
||
stayLast
|
||
|
||
含义:
|
||
|
||
stop:全部用完后停止
|
||
loop:重新从第一个开始
|
||
stayLast:停留在最后一个继续尝试
|
||
|
||
我建议默认:
|
||
|
||
endBehavior: stop
|
||
|
||
避免重复消耗已经没资源的供应商。
|
||
|
||
7. 管理接口
|
||
|
||
后面最好有一个简单管理 API:
|
||
|
||
GET /status
|
||
POST /upstreams/{name}/enable
|
||
POST /upstreams/{name}/disable
|
||
POST /routing/{name}/switch
|
||
POST /reload
|
||
|
||
这样不用每次改配置重启。
|
||
|
||
8. 指标监控
|
||
|
||
至少记录:
|
||
|
||
当前启用 upstream
|
||
当前代理数量
|
||
可用代理数量
|
||
活跃并发
|
||
拉取次数
|
||
空返回次数
|
||
拉取失败次数
|
||
切换次数
|
||
健康检查失败次数
|
||
请求成功率
|
||
请求延迟
|
||
|
||
尤其是这几个最重要:
|
||
|
||
upstream_switch_total
|
||
upstream_empty_fetch_total
|
||
proxy_available_total
|
||
proxy_active_connections
|
||
9. 配置校验
|
||
|
||
启动时应阻止明显错误:
|
||
|
||
switchAfterEmptyFetch <= 0
|
||
maxSize <= 0
|
||
maxConcurrencyPerProxy <= 0
|
||
requestInterval < 0
|
||
routing 引用不存在的 upstream
|
||
顺序策略只有一个 upstream
|
||
所有 upstream 都 disabled
|
||
正则无法编译
|
||
|
||
宁愿启动失败,也不要运行中悄悄出错。
|
||
|
||
最值得优先实现的 8 项
|
||
|
||
按开发优先级,我会这样排:
|
||
|
||
配置校验
|
||
首条命中路由
|
||
upstream 启用/禁用
|
||
连续空结果自动切换
|
||
切换操作原子化
|
||
maxSize 并发防超限
|
||
singleflight 合并拉取
|
||
状态与指标接口
|
||
|
||
做到这 8 项,第一版就已经比较稳了。
|
||
|
||
你们现在最大的设计风险不是“功能少”,而是多个请求同时触发拉取、切换和计数时出现竞态。这部分最好一开始就设计好,不然后面压力一上来,最容易出现重复拉取、越过 maxSize、连续跳过多个供应商等问题。
|
||
|
||
可以,这相当于系统除了“替用户转发请求”,还要再提供一种模式:
|
||
|
||
用户通过 API 从聚合代理池领取代理地址,然后自行连接该代理。
|
||
|
||
这时项目就不只是代理网关,还多了一个 代理分发服务(Proxy Distribution API)。
|
||
|
||
建议把两种能力分开:
|
||
|
||
模式一:Gateway
|
||
用户请求 → 系统选择代理 → 系统代转发
|
||
|
||
模式二:Extract API
|
||
用户调用 API → 系统返回代理地址 → 用户自行使用
|
||
一、增加对外提取 API
|
||
|
||
例如:
|
||
|
||
GET /api/v1/proxies
|
||
|
||
请求参数:
|
||
|
||
count 提取数量
|
||
protocol http / https / socks5
|
||
region 地区
|
||
carrier 运营商
|
||
ttl 希望最低剩余有效期
|
||
session 固定会话标识
|
||
|
||
示例:
|
||
|
||
GET /api/v1/proxies?count=5&protocol=http®ion=shanghai
|
||
Authorization: Bearer xxxx
|
||
|
||
返回:
|
||
|
||
{
|
||
"code": 0,
|
||
"data": {
|
||
"proxies": [
|
||
{
|
||
"id": "px_01",
|
||
"url": "http://user:pass@1.2.3.4:8000",
|
||
"expiresAt": "2026-07-28T18:30:00+08:00",
|
||
"region": "shanghai"
|
||
}
|
||
]
|
||
}
|
||
}
|
||
|
||
也可以支持纯文本:
|
||
|
||
GET /api/v1/proxies?count=5&format=text
|
||
|
||
返回:
|
||
|
||
http://user:pass@1.2.3.4:8000
|
||
http://user:pass@5.6.7.8:9000
|
||
二、不能简单把池里的 IP 直接返回
|
||
|
||
这是最重要的一点。
|
||
|
||
系统内部转发时,系统知道:
|
||
|
||
这个代理当前有多少并发
|
||
什么时候释放
|
||
是否已经失效
|
||
|
||
但把代理地址交给外部用户后,系统无法天然知道:
|
||
|
||
用户什么时候开始使用
|
||
使用多少并发
|
||
用了多久
|
||
是否还在使用
|
||
是否把代理转发给其他人
|
||
|
||
所以必须引入:
|
||
|
||
Lease(租约)
|
||
|
||
每次通过 API 提取代理,都创建一条租约。
|
||
|
||
用户
|
||
↓
|
||
领取代理
|
||
↓
|
||
创建 Lease
|
||
↓
|
||
占用代理容量
|
||
↓
|
||
租约到期后释放
|
||
三、租约模型
|
||
|
||
建议运行时维护:
|
||
|
||
leaseId
|
||
clientId
|
||
proxyId
|
||
issuedAt
|
||
expiresAt
|
||
reservedConcurrency
|
||
status
|
||
|
||
例如:
|
||
|
||
{
|
||
"leaseId": "lease_123",
|
||
"proxyId": "px_01",
|
||
"expiresAt": "2026-07-28T18:30:00+08:00",
|
||
"reservedConcurrency": 1
|
||
}
|
||
|
||
默认可以规定:
|
||
|
||
distribution:
|
||
leaseDuration: 60s
|
||
concurrencyPerLease: 1
|
||
|
||
表示:
|
||
|
||
用户提取一个代理
|
||
默认占用一个并发槽位
|
||
60 秒后自动释放租约
|
||
四、提取数量和并发容量不能混为一谈
|
||
|
||
例如:
|
||
|
||
一个代理最多支持 10 并发
|
||
|
||
用户提取一次,不一定意味着这个 IP 彻底不能再分配。
|
||
|
||
可以有两种分配模式。
|
||
|
||
独占模式
|
||
allocationMode: exclusive
|
||
|
||
一个代理只分配给一个用户。
|
||
|
||
适合:
|
||
|
||
固定 IP
|
||
登录会话
|
||
对 IP 隔离要求高
|
||
单用户独占套餐
|
||
共享模式
|
||
allocationMode: shared
|
||
|
||
同一个代理可以被多个用户领取,但不能超过容量。
|
||
|
||
例如:
|
||
|
||
maxConcurrencyPerProxy = 10
|
||
已经预留 = 7
|
||
剩余 = 3
|
||
|
||
还可以继续分配 3 个租约。
|
||
|
||
建议默认:
|
||
|
||
allocationMode: shared
|
||
concurrencyPerLease: 1
|
||
五、用户提取后是否从内部代理池移除
|
||
|
||
不应该直接移除,而应该变成:
|
||
|
||
Healthy
|
||
↓
|
||
Leased
|
||
|
||
如果还有剩余容量,仍然可以继续参与分配。
|
||
|
||
例如:
|
||
|
||
Proxy A
|
||
最大并发:10
|
||
内部网关占用:4
|
||
外部租约占用:3
|
||
剩余容量:3
|
||
|
||
统一计算:
|
||
|
||
availableConcurrency =
|
||
maxConcurrency
|
||
- internalActive
|
||
- leasedConcurrency
|
||
- reservedConcurrency
|
||
|
||
这样网关模式和 API 提取模式可以共用同一个聚合代理池。
|
||
|
||
六、最好支持池隔离
|
||
|
||
虽然可以共用一个池,但实际运行中建议支持隔离。
|
||
|
||
例如:
|
||
|
||
upstreams:
|
||
provider-a:
|
||
exposure:
|
||
gateway: true
|
||
extractApi: true
|
||
|
||
或者:
|
||
|
||
exposure:
|
||
modes:
|
||
- gateway
|
||
- extract
|
||
|
||
某些代理商可能只允许内部使用:
|
||
|
||
exposure:
|
||
modes:
|
||
- gateway
|
||
|
||
某些代理商专门用于用户提取:
|
||
|
||
exposure:
|
||
modes:
|
||
- extract
|
||
|
||
还可以设置比例:
|
||
|
||
capacity:
|
||
maxConcurrencyPerProxy: 10
|
||
gatewayReserveRatio: 0.3
|
||
extractReserveRatio: 0.7
|
||
|
||
避免 API 用户一下把所有代理容量领光,导致内部代理网关没代理可用。
|
||
|
||
七、增加 Distribution 配置
|
||
|
||
建议在顶层增加:
|
||
|
||
distribution:
|
||
enabled: true
|
||
|
||
listen: 0.0.0.0:8081
|
||
|
||
auth:
|
||
type: bearer
|
||
|
||
lease:
|
||
duration: 60s
|
||
maxDuration: 10m
|
||
concurrencyPerLease: 1
|
||
|
||
limits:
|
||
maxCountPerRequest: 20
|
||
requestsPerMinute: 60
|
||
maxActiveLeasesPerClient: 100
|
||
|
||
allocation:
|
||
mode: shared
|
||
strategy: leastLoad
|
||
|
||
response:
|
||
defaultFormat: json
|
||
|
||
完整一点:
|
||
|
||
distribution:
|
||
enabled: true
|
||
|
||
endpoints:
|
||
extract: /api/v1/proxies
|
||
release: /api/v1/leases/{leaseId}
|
||
status: /api/v1/leases/{leaseId}
|
||
|
||
auth:
|
||
type: apiKey
|
||
header: X-API-Key
|
||
|
||
lease:
|
||
duration: 60s
|
||
maxDuration: 5m
|
||
concurrencyPerLease: 1
|
||
|
||
limits:
|
||
maxCountPerRequest: 10
|
||
maxActiveLeasesPerClient: 50
|
||
requestsPerMinute: 30
|
||
|
||
allocation:
|
||
mode: shared
|
||
strategy: leastLoad
|
||
minRemainingTTL: 15s
|
||
|
||
response:
|
||
defaultFormat: json
|
||
allowTextFormat: true
|
||
八、建议提供三个 API
|
||
提取代理
|
||
POST /api/v1/proxies/extract
|
||
|
||
请求:
|
||
|
||
{
|
||
"count": 3,
|
||
"protocol": "http",
|
||
"region": "shanghai",
|
||
"leaseDuration": "60s"
|
||
}
|
||
|
||
响应:
|
||
|
||
{
|
||
"proxies": [
|
||
{
|
||
"leaseId": "lease_001",
|
||
"proxy": "http://user:pass@1.2.3.4:8000",
|
||
"expiresAt": "2026-07-28T18:30:00+08:00"
|
||
}
|
||
]
|
||
}
|
||
主动释放
|
||
DELETE /api/v1/leases/lease_001
|
||
|
||
用户提前用完时可以释放。
|
||
|
||
查询租约
|
||
GET /api/v1/leases/lease_001
|
||
|
||
返回:
|
||
|
||
{
|
||
"status": "active",
|
||
"expiresAt": "2026-07-28T18:30:00+08:00"
|
||
}
|
||
九、连续无 IP 时仍然要触发供应商切换
|
||
|
||
API 用户请求代理时,流程和内部网关一致:
|
||
|
||
用户请求提取 5 个代理
|
||
↓
|
||
当前上游容量不足
|
||
↓
|
||
调用当前代理商 API
|
||
↓
|
||
连续 N 次没有 IP
|
||
↓
|
||
切换到下一个代理商
|
||
↓
|
||
继续补足用户请求
|
||
|
||
例如:
|
||
|
||
routing:
|
||
- name: public-extract
|
||
purpose: extract
|
||
|
||
upstreams:
|
||
- provider-a
|
||
- provider-b
|
||
- provider-c
|
||
|
||
strategy:
|
||
type: sequential
|
||
switchAfterEmptyFetch: 5
|
||
|
||
也可以让网关和提取 API 使用不同的供应商顺序:
|
||
|
||
routing:
|
||
- name: gateway-jd
|
||
purpose: gateway
|
||
upstreams:
|
||
- provider-a
|
||
- provider-b
|
||
|
||
- name: public-extract
|
||
purpose: extract
|
||
upstreams:
|
||
- provider-c
|
||
- provider-b
|
||
|
||
这个设计很实用,因为有些代理商适合内部转发,有些适合对外分发。
|
||
|
||
十、必须做用户级限制
|
||
|
||
否则一个用户调用:
|
||
|
||
count=10000
|
||
|
||
就可能把整个池拿空。
|
||
|
||
至少需要:
|
||
|
||
clients:
|
||
user-a:
|
||
enabled: true
|
||
apiKey: xxx
|
||
|
||
limits:
|
||
maxCountPerRequest: 10
|
||
maxActiveLeases: 100
|
||
requestsPerMinute: 60
|
||
maxConcurrentCapacity: 100
|
||
|
||
还可以限制允许使用的代理池:
|
||
|
||
clients:
|
||
user-a:
|
||
allowedUpstreams:
|
||
- provider-a
|
||
- provider-b
|
||
|
||
以及允许的地区:
|
||
|
||
allowedRegions:
|
||
- shanghai
|
||
- beijing
|
||
十一、认证信息泄露问题
|
||
|
||
如果返回:
|
||
|
||
http://username:password@ip:port
|
||
|
||
用户就能看到上游代理商的真实账号密码。
|
||
|
||
更安全的做法是由聚合服务生成临时认证:
|
||
|
||
http://临时用户名:临时密码@你的代理入口:端口
|
||
|
||
然后由系统再转发到真实上游。
|
||
|
||
即:
|
||
|
||
用户
|
||
↓
|
||
聚合代理入口
|
||
↓
|
||
真实上游代理
|
||
|
||
这种模式叫:
|
||
|
||
Proxy Gateway Credential
|
||
|
||
优点:
|
||
|
||
不暴露供应商账号
|
||
可以随时撤销
|
||
可以限制租约时间
|
||
可以统计真实使用量
|
||
可以控制用户并发
|
||
可以防止用户长期保存代理地址
|
||
|
||
如果直接返回真实代理 IP 和认证信息,租约只能算“逻辑约束”,无法真正阻止用户在过期后继续使用。
|
||
|
||
十二、推荐两种提取模式
|
||
Raw 模式
|
||
|
||
直接返回真实上游地址:
|
||
|
||
distribution:
|
||
mode: raw
|
||
|
||
优点:
|
||
|
||
实现简单
|
||
性能高
|
||
用户直接连接供应商代理
|
||
|
||
缺点:
|
||
|
||
难以控制租约
|
||
难以统计实际使用
|
||
上游凭证可能泄露
|
||
用户可能绕过系统继续使用
|
||
Gateway 模式
|
||
|
||
返回你自己的代理入口:
|
||
|
||
distribution:
|
||
mode: gateway
|
||
|
||
返回:
|
||
|
||
http://lease_001:temporary-password@proxy.example.com:8080
|
||
|
||
系统内部根据 lease_001 选择真实代理。
|
||
|
||
优点:
|
||
|
||
可控
|
||
可撤销
|
||
可计费
|
||
可限制并发
|
||
不暴露供应商信息
|
||
|
||
对于正式对外提供服务,我更推荐 gateway 模式。
|
||
|
||
十三、建议加入项目实施范围
|
||
|
||
第一版可以先做:
|
||
|
||
1. JSON / 文本格式提取
|
||
2. API Key 鉴权
|
||
3. count 数量限制
|
||
4. 共享容量分配
|
||
5. 固定租约时间
|
||
6. 自动过期释放
|
||
7. 连续空 IP 自动切换供应商
|
||
8. 用户级速率限制
|
||
9. 提取、释放、查询三个接口
|
||
|
||
第二版再做:
|
||
|
||
1. 临时代理账号
|
||
2. Gateway 模式
|
||
3. 地区和运营商筛选
|
||
4. Sticky Session
|
||
5. 用户套餐与计费
|
||
6. 租约续期
|
||
7. 使用量统计
|
||
8. 多实例租约一致性
|
||
|
||
最终架构可以变成:
|
||
|
||
┌────────────────────┐
|
||
Business Request ─────▶│ Proxy Gateway │
|
||
└─────────┬──────────┘
|
||
│
|
||
▼
|
||
Aggregate Proxy Pool
|
||
▲
|
||
│
|
||
┌─────────┴──────────┐
|
||
Extract API Request ───▶│ Distribution API │
|
||
└────────────────────┘
|
||
│
|
||
▼
|
||
Provider A / B / C
|
||
|
||
核心原则是:
|
||
|
||
网关请求和 API 提取都从同一个容量模型里分配代理,但通过租约、配额和预留比例避免互相抢光资源。
|
||
|
||
这样项目就既能作为透明代理服务使用,也能作为“代理 IP 提取平台”使用。
|
||
|
||
对,这个必须纳入,而且要区分两类认证,别混在一起:
|
||
|
||
请求代理商提取 API 的认证
|
||
最终代理 IP 本身的认证
|
||
|
||
它们不是一回事。
|
||
|
||
一、提取 API 的账号密码
|
||
|
||
例如代理商要求:
|
||
|
||
GET https://provider.example.com/get
|
||
Authorization: Basic xxx
|
||
|
||
或者:
|
||
|
||
?username=abc&password=123
|
||
|
||
建议统一放到 api.auth:
|
||
|
||
upstreams:
|
||
provider-a:
|
||
enabled: true
|
||
|
||
api:
|
||
url: https://provider.example.com/get
|
||
method: GET
|
||
|
||
auth:
|
||
type: basic
|
||
username: ${PROVIDER_A_API_USERNAME}
|
||
password: ${PROVIDER_A_API_PASSWORD}
|
||
|
||
支持几种常见方式:
|
||
|
||
auth:
|
||
type: none
|
||
auth:
|
||
type: basic
|
||
username: ${API_USERNAME}
|
||
password: ${API_PASSWORD}
|
||
auth:
|
||
type: bearer
|
||
token: ${API_TOKEN}
|
||
auth:
|
||
type: apiKey
|
||
location: header
|
||
name: X-API-Key
|
||
value: ${API_KEY}
|
||
auth:
|
||
type: apiKey
|
||
location: query
|
||
name: key
|
||
value: ${API_KEY}
|
||
二、直接通过请求参数认证
|
||
|
||
有些代理商把账号密码放进 URL 参数,例如:
|
||
|
||
https://provider.example.com/get?username=xxx&password=xxx
|
||
|
||
可以支持:
|
||
|
||
api:
|
||
url: https://provider.example.com/get
|
||
method: GET
|
||
|
||
query:
|
||
username: ${PROVIDER_A_API_USERNAME}
|
||
password: ${PROVIDER_A_API_PASSWORD}
|
||
count: 1
|
||
|
||
程序负责 URL 编码,避免用户手工拼接:
|
||
|
||
url: https://provider.example.com/get?username=xxx&password=xxx
|
||
|
||
后者容易泄露到日志里,也容易因为特殊字符出问题。
|
||
|
||
三、POST 表单或 JSON 认证
|
||
|
||
部分供应商可能要求 POST:
|
||
|
||
api:
|
||
url: https://provider.example.com/get
|
||
method: POST
|
||
|
||
headers:
|
||
Content-Type: application/json
|
||
|
||
body:
|
||
type: json
|
||
value:
|
||
username: ${PROVIDER_A_API_USERNAME}
|
||
password: ${PROVIDER_A_API_PASSWORD}
|
||
count: 1
|
||
|
||
表单方式:
|
||
|
||
body:
|
||
type: form
|
||
value:
|
||
username: ${PROVIDER_A_API_USERNAME}
|
||
password: ${PROVIDER_A_API_PASSWORD}
|
||
num: 1
|
||
|
||
这样基本能覆盖绝大多数代理商接口。
|
||
|
||
代理本身的账号密码
|
||
|
||
代理商返回的代理可能是:
|
||
|
||
1.2.3.4:8000
|
||
|
||
但使用代理时还需要:
|
||
|
||
proxyUsername
|
||
proxyPassword
|
||
|
||
可以配置为固定认证:
|
||
|
||
proxyAuth:
|
||
type: static
|
||
username: ${PROVIDER_A_PROXY_USERNAME}
|
||
password: ${PROVIDER_A_PROXY_PASSWORD}
|
||
|
||
解析后内部组合为:
|
||
|
||
http://username:password@1.2.3.4:8000
|
||
|
||
有些接口直接返回完整地址:
|
||
|
||
http://user:pass@1.2.3.4:8000
|
||
|
||
则配置:
|
||
|
||
proxyAuth:
|
||
type: response
|
||
|
||
还有一种是白名单 IP 鉴权:
|
||
|
||
proxyAuth:
|
||
type: ipWhitelist
|
||
|
||
这种就不需要用户名密码。
|
||
|
||
推荐完整配置
|
||
upstreams:
|
||
provider-a:
|
||
enabled: true
|
||
|
||
provider:
|
||
billingMode: fetch
|
||
protocols:
|
||
- http
|
||
- https
|
||
|
||
api:
|
||
url: https://provider.example.com/api/proxy
|
||
method: GET
|
||
|
||
auth:
|
||
type: basic
|
||
username: ${PROVIDER_A_API_USERNAME}
|
||
password: ${PROVIDER_A_API_PASSWORD}
|
||
|
||
query:
|
||
count: 1
|
||
format: text
|
||
|
||
headers:
|
||
Accept: text/plain
|
||
|
||
template: |
|
||
{{$x := regexFindAll "\d{1,3}(\.\d{1,3}){3}:\d{2,5}" . -1}}
|
||
{{range $s := $x}}{{printf "http://%s\n" $s}}{{end}}
|
||
|
||
proxyAuth:
|
||
type: static
|
||
username: ${PROVIDER_A_PROXY_USERNAME}
|
||
password: ${PROVIDER_A_PROXY_PASSWORD}
|
||
|
||
pool:
|
||
maxSize: 100
|
||
|
||
capacity:
|
||
maxConcurrencyPerProxy: 10
|
||
|
||
lifecycle:
|
||
ttl: 120s
|
||
allocationSafetyMargin: 10s
|
||
|
||
fetch:
|
||
requestInterval: 1s
|
||
timeout: 3s
|
||
retry: 5
|
||
maxInFlight: 1
|
||
密钥不要直接写配置文件
|
||
|
||
不建议:
|
||
|
||
username: myuser
|
||
password: 123456
|
||
|
||
建议用环境变量:
|
||
|
||
username: ${PROVIDER_A_API_USERNAME}
|
||
password: ${PROVIDER_A_API_PASSWORD}
|
||
|
||
程序启动时替换。
|
||
|
||
也可以支持文件引用:
|
||
|
||
passwordFile: /run/secrets/provider_a_password
|
||
|
||
或者:
|
||
|
||
tokenFile: /run/secrets/provider_a_token
|
||
|
||
优先级可以定义为:
|
||
|
||
直接配置值
|
||
环境变量
|
||
Secret 文件
|
||
|
||
但生产环境最好只允许后两种。
|
||
|
||
日志必须脱敏
|
||
|
||
涉及这些字段时:
|
||
|
||
password
|
||
token
|
||
apiKey
|
||
Authorization
|
||
Proxy-Authorization
|
||
|
||
日志里必须变成:
|
||
|
||
***
|
||
|
||
URL 查询参数也要过滤,例如:
|
||
|
||
https://provider.example.com/get?username=abc&password=***
|
||
|
||
否则账号密码很容易出现在:
|
||
|
||
应用日志
|
||
错误日志
|
||
监控系统
|
||
链路追踪
|
||
HTTP access log
|
||
配置结构建议
|
||
|
||
最终 api 节点可以统一成:
|
||
|
||
api:
|
||
url:
|
||
method:
|
||
|
||
auth:
|
||
type:
|
||
username:
|
||
password:
|
||
token:
|
||
location:
|
||
name:
|
||
value:
|
||
|
||
headers: {}
|
||
query: {}
|
||
|
||
body:
|
||
type:
|
||
value:
|
||
|
||
template:
|
||
|
||
而代理连接认证独立:
|
||
|
||
proxyAuth:
|
||
type:
|
||
username:
|
||
password:
|
||
|
||
这样一眼就能看明白:
|
||
|
||
api.auth
|
||
= 如何请求供应商接口
|
||
|
||
proxyAuth
|
||
= 如何连接获取到的代理
|
||
|
||
这是最关键的边界,不然后面很容易把 API 密码和代理密码搞串。
|
||
|
||
|
||
都需要考虑,而且这两个认证应该分开设计。
|
||
|
||
实际上你的系统以后会有 3 层认证,很多代理平台就是这么做的。
|
||
|
||
聚合代理池
|
||
|
||
┌──────────────────────┐
|
||
│ ① 提取 API 认证 │
|
||
└─────────┬────────────┘
|
||
│
|
||
GET /api/proxies│
|
||
▼
|
||
返回代理信息
|
||
│
|
||
│
|
||
┌─────────┴────────────┐
|
||
│ ② 代理接入认证 │
|
||
└─────────┬────────────┘
|
||
│
|
||
CONNECT/HTTP Proxy
|
||
▼
|
||
聚合代理池
|
||
│
|
||
▼
|
||
┌─────────┴────────────┐
|
||
│ ③ 上游代理认证 │
|
||
└──────────────────────┘
|
||
① 提取 API(必须认证)
|
||
|
||
例如:
|
||
|
||
GET /api/v1/proxies
|
||
|
||
绝对不能开放。
|
||
|
||
建议至少支持:
|
||
|
||
API Key
|
||
Bearer Token
|
||
Basic Auth
|
||
|
||
例如:
|
||
|
||
Authorization: Bearer xxxxxxxxx
|
||
|
||
或者
|
||
|
||
X-API-Key: xxxxxxxxx
|
||
|
||
认证成功后才能:
|
||
|
||
提取 IP
|
||
查看租约
|
||
释放租约
|
||
|
||
这是业务接口认证。
|
||
|
||
② 聚合代理池(也建议认证)
|
||
|
||
如果你的聚合代理支持:
|
||
|
||
http://proxy.xxx.com:8080
|
||
|
||
用户浏览器或者程序直接配置:
|
||
|
||
HTTP Proxy
|
||
|
||
那么也建议认证。
|
||
|
||
例如:
|
||
|
||
proxy.xxx.com:8080
|
||
|
||
用户名:user001
|
||
密码:******
|
||
|
||
HTTP Proxy 就是:
|
||
|
||
Proxy-Authorization
|
||
|
||
SOCKS5 也是:
|
||
|
||
Username / Password
|
||
|
||
这样好处很多:
|
||
|
||
可以区分用户
|
||
可以统计流量
|
||
可以限速
|
||
可以限制并发
|
||
可以限制允许访问哪些 Routing
|
||
可以禁用某个用户
|
||
|
||
否则所有人都共用一个入口。
|
||
|
||
我建议支持三种模式
|
||
IP 白名单
|
||
gateway:
|
||
|
||
auth:
|
||
|
||
type: ipWhitelist
|
||
|
||
例如:
|
||
|
||
1.1.1.1
|
||
|
||
2.2.2.2
|
||
|
||
这些 IP 可以直接连。
|
||
|
||
用户密码
|
||
gateway:
|
||
|
||
auth:
|
||
|
||
type: basic
|
||
|
||
例如:
|
||
|
||
username
|
||
|
||
password
|
||
|
||
最常见。
|
||
|
||
Token
|
||
|
||
例如:
|
||
|
||
Proxy-Authorization: Bearer xxxxxx
|
||
|
||
以后方便接 OAuth。
|
||
|
||
③ 上游代理认证
|
||
|
||
这个前面已经说了。
|
||
|
||
聚合代理连接真正代理商的时候:
|
||
|
||
Provider A
|
||
|
||
需要:
|
||
|
||
user/pass
|
||
|
||
这是系统内部使用。
|
||
|
||
用户完全不知道。
|
||
|
||
其实以后最好抽象成 Client
|
||
|
||
例如:
|
||
|
||
clients:
|
||
|
||
client-a:
|
||
|
||
enabled: true
|
||
|
||
apiKey: xxxx
|
||
|
||
gatewayAuth:
|
||
|
||
username: aaa
|
||
|
||
password: bbb
|
||
|
||
permissions:
|
||
|
||
routing:
|
||
|
||
- jd
|
||
|
||
- taobao
|
||
|
||
extract: true
|
||
|
||
gateway: true
|
||
|
||
limits:
|
||
|
||
maxExtractCount: 20
|
||
|
||
maxConcurrent: 100
|
||
|
||
qps: 50
|
||
|
||
以后所有权限都挂在 Client 上。
|
||
|
||
例如:
|
||
|
||
Client A
|
||
|
||
↓
|
||
|
||
允许:
|
||
|
||
提取IP
|
||
|
||
↓
|
||
|
||
允许:
|
||
|
||
走Gateway
|
||
|
||
↓
|
||
|
||
允许:
|
||
|
||
JD
|
||
|
||
↓
|
||
|
||
不允许:
|
||
|
||
淘宝
|
||
|
||
以后做 SaaS 就很方便。
|
||
|
||
还有一个很多人都会漏掉
|
||
|
||
如果用户:
|
||
|
||
API 提取了 IP
|
||
|
||
是不是还能:
|
||
|
||
走 Gateway
|
||
|
||
我建议权限分开。
|
||
|
||
例如:
|
||
|
||
permissions:
|
||
|
||
extract: true
|
||
|
||
gateway: false
|
||
|
||
或者:
|
||
|
||
permissions:
|
||
|
||
extract: false
|
||
|
||
gateway: true
|
||
|
||
有些客户只买:
|
||
|
||
提取模式
|
||
|
||
有些客户只买:
|
||
|
||
代理转发
|
||
|
||
不要绑在一起。
|
||
|
||
我建议整个项目以后就围绕四个核心对象设计
|
||
Client(客户端)
|
||
│
|
||
├── Authentication(认证)
|
||
│
|
||
├── Permission(权限)
|
||
│
|
||
├── Limits(配额)
|
||
│
|
||
└── Usage(统计)
|
||
│
|
||
▼
|
||
Routing
|
||
│
|
||
▼
|
||
Upstream Pool
|
||
│
|
||
▼
|
||
Provider
|
||
|
||
这样后面无论增加:
|
||
|
||
API 提取
|
||
HTTP Proxy
|
||
SOCKS5
|
||
Web 管理后台
|
||
用户套餐
|
||
计费
|
||
|
||
都不用改核心架构,只是在 Client 这一层扩展认证、权限和配额即可。这也是大多数商业代理平台采用的设计思路。
|
||
|
||
|
||
对,客户端认证不应该强制开启,应该做成可配置能力。内网、自用、单机部署时强制认证,反而增加配置和使用成本。
|
||
|
||
不过建议不要简单设计成 auth: true/false,而是区分两个入口:
|
||
|
||
Gateway 代理入口认证
|
||
Extract API 提取接口认证
|
||
|
||
它们可以独立开启或关闭。
|
||
|
||
推荐配置
|
||
gateway:
|
||
enabled: true
|
||
listen: 0.0.0.0:8080
|
||
|
||
auth:
|
||
mode: none
|
||
|
||
distribution:
|
||
enabled: true
|
||
listen: 0.0.0.0:8081
|
||
|
||
auth:
|
||
mode: apiKey
|
||
|
||
这样可以实现:
|
||
|
||
HTTP/SOCKS5 代理入口:无需认证
|
||
提取 IP API:需要 API Key
|
||
|
||
反过来也可以:
|
||
|
||
gateway:
|
||
auth:
|
||
mode: usernamePassword
|
||
|
||
distribution:
|
||
auth:
|
||
mode: none
|
||
|
||
只是第二种通常不太推荐。
|
||
|
||
支持的认证模式
|
||
|
||
建议统一使用 mode:
|
||
|
||
auth:
|
||
mode: none
|
||
auth:
|
||
mode: usernamePassword
|
||
auth:
|
||
mode: apiKey
|
||
auth:
|
||
mode: ipWhitelist
|
||
|
||
还可以允许组合认证:
|
||
|
||
auth:
|
||
mode: any
|
||
|
||
methods:
|
||
- type: ipWhitelist
|
||
cidrs:
|
||
- 192.168.0.0/16
|
||
- 10.0.0.0/8
|
||
|
||
- type: usernamePassword
|
||
|
||
这里 any 表示满足任意一种即可:
|
||
|
||
来源属于内网白名单
|
||
或
|
||
提供正确账号密码
|
||
|
||
这个模式特别适合:
|
||
|
||
内网调用免认证
|
||
外网调用必须认证
|
||
同一套服务同时面向内外网
|
||
内网使用也别完全依赖“没有公网暴露”
|
||
|
||
即使只在内网运行,也可能遇到:
|
||
|
||
局域网里其他设备误用
|
||
容器端口意外映射到宿主机
|
||
防火墙配置错误
|
||
VPN 用户访问
|
||
反向代理把接口暴露出去
|
||
SSRF 利用内部代理
|
||
某台内网设备被入侵后滥用代理池
|
||
|
||
所以可以允许关闭认证,但最好提供安全保护。
|
||
|
||
关闭认证时限制监听地址
|
||
|
||
例如仅监听本机:
|
||
|
||
gateway:
|
||
listen: 127.0.0.1:8080
|
||
|
||
auth:
|
||
mode: none
|
||
|
||
或者绑定内网地址:
|
||
|
||
gateway:
|
||
listen: 192.168.1.10:8080
|
||
|
||
auth:
|
||
mode: none
|
||
|
||
不要在无认证时默认监听:
|
||
|
||
0.0.0.0
|
||
无认证时增加来源网段限制
|
||
gateway:
|
||
auth:
|
||
mode: none
|
||
|
||
access:
|
||
allowCIDRs:
|
||
- 192.168.0.0/16
|
||
- 10.0.0.0/8
|
||
- 127.0.0.1/32
|
||
|
||
注意,这不是身份认证,而是访问控制。两者最好分开:
|
||
|
||
access:
|
||
allowCIDRs: []
|
||
|
||
auth:
|
||
mode: none
|
||
|
||
这样语义更清楚。
|
||
|
||
提取 API 是否可以不认证
|
||
|
||
技术上可以,产品上应允许,默认上不建议。
|
||
|
||
提取 API 比代理入口更容易被滥用,因为调用一次就能批量拿走代理:
|
||
|
||
GET /api/v1/proxies?count=100
|
||
|
||
无认证时系统无法知道:
|
||
|
||
谁提取了代理
|
||
每个用户提取了多少
|
||
应该限制谁
|
||
谁需要承担租约
|
||
谁触发了上游代理消耗
|
||
|
||
所以无认证的提取 API 最好只用于:
|
||
|
||
单机工具
|
||
受控内网
|
||
测试环境
|
||
可信局域网服务
|
||
|
||
配置可以允许:
|
||
|
||
distribution:
|
||
auth:
|
||
mode: none
|
||
|
||
access:
|
||
allowCIDRs:
|
||
- 127.0.0.1/32
|
||
- 192.168.0.0/16
|
||
|
||
同时仍然执行全局限制:
|
||
|
||
distribution:
|
||
limits:
|
||
maxCountPerRequest: 10
|
||
requestsPerMinute: 60
|
||
maxActiveLeases: 100
|
||
|
||
认证关闭不代表限流、租约和容量控制也关闭。
|
||
|
||
无认证时如何识别客户端
|
||
|
||
认证关闭后没有 clientId,但系统仍然需要做统计和限流。
|
||
|
||
可以设置:
|
||
|
||
clientIdentification:
|
||
mode: sourceIP
|
||
|
||
系统使用来源 IP 作为匿名客户端标识:
|
||
|
||
anonymous:192.168.1.23
|
||
|
||
例如:
|
||
|
||
distribution:
|
||
auth:
|
||
mode: none
|
||
|
||
clientIdentification:
|
||
mode: sourceIP
|
||
|
||
limits:
|
||
maxCountPerRequest: 10
|
||
requestsPerMinutePerClient: 30
|
||
|
||
但要注意反向代理场景。如果服务前面有 Nginx,直接读取连接来源,看到的可能永远是 Nginx 的 IP。
|
||
|
||
因此需要明确可信代理:
|
||
|
||
network:
|
||
trustedProxies:
|
||
- 192.168.1.20/32
|
||
|
||
只有请求来自可信反向代理时,才接受:
|
||
|
||
X-Forwarded-For
|
||
Forwarded
|
||
|
||
否则攻击者可以伪造来源 IP 绕过限流。
|
||
|
||
建议的默认安全策略
|
||
|
||
可以设定以下默认值:
|
||
|
||
gateway:
|
||
listen: 127.0.0.1:8080
|
||
|
||
auth:
|
||
mode: none
|
||
|
||
distribution:
|
||
listen: 127.0.0.1:8081
|
||
|
||
auth:
|
||
mode: none
|
||
|
||
这对本机部署开箱即用。
|
||
|
||
当监听非回环地址时,例如:
|
||
|
||
listen: 0.0.0.0:8080
|
||
|
||
程序可以:
|
||
|
||
输出醒目警告;
|
||
要求配置认证或 allowCIDRs;
|
||
严格模式下直接拒绝启动。
|
||
|
||
例如:
|
||
|
||
security:
|
||
requireProtectionOnPublicListen: true
|
||
|
||
校验规则:
|
||
|
||
监听非回环地址
|
||
并且 auth.mode = none
|
||
并且 allowCIDRs 为空
|
||
↓
|
||
启动失败
|
||
|
||
这比强制所有场景认证更合理。
|
||
|
||
最终建议结构
|
||
gateway:
|
||
enabled: true
|
||
listen: 192.168.1.10:8080
|
||
|
||
access:
|
||
allowCIDRs:
|
||
- 192.168.0.0/16
|
||
|
||
auth:
|
||
mode: none
|
||
|
||
limits:
|
||
maxConcurrentConnections: 500
|
||
|
||
distribution:
|
||
enabled: true
|
||
listen: 192.168.1.10:8081
|
||
|
||
access:
|
||
allowCIDRs:
|
||
- 192.168.0.0/16
|
||
|
||
auth:
|
||
mode: none
|
||
|
||
clientIdentification:
|
||
mode: sourceIP
|
||
|
||
limits:
|
||
maxCountPerRequest: 10
|
||
requestsPerMinutePerClient: 30
|
||
maxActiveLeasesPerClient: 50
|
||
|
||
公网部署时改成:
|
||
|
||
gateway:
|
||
listen: 0.0.0.0:8080
|
||
|
||
auth:
|
||
mode: usernamePassword
|
||
|
||
distribution:
|
||
listen: 0.0.0.0:8081
|
||
|
||
auth:
|
||
mode: apiKey
|
||
|
||
所以最终原则可以写进实施文档:
|
||
|
||
客户端认证为可选能力,不作为系统运行的强制条件。系统应允许在可信内网或本机环境中关闭认证,但认证、来源访问控制、客户端识别和限流应相互独立。对非回环地址开放且未配置任何保护措施时,系统应发出警告或拒绝启动。
|
||
|
||
这样既不会把内网用户折腾得够呛,也不会让一个误开的 0.0.0.0 直接变成免费公共代理。
|
||
|
||
|
||
还有一些,而且现在最该补的,不是再加更多功能,而是把边界、并发和故障处理收紧。你们当前方案已经覆盖了:
|
||
|
||
多上游代理商
|
||
聚合代理池
|
||
连续空 IP 自动切换
|
||
API 提取 IP
|
||
HTTP/SOCKS 代理入口
|
||
可选客户端认证
|
||
上游 API 认证
|
||
健康检查、TTL、并发容量
|
||
|
||
剩下主要是下面这些。
|
||
|
||
一、必须先定清楚的核心语义
|
||
1. “获取成功”到底怎么算
|
||
|
||
上游 API 返回了内容,不代表成功获取到代理。
|
||
|
||
建议完整流程是:
|
||
|
||
请求上游 API 成功
|
||
→ 响应解析成功
|
||
→ 代理格式合法
|
||
→ 去重后还有新代理
|
||
→ 代理通过基础检测
|
||
→ 才算获取成功
|
||
|
||
例如上游返回:
|
||
|
||
1.2.3.4:8080
|
||
1.2.3.4:8080
|
||
非法内容
|
||
|
||
池里本来已经有 1.2.3.4:8080,那么这次实际新增数量仍然是 0。
|
||
|
||
这里要明确:
|
||
|
||
switchAfterEmptyFetch
|
||
|
||
是根据:
|
||
|
||
上游原始返回 0 个
|
||
|
||
还是:
|
||
|
||
最终成功加入池中 0 个
|
||
|
||
我更建议按:
|
||
|
||
上游响应解析后,没有得到任何合法代理。
|
||
|
||
不要因为全是重复 IP 就直接切换供应商,否则可能误判。重复 IP 可以单独记指标。
|
||
|
||
2. 获取错误和空结果必须分开
|
||
|
||
这点非常重要:
|
||
|
||
空结果:
|
||
API 正常返回,但没有 IP
|
||
|
||
获取错误:
|
||
超时、500、认证失败、解析错误、DNS 错误
|
||
|
||
只有空结果累加:
|
||
|
||
consecutiveEmptyFetch
|
||
|
||
错误要走另外的重试和退避逻辑:
|
||
|
||
fetchErrorCount
|
||
|
||
否则上游临时网络故障,也会被当成“代理已经用完”。
|
||
|
||
3. 切换是针对谁生效
|
||
|
||
假设同一个上游被多个路由使用:
|
||
|
||
routing:
|
||
- name: jd
|
||
upstreams: [provider-a, provider-b]
|
||
|
||
- name: taobao
|
||
upstreams: [provider-a, provider-c]
|
||
|
||
当 provider-a 连续空 5 次,到底是:
|
||
|
||
所有路由都停用 provider-a
|
||
|
||
还是:
|
||
|
||
只让 jd 切到 provider-b
|
||
|
||
建议定义为:
|
||
|
||
空结果计数属于 upstream
|
||
当前选中的供应商属于 routing
|
||
当 upstream 触发不可补充时,所有引用它的 routing 都可以切换
|
||
已经在池里的代理仍然允许继续使用
|
||
|
||
否则不同路由之间会出现状态打架。
|
||
|
||
二、容量模型还需要补完整
|
||
4. 不要只按 IP 数量补池
|
||
|
||
例如:
|
||
|
||
池中有 100 个 IP
|
||
每个最大并发 1
|
||
当前 100 个都在忙
|
||
|
||
虽然数量达到 maxSize,实际上已经没有可用容量。
|
||
|
||
因此补池判断应基于:
|
||
|
||
availableSlots
|
||
|
||
而不是只看:
|
||
|
||
proxyCount
|
||
|
||
计算可以统一为:
|
||
|
||
可用容量 =
|
||
所有健康代理最大并发总和
|
||
- 网关活跃并发
|
||
- API 提取租约预留
|
||
- 正在分配但尚未建立的预留容量
|
||
5. 分配过程需要临时预留
|
||
|
||
容易出现这种竞态:
|
||
|
||
代理剩余容量 = 1
|
||
|
||
请求 A 查询:可用
|
||
请求 B 查询:也可用
|
||
|
||
A 分配
|
||
B 也分配
|
||
|
||
于是超出最大并发。
|
||
|
||
因此需要一个状态:
|
||
|
||
reservedConcurrency
|
||
|
||
分配流程:
|
||
|
||
选择代理
|
||
→ 原子预留容量
|
||
→ 建立连接
|
||
→ 成功后 reserved 转 active
|
||
→ 失败后释放 reserved
|
||
6. API 提取模式无法真实知道用户是否还在使用
|
||
|
||
如果直接把真实上游 IP 返回给用户,系统只能通过租约推测容量。
|
||
|
||
例如租约是 60 秒,但用户可能:
|
||
|
||
5 秒就不用了
|
||
使用 10 分钟
|
||
同一个 IP 开 20 个连接
|
||
把 IP 分享给别人
|
||
|
||
所以 Raw 提取模式下:
|
||
|
||
租约容量只是估算
|
||
|
||
不能作为精确并发控制。
|
||
|
||
需要在文档明确:
|
||
|
||
raw 模式:弱控制
|
||
gateway 模式:强控制
|
||
|
||
正式商用最好以 gateway 模式为主。
|
||
|
||
三、代理本身的属性模型
|
||
7. 代理不能只保存 IP、端口
|
||
|
||
建议至少保存:
|
||
|
||
id
|
||
scheme
|
||
host
|
||
port
|
||
username
|
||
password
|
||
sourceUpstream
|
||
createdAt
|
||
expiresAt
|
||
lastCheckedAt
|
||
lastSuccessAt
|
||
latency
|
||
activeConcurrency
|
||
reservedConcurrency
|
||
status
|
||
tags
|
||
|
||
可选属性:
|
||
|
||
country
|
||
region
|
||
city
|
||
carrier
|
||
ASN
|
||
residential / datacenter
|
||
supportsHTTPS
|
||
supportsConnect
|
||
supportsUDP
|
||
|
||
否则以后做地区筛选、质量调度时需要大改数据结构。
|
||
|
||
8. 去重键要设计好
|
||
|
||
不能只用 IP 去重。
|
||
|
||
下面可能是不同代理:
|
||
|
||
1.2.3.4:8000
|
||
1.2.3.4:9000
|
||
|
||
下面也可能因为账号不同而属于不同通道:
|
||
|
||
user-a@1.2.3.4:8000
|
||
user-b@1.2.3.4:8000
|
||
|
||
建议唯一键:
|
||
|
||
scheme + host + port + username
|
||
|
||
密码不要参与哈希日志展示,但内部身份判断可考虑凭证版本。
|
||
|
||
9. TTL 来源要区分
|
||
|
||
代理过期时间可能来自:
|
||
|
||
上游明确返回
|
||
配置固定 TTL
|
||
根据获取时间估算
|
||
长效代理没有 TTL
|
||
|
||
建议优先级:
|
||
|
||
上游返回 expiresAt
|
||
> 上游返回 ttl
|
||
> 配置 lifecycle.ttl
|
||
> 不过期
|
||
|
||
还要避免服务器时间误差,最好内部统一使用 UTC。
|
||
|
||
四、健康检查还不够细
|
||
10. 健康检查需要区分全局和目标站点
|
||
|
||
代理可能:
|
||
|
||
能访问普通网站
|
||
但访问京东失败
|
||
|
||
所以最好区分:
|
||
|
||
globalHealth
|
||
routeHealth
|
||
|
||
例如:
|
||
|
||
代理本身正常
|
||
但对 jd 路由不可用
|
||
|
||
这时不应把它从全局池删除,只需不再分配给 JD。
|
||
|
||
第一版可以先只做全局健康检查,但数据模型最好预留目标级失败记录。
|
||
|
||
11. 健康检查不能造成流量风暴
|
||
|
||
假设有 1 万个代理,每 10 秒检查一次:
|
||
|
||
每秒约 1000 次检测
|
||
|
||
可能把自己和目标检测站打爆。
|
||
|
||
建议:
|
||
|
||
检测任务分散执行,不要同一时刻集中触发
|
||
添加随机抖动
|
||
限制最大并发
|
||
新代理优先检查
|
||
正常代理降低检查频率
|
||
失败代理短期加快复检
|
||
|
||
例如:
|
||
|
||
check:
|
||
interval: 30s
|
||
jitter: 20%
|
||
maxInFlight: 100
|
||
12. 失败淘汰需要分级
|
||
|
||
一次失败就删除太激进。
|
||
|
||
建议:
|
||
|
||
第一次失败 → SUSPECT
|
||
连续 N 次失败 → UNHEALTHY
|
||
复检成功 → HEALTHY
|
||
超过一定时间仍失败 → REMOVE
|
||
|
||
不一定要暴露复杂状态给用户,但内部最好这样处理。
|
||
|
||
五、请求调度策略
|
||
13. 代理选择不要只做随机
|
||
|
||
后面至少会需要:
|
||
|
||
leastConnections
|
||
leastLatency
|
||
roundRobin
|
||
random
|
||
stickySession
|
||
|
||
推荐默认:
|
||
|
||
leastConnections
|
||
|
||
如果延迟差异较大,可以用:
|
||
|
||
综合评分 =
|
||
负载权重
|
||
+ 延迟权重
|
||
+ 最近失败惩罚
|
||
|
||
第一版不用做复杂评分,但接口应该可扩展。
|
||
|
||
14. Sticky Session 要尽早考虑
|
||
|
||
某些网站登录后要求同一出口 IP。
|
||
|
||
例如用户传:
|
||
|
||
sessionId = abc
|
||
|
||
系统应该尽量把同一个 session 固定到同一个代理。
|
||
|
||
需要明确:
|
||
|
||
session 绑定多久
|
||
代理失效后是否自动重绑
|
||
多个路由是否共享 session
|
||
|
||
否则后面增加固定会话时,会影响整个分配接口。
|
||
|
||
15. 请求失败是否自动换代理重试
|
||
|
||
这是关键行为。
|
||
|
||
例如业务请求失败后:
|
||
|
||
是否自动换另一个代理重试?
|
||
|
||
需要避免:
|
||
|
||
POST 支付请求
|
||
|
||
被重复发送。
|
||
|
||
建议默认:
|
||
|
||
GET、HEAD 可以自动重试
|
||
POST、PUT、PATCH、DELETE 默认不自动重试
|
||
用户可配置是否允许
|
||
每次重试必须换代理或根据错误类型决定
|
||
|
||
配置示例:
|
||
|
||
gateway:
|
||
retry:
|
||
maxAttempts: 2
|
||
retryMethods:
|
||
- GET
|
||
- HEAD
|
||
六、上游 API 调用机制
|
||
16. 多请求合并
|
||
|
||
大量客户端同时缺代理时,应使用 singleflight:
|
||
|
||
100 个请求发现池容量不足
|
||
→ 只触发一次补池任务
|
||
→ 其他请求等待结果
|
||
|
||
否则容易瞬间调用上游 API 100 次。
|
||
|
||
17. 需要指数退避
|
||
|
||
请求上游失败时不能固定高速重试:
|
||
|
||
1s
|
||
2s
|
||
4s
|
||
8s
|
||
|
||
同时增加随机抖动,避免多实例同时重试:
|
||
|
||
fetch:
|
||
retry:
|
||
maxAttempts: 5
|
||
backoff:
|
||
initial: 1s
|
||
max: 30s
|
||
jitter: 20%
|
||
|
||
空 IP 是否遵守 requestInterval 也要明确,不能为了尽快达到 5 次而连续轰炸代理商。
|
||
|
||
18. API 模板功能要有限制
|
||
|
||
你们准备通过模板解析上游响应,这很灵活,但风险也大:
|
||
|
||
模板死循环
|
||
超大响应
|
||
正则灾难性回溯
|
||
模板访问敏感变量
|
||
CPU 和内存消耗过高
|
||
|
||
建议:
|
||
|
||
限制响应体大小
|
||
限制模板执行时间
|
||
限制可用函数
|
||
正则预编译
|
||
不允许任意文件或网络访问
|
||
七、多实例部署
|
||
19. 单机逻辑和多实例逻辑不一样
|
||
|
||
一旦部署多个节点,就会有这些问题:
|
||
|
||
两个实例同时补池
|
||
两个实例分别认为自己没超过 maxSize
|
||
两个实例同时切换上游
|
||
租约被重复分配
|
||
|
||
因此要明确项目第一版是:
|
||
|
||
单实例
|
||
|
||
还是:
|
||
|
||
支持集群
|
||
|
||
如果支持集群,需要共享:
|
||
|
||
当前活动 upstream
|
||
consecutiveEmptyFetch
|
||
租约
|
||
客户端配额
|
||
上游 API 限速
|
||
代理池元数据,或明确每个节点维护独立池
|
||
|
||
我建议第一版明确为单实例,集群作为第二阶段,不要半支持。
|
||
|
||
20. 重启后的恢复策略
|
||
|
||
重启后需要决定:
|
||
|
||
代理池是否恢复
|
||
租约是否恢复
|
||
当前切换到哪个供应商
|
||
空结果计数是否恢复
|
||
活跃连接如何处理
|
||
|
||
建议至少持久化:
|
||
|
||
当前 routing 所选 upstream
|
||
最后切换时间
|
||
有效租约
|
||
|
||
短效代理本身通常不值得持久化,重启后重新拉取即可。
|
||
|
||
八、安全方面
|
||
21. 防止成为 SSRF 和内网穿透工具
|
||
|
||
代理入口如果允许任意目标,会有人访问:
|
||
|
||
127.0.0.1
|
||
169.254.169.254
|
||
内网数据库
|
||
Kubernetes API
|
||
路由器管理页面
|
||
|
||
应支持目标访问策略:
|
||
|
||
gateway:
|
||
destinationPolicy:
|
||
denyPrivateNetworks: true
|
||
denyLoopback: true
|
||
denyLinkLocal: true
|
||
|
||
还需要防止 DNS Rebinding:
|
||
|
||
域名解析前检查
|
||
连接时再次检查实际 IP
|
||
|
||
这项对公网部署是必须的。
|
||
|
||
22. 访问日志不要泄露敏感信息
|
||
|
||
需要脱敏:
|
||
|
||
上游 API 密码
|
||
上游代理密码
|
||
客户端密码
|
||
API Key
|
||
Bearer Token
|
||
带认证信息的代理 URL
|
||
URL 查询参数中的密钥
|
||
|
||
最好统一做 Secret 类型,而不是靠每个日志调用者记得脱敏。
|
||
|
||
23. 管理 API 和业务 API 要分开
|
||
|
||
不要让管理接口和提取 API 共用一套低权限认证。
|
||
|
||
建议:
|
||
|
||
Gateway 端口
|
||
Extract API 端口
|
||
Admin API 端口
|
||
Metrics 端口
|
||
|
||
可以逻辑分开,是否物理端口分开视部署需求决定。
|
||
|
||
管理接口必须有更严格权限。
|
||
|
||
九、配置和运维
|
||
24. 配置热更新要定义行为
|
||
|
||
修改配置时:
|
||
|
||
删除 upstream 怎么处理现有代理
|
||
修改 maxSize 是否立即缩容
|
||
修改认证是否影响已有连接
|
||
修改 routing 是否立即生效
|
||
修改 API 地址是否重置空计数
|
||
|
||
建议使用:
|
||
|
||
校验新配置
|
||
→ 构建新配置快照
|
||
→ 原子替换
|
||
→ 旧任务优雅退出
|
||
|
||
不要边读配置边修改运行对象。
|
||
|
||
25. 配置版本必须存在
|
||
|
||
建议:
|
||
|
||
version: 1
|
||
|
||
以后修改字段语义时可以做迁移,避免配置格式失控。
|
||
|
||
26. 优雅停机
|
||
|
||
程序退出时要:
|
||
|
||
停止接受新请求
|
||
停止发起新 fetch
|
||
等待当前代理请求结束
|
||
保存必要状态
|
||
超时后强制关闭
|
||
|
||
例如:
|
||
|
||
shutdown:
|
||
gracePeriod: 30s
|
||
十、可观测性
|
||
27. 状态接口不能只显示 IP 数量
|
||
|
||
至少要看到:
|
||
|
||
每个 routing 当前使用哪个 upstream
|
||
每个 upstream 连续空结果次数
|
||
代理总数
|
||
健康代理数
|
||
可用容量
|
||
活跃连接
|
||
预留容量
|
||
API 提取租约数
|
||
上游 fetch 成功/空/失败次数
|
||
最近一次切换原因
|
||
最近一次切换时间
|
||
28. 指标标签不能无限增长
|
||
|
||
Prometheus 指标里不要用这些作为标签:
|
||
|
||
proxy IP
|
||
sessionId
|
||
clientId
|
||
完整 URL
|
||
|
||
否则高基数会把监控系统拖垮。
|
||
|
||
IP 级信息放日志或管理查询,不要放指标标签。
|
||
|
||
十一、测试必须覆盖的场景
|
||
|
||
建议至少做以下测试:
|
||
|
||
连续 4 次空,第 5 次成功,不能切换
|
||
连续 5 次空,只切换一次
|
||
100 个并发请求,只触发有限次数 fetch
|
||
并发切换不能从 A 一次跳到 C
|
||
maxSize 在并发 fetch 下不能超限
|
||
代理过期前 safety margin 内不能再分配
|
||
网关和 API 提取同时使用时容量不能超卖
|
||
配置热更新过程中请求不中断
|
||
上游 API 超时不能计为空结果
|
||
API 返回重复代理不能导致池重复
|
||
所有上游不可用时按 onUnavailable 执行
|
||
|
||
这些比再加一个新策略更重要。
|
||
|
||
建议的开发优先级
|
||
第一阶段:可稳定运行
|
||
获取成功、空结果、错误的语义
|
||
原子容量预留
|
||
singleflight 补池
|
||
原子切换上游
|
||
maxSize 防超限
|
||
TTL 和过期控制
|
||
基础健康检查
|
||
API/Gateway 限流
|
||
配置校验
|
||
状态接口和日志脱敏
|
||
第二阶段:更好用
|
||
Sticky Session
|
||
leastConnections 调度
|
||
route 级健康状态
|
||
配置热更新
|
||
租约管理
|
||
客户端权限和配额
|
||
请求失败分类与安全重试
|
||
第三阶段:商业化和集群
|
||
多实例一致性
|
||
Gateway 临时凭证
|
||
套餐和计费
|
||
使用量审计
|
||
地区、运营商、ASN 筛选
|
||
管理后台
|
||
Secret 管理和凭证轮换
|
||
|
||
当前最值得优化的三个点是:
|
||
|
||
补池请求合并、容量原子预留、供应商切换原子化。
|
||
|
||
这三个如果没做好,低流量时看起来一切正常,流量一高就会出现重复提取、容量超卖和供应商连续跳转。功能可以慢慢加,这三块最好第一版就打牢。
|
||
|
||
对,这样就简单很多,而且逻辑是成立的。
|
||
|
||
你的模式本质上不是“租约并发”,而是:
|
||
|
||
API 一旦提取成功,该代理立即从可分配池中移除,归提取者独占,系统不再重复分配。
|
||
|
||
因此确实不用关心用户:
|
||
|
||
有没有真正使用;
|
||
使用了多少并发;
|
||
什么时候停止使用;
|
||
是否把代理交给别人。
|
||
|
||
系统只需要保证:
|
||
|
||
同一个代理只提取一次
|
||
推荐状态流转
|
||
FETCHED
|
||
↓
|
||
CHECKING
|
||
↓
|
||
AVAILABLE
|
||
↓ API 提取
|
||
EXTRACTED
|
||
↓
|
||
等待过期
|
||
↓
|
||
EXPIRED / REMOVED
|
||
|
||
当用户通过 API 提取时:
|
||
|
||
AVAILABLE → EXTRACTED
|
||
|
||
进入 EXTRACTED 后:
|
||
|
||
不再被 API 提取;
|
||
不再分配给 Gateway;
|
||
不再参与可用代理数量统计;
|
||
不需要用户主动释放;
|
||
到期后直接删除。
|
||
|
||
这样连租约都可以不做。
|
||
|
||
提取过程必须原子化
|
||
|
||
虽然不用管理租约,但仍然要防止并发重复提取。
|
||
|
||
例如两个请求同时获取一个代理:
|
||
|
||
请求 A 查询到 Proxy-1 可用
|
||
请求 B 也查询到 Proxy-1 可用
|
||
|
||
如果只是先查询再删除,就可能同时返回给两个人。
|
||
|
||
正确流程应该是:
|
||
|
||
查找 AVAILABLE 代理
|
||
↓
|
||
原子修改为 EXTRACTED
|
||
↓
|
||
修改成功才返回
|
||
|
||
数据库可以类似:
|
||
|
||
UPDATE proxies
|
||
SET status = 'EXTRACTED',
|
||
extracted_at = NOW()
|
||
WHERE id = ?
|
||
AND status = 'AVAILABLE';
|
||
|
||
只有影响行数为 1 的请求获得该代理。
|
||
|
||
纯内存实现则用锁或 CAS。
|
||
|
||
批量提取也要么占用成功,要么不返回
|
||
|
||
例如:
|
||
|
||
GET /api/v1/proxies?count=10
|
||
|
||
系统应该:
|
||
|
||
筛选可用代理
|
||
→ 原子占用
|
||
→ 返回实际占用成功的代理
|
||
|
||
可以定义两种数量语义。
|
||
|
||
尽量返回
|
||
|
||
池里只有 6 个,就返回 6 个:
|
||
|
||
{
|
||
"requested": 10,
|
||
"returned": 6,
|
||
"proxies": []
|
||
}
|
||
必须足量
|
||
|
||
池里不足 10 个则不提取:
|
||
|
||
{
|
||
"code": "INSUFFICIENT_PROXIES",
|
||
"requested": 10,
|
||
"available": 6
|
||
}
|
||
|
||
建议支持配置:
|
||
|
||
distribution:
|
||
extraction:
|
||
fulfillment: partial
|
||
|
||
可选:
|
||
|
||
partial
|
||
allOrNothing
|
||
|
||
默认推荐 partial,用户体验更直接。
|
||
|
||
是否还需要保存提取记录
|
||
|
||
不需要“租约”,但建议保留一条简单的提取记录,用于审计和排错:
|
||
|
||
proxyId
|
||
clientId(启用认证时)
|
||
sourceIP
|
||
extractedAt
|
||
expiresAt
|
||
upstream
|
||
requestId
|
||
|
||
这不是为了重新释放代理,而是为了回答:
|
||
|
||
这个 IP 有没有被提取过;
|
||
什么时间被提取;
|
||
被哪个客户端提取;
|
||
从哪个供应商获取;
|
||
为什么池里代理消耗这么快。
|
||
|
||
无认证模式下,可以记录:
|
||
|
||
clientId = anonymous
|
||
sourceIP = 192.168.1.20
|
||
|
||
不需要维护复杂状态。
|
||
|
||
maxSize 要明确是否包含已提取代理
|
||
|
||
按照你们之前对 maxSize 的定义:
|
||
|
||
该上游最多维护或获取多少代理。
|
||
|
||
这里有两种可能。
|
||
|
||
限制当前池内代理数
|
||
AVAILABLE + CHECKING + BUSY
|
||
|
||
一旦代理被提取并移出池,就释放一个位置,系统可以继续从上游获取。
|
||
|
||
这种适合持续供应模式。
|
||
|
||
限制累计提取数量
|
||
|
||
例如代理商套餐总共只允许提取 1000 个:
|
||
|
||
累计从上游成功获取数量 <= maxSize
|
||
|
||
这种情况下,代理被用户提取后也不能释放额度。
|
||
|
||
所以建议不要让一个 maxSize 同时承担两种含义,拆开会更清楚:
|
||
|
||
pool:
|
||
maxSize: 100
|
||
|
||
fetch:
|
||
maxTotal: 1000
|
||
|
||
含义:
|
||
|
||
pool.maxSize
|
||
= 当前系统中最多保留多少个未提取代理
|
||
|
||
fetch.maxTotal
|
||
= 本次运行或计费周期内最多从供应商提取多少个
|
||
|
||
不过根据你之前的定义,如果 maxSize 就是“允许从该代理商最多获取多少个”,那它更接近:
|
||
|
||
fetch:
|
||
maxTotal: 1000
|
||
|
||
不应该再叫 pool.maxSize。
|
||
|
||
Gateway 和提取 API 是否共用池
|
||
|
||
如果提取 API 拿走以后永不再分配,那么共用池时要明确优先级。
|
||
|
||
例如池中有 10 个代理:
|
||
|
||
Gateway 正在等待代理
|
||
API 一次请求提取 10 个
|
||
|
||
API 可能直接清空整个池。
|
||
|
||
可以有三种方案。
|
||
|
||
完全共用
|
||
|
||
最简单:
|
||
|
||
allocation:
|
||
gatewayReserve: 0
|
||
|
||
谁先抢到谁用。
|
||
|
||
适合小型内网、自用系统。
|
||
|
||
为 Gateway 预留数量
|
||
allocation:
|
||
gatewayReserve: 10
|
||
|
||
当池里只剩 10 个时,提取 API 不再拿走,但 Gateway 还能使用。
|
||
|
||
分池
|
||
pools:
|
||
gateway:
|
||
upstreams: [provider-a]
|
||
|
||
extract:
|
||
upstreams: [provider-a, provider-b]
|
||
|
||
这是隔离最彻底的方式,但配置和资源管理更复杂。
|
||
|
||
第一版可以直接共用,再加一个简单的预留值:
|
||
|
||
distribution:
|
||
extraction:
|
||
reserveForGateway: 5
|
||
|
||
提取 API 可领取数量:
|
||
|
||
max(0, availableProxyCount - reserveForGateway)
|
||
提取后的健康和过期问题
|
||
|
||
代理提取出去后,系统不再负责分配,但返回时应告诉用户剩余有效时间:
|
||
|
||
{
|
||
"proxy": "http://1.2.3.4:8080",
|
||
"expiresAt": "2026-07-28T17:30:00+08:00",
|
||
"remainingTtlSeconds": 83
|
||
}
|
||
|
||
并设置最低剩余寿命:
|
||
|
||
distribution:
|
||
extraction:
|
||
minRemainingTTL: 30s
|
||
|
||
避免把只剩 2 秒的代理提取给用户。
|
||
|
||
提取前可以选择:
|
||
|
||
只使用最近健康检查通过的代理
|
||
|
||
例如:
|
||
|
||
distribution:
|
||
extraction:
|
||
maxHealthCheckAge: 15s
|
||
|
||
否则代理虽然状态是 AVAILABLE,但可能已经很久没检测过。
|
||
|
||
配置可以简化成这样
|
||
distribution:
|
||
enabled: true
|
||
listen: 192.168.1.10:8081
|
||
|
||
auth:
|
||
mode: none
|
||
|
||
extraction:
|
||
mode: exclusive
|
||
fulfillment: partial
|
||
|
||
maxCountPerRequest: 20
|
||
minRemainingTTL: 30s
|
||
reserveForGateway: 5
|
||
|
||
limits:
|
||
requestsPerMinute: 60
|
||
|
||
其中:
|
||
|
||
mode: exclusive
|
||
|
||
代表:
|
||
|
||
提取成功后立即从聚合池中永久移出,不再分配给任何请求。
|
||
|
||
甚至 mode 都可以省掉。要是你们的产品定义里 API 提取永远都是独占提取,那就直接把它写成固定行为,不用为不存在的共享需求提前增加配置。
|
||
|
||
最终流程
|
||
客户端请求提取 N 个代理
|
||
↓
|
||
身份认证 / 来源检查
|
||
↓
|
||
数量和频率限制
|
||
↓
|
||
筛选:
|
||
AVAILABLE
|
||
剩余 TTL 足够
|
||
健康检查未过时
|
||
↓
|
||
原子标记 EXTRACTED
|
||
↓
|
||
返回代理
|
||
↓
|
||
系统不再管理其使用情况
|
||
↓
|
||
过期后清理记录
|
||
|
||
所以你的判断没问题:**API 提取的代理只要保证“一次性独占发放”,就不需要租约、释放接口和实际使用量追踪。**保留提取记录和原子占用就够了。
|