154 KiB
可以做,但先把目标拆清楚:“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 自建节点
整个系统分成两个部分:
- 数据面 Proxy Worker
真正转发流量,要求:
无状态或弱状态 不在每个请求中查询数据库 不在每个请求中调用上游提取 API 使用内存中的代理列表完成选择 支持 HTTP、HTTPS CONNECT,按需要增加 SOCKS5 使用异步网络模型 连接池复用上游连接 支持失败快速切换
推荐使用:
Go:开发速度快,处理数万连接比较轻松 Rust + Tokio:性能和资源控制更强,开发成本较高 前置负载均衡:HAProxy、Envoy 或 LVS
不建议用普通 Python Web 框架直接承担核心转发流量。
- 控制面 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 一个合理的请求流程
- 客户端连接 Proxy Gateway
- 验证客户端身份和并发额度
- 根据租户、地区、协议、目标域名查找本地代理池
- 如果存在 session_id,优先获取绑定代理
- 根据评分和当前连接数选择上游
- 建立上游连接
- 转发数据
- 记录成功率、延迟、字节数
- 失败时按规则切换一次代理
- 异步更新代理评分
最重要的原则是:
转发热路径只做认证、内存选路和网络转发;提取代理、健康检查、数据库操作、评分计算全部放在后台。
按照你截图里的上游模式,我会优先设计成“固定会话池”和“短效轮换池”两个完全独立的资源池,再通过统一网关对外提供服务。这样比把所有 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 次获取。
时效性处理
代理商可能用三种方式表达有效期:
- 返回绝对过期时间 { "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:
逗号字符串存在问题:
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 理解成“平时一直维持这么多”,而应该只是绝对上限。
空闲时应该缩池,忙时再扩池。关键还要区分代理商的计费模式。
两类代理商要用不同策略
- 提取即计费 / 提取即消耗 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
配置结构
version: 1
defaults:
check:
urls:
- http://connect.rom.miui.com/generate_204
timeout: 2s
retry: 2
retryInterval: 500ms
routing: []
upstreams: {}
Routing(请求路由)
用于决定某个请求应该使用哪个 Upstream。
按照从上到下匹配。
命中第一条规则后立即停止。
示例
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
目前支持:
match:
hostRegex:
method:
pathRegex:
headers:
例如:
match:
hostRegex: '(^|\.)jd\.com$'
method:
- GET
- POST
pathRegex: '^/api'
Action
一个规则只能执行一个 Action。
支持:
action: direct
action: reject
或者:
upstreams:
- proxy-jd
多个 Upstream:
upstreams:
- proxy-a
- proxy-b
Upstream Selection
当存在多个 Upstream 时,需要指定选择策略。
strategy: random
支持:
random
roundRobin
weighted
leastConnections
failover
例如:
routing:
- match:
hostRegex: '(^|\.)jd\.com$'
upstreams:
- proxy-a
- proxy-b
strategy: leastConnections
Upstream
每个 Upstream 表示一个代理池。
upstreams:
proxy-jd:
api:
url:
template:
pool:
capacity:
lifecycle:
fetch:
check:
API
代理提取接口。
api:
url: https://xxx/api
template: |
{{$x := regexFind "\d{1,3}(\.\d{1,3}){3}:\d{2,5}" .}}
{{printf "http://%s" $x}}
Pool
代理池维护策略。
pool:
maxSize: 100
shrinkDelay: 30s
说明:
| 参数 | 说明 |
|---|---|
| maxSize | 允许同时维护的最大代理数量 |
| shrinkDelay | 代理空闲多久开始释放 |
Capacity
代理容量。
capacity:
maxConcurrencyPerIP: 10
说明:
一个代理最多同时承载多少请求。
超过后不会继续分配。
Lifecycle
代理生命周期。
lifecycle:
ttl: 120s
allocationSafetyMargin: 15s
说明:
ttl
主动失效时间。
allocationSafetyMargin
距离失效不足该时间,不再分配新请求。
例如:
TTL = 120s
Safety = 15s
代理使用:
0~105 秒
105~120 秒
仅允许已有连接
120 秒
删除
Fetch
控制如何向代理商提取代理。
fetch:
requestInterval: 200ms
timeout: 5s
retry: 5
maxInFlight: 1
说明:
| 参数 | 说明 |
|---|---|
| requestInterval | 两次请求 API 的最小时间间隔 |
| timeout | API 超时时间 |
| retry | API 最大重试次数 |
| maxInFlight | 同时最多几个请求正在提取 |
例如:
requestInterval: 1s
表示:
最多约 1 次/秒
requestInterval: 200ms
表示:
最多约 5 次/秒
Health Check
代理健康检查。
check:
urls:
- http://connect.rom.miui.com/generate_204
- https://www.baidu.com/
timeout: 2s
retry: 2
retryInterval: 500ms
interval: 10s
完整示例
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
version: 1
defaults: {}
routing: []
upstreams: {}
defaults
全局默认配置。
所有 Upstream 可继承,支持覆盖。
defaults:
fetch:
timeout: 5s
retry: 3
check:
interval: 10s
timeout: 2s
retry: 2
urls:
- http://connect.rom.miui.com/generate_204
routing
请求路由规则。
负责:
一个请求应该使用哪个 Upstream。
不负责代理维护。
按照从上到下匹配。
第一条命中立即停止。
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
目前支持:
match:
hostRegex:
method:
pathRegex:
headers:
以后还能扩展:
query:
clientIP:
scheme:
port:
strategy
当有多个 Upstream 时。
支持:
random
roundRobin
weighted
leastConnections
leastLoad
failover
action
支持:
action: direct
action: reject
或者:
upstreams:
- proxy-a
upstreams
每个 Upstream 对应一个代理池。
upstreams:
jd-primary:
provider:
api:
pool:
capacity:
lifecycle:
fetch:
check:
provider
描述代理商能力。
不是运行参数。
provider:
billingMode: usage
protocol:
- http
- https
stickySession: false
ipv6: true
auth:
usernamePassword: true
duplicatePolicy: allow
billingMode
支持:
fetch
usage
prepaid
说明:
| 模式 | 说明 |
|---|---|
| fetch | 提取即计费 |
| usage | 使用时计费 |
| prepaid | 套餐计费 |
api
代理提取接口。
api:
url:
method: GET
headers: {}
body: {}
template:
template:
template: |
{{$x := regexFind "\d{1,3}(\.\d{1,3}){3}:\d{2,5}" .}}
{{printf "http://%s" $x}}
pool
代理池策略。
pool:
maxSize: 100
shrinkDelay: 60s
说明:
|maxSize|允许维护的最大代理数量|
不是:
启动立即拉满。
而是:
系统最多允许维护多少个代理。
capacity
代理容量。
capacity:
maxConcurrencyPerProxy: 10
表示:
每个代理允许同时承载多少请求。
超过以后不再分配。
lifecycle
生命周期。
lifecycle:
ttl: 120s
allocationSafetyMargin: 15s
说明:
TTL
代理生命周期
allocationSafetyMargin
距离过期多少秒停止分配新请求
fetch
代理获取策略。
fetch:
timeout: 5s
retry: 3
requestInterval: 1s
maxInFlight: 1
说明:
| 参数 | 说明 |
|---|---|
| timeout | API超时 |
| retry | API重试次数 |
| requestInterval | 最小请求间隔 |
| maxInFlight | 最大同时提取数 |
check
健康检查。
check:
interval: 10s
timeout: 2s
retry: 2
retryInterval: 500ms
urls:
- http://connect.rom.miui.com/generate_204
- https://www.baidu.com/
Runtime State(非配置)
运行期间每个 Upstream 内部维护:
Healthy
Checking
Fetching
Reserved
Expired
调度器仅从:
Healthy
选择代理。
Scheduling
代理选择流程:
Request
│
▼
Routing
│
▼
Select Upstream
│
▼
Healthy Proxy
│
▼
Capacity Check
│
▼
Allocate
Fetch Flow
Pool Need Proxy
│
▼
Check requestInterval
│
▼
Acquire Fetch Slot
│
▼
Call Provider API
│
▼
Template Parse
│
▼
Health Check
│
▼
Join Healthy Pool
Health Flow
Healthy
│
Periodic Check
│
├────Success──────────────┐
│ │
▼ │
Keep Healthy │
│
Failure │
│ │
Retry │
│ │
Still Fail │
▼ │
Remove Proxy──────────────────┘
Complete Example
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。
例如:
routing:
- name: JD
match:
hostRegex: '(^|\.)jd\.com$'
upstreams:
- jd-a
- jd-b
- jd-c
strategy:
type: sequential
switchAfterEmptyFetch: 5
匹配方式
目前支持:
match:
hostRegex:
后续可扩展:
method
path
header
clientIP
五、Upstream
每一个 Upstream 表示一个代理供应商。
例如:
upstreams:
jd-a:
enabled: true
api:
fetch:
check:
lifecycle:
capacity:
六、启用状态
配置存在,不代表启用。
enabled: true
表示参与运行。
enabled: false
表示:
- 不获取代理
- 不健康检查
- 不参与路由
仅保留配置。
七、代理获取
代理通过 API 获取。
例如:
api:
url: https://xxx/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
参数说明:
| 参数 | 说明 |
|---|---|
| requestInterval | 两次请求 API 最小时间间隔 |
| retry | 获取失败最大重试次数 |
| timeout | API 超时时间 |
| maxInFlight | 同时最多几个获取任务 |
九、健康检查
check:
urls:
- http://connect.rom.miui.com/generate_204
interval: 10s
timeout: 2s
retry: 2
健康检查失败:
重新检测
↓
仍失败
↓
移出代理池
十、生命周期
lifecycle:
ttl: 120s
allocationSafetyMargin: 10s
说明:
TTL
代理生命周期
SafetyMargin
距离过期多少秒停止分配新请求
十一、容量控制
capacity:
maxConcurrencyPerProxy: 10
表示:
一个代理同时允许多少请求。
达到上限以后:
不再继续分配。
十二、供应商切换
当一个 Routing 配置多个 Upstream 时:
upstreams:
- jd-a
- jd-b
- jd-c
系统按照 Strategy 调度。
目前支持:
sequential
random
roundRobin
leastConnections
weighted
Sequential
Sequential 表示:
按顺序使用代理供应商。
例如:
jd-a
↓
jd-b
↓
jd-c
默认始终使用第一个。
自动切换
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
十六、完整示例
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 等策略分别画成流程图说明。这样文档不仅说明配置怎么写,还能清楚表达系统在不同策略下的运行方式,对后续开发和维护都会更直观。
有,而且还不少。你们现在已经把“多上游、路由、按顺序切换、健康检查、生命周期、容量”这些骨架搭起来了,下一步最容易踩坑的是边界条件、并发一致性、失败语义和可观测性。
我建议分成“第一版必须补”和“后续优化”两层。
第一版必须补的内容
- 规则冲突与默认行为
现在规则是从上往下匹配,那就必须明确:
第一条命中即停止
还要定义没有任何规则命中时怎么办:
routingDefault: action: direct
或者:
routingDefault: action: reject
不要让程序自己猜。
还要在启动时检测:
重复规则 永远不会命中的规则 .* 出现在前面导致后续规则失效 引用了不存在的 upstream 所有 upstream 都被禁用 2. enabled 不能只在启动时读取
最好支持运行时热更新:
enabled: false
修改后应做到:
停止新代理拉取 停止分配新请求 已有连接允许完成 代理池进入 draining 最终释放资源
不要直接粗暴断开已有连接。
- 顺序切换状态必须持久化
例如:
provider-a → provider-b → provider-c
如果程序重启后又从 provider-a 开始,会重复消耗已经耗尽的供应商。
所以至少要保存:
当前激活 upstream 连续空结果次数 最后切换时间
可以放:
本地文件 Redis 数据库
单机可以先用本地持久化,多实例建议放 Redis 或数据库。
- 连续空结果计数必须定义准确
“连续多少次获取不到 IP”需要明确什么算空。
建议只有下面情况才计数:
API 请求成功 响应解析成功 最终得到 0 个有效代理
这些不应该算空:
API 超时 HTTP 500 DNS 失败 模板解析异常 认证失败 返回格式变更
否则代理商接口偶发故障,也会被误判成“没 IP”。
建议分开两个计数:
emptyFetchCount fetchErrorCount
即使第一版不根据错误次数切换,也要至少分开记录。
- 切换时的并发竞争
多个协程同时发现:
emptyFetchCount >= 5
可能同时切换多次:
A → B → C
本来只该切到 B,结果直接跳到 C。
所以切换必须是原子的。
逻辑类似:
lock() if currentUpstream == expected { switchNext() } unlock()
或者使用 CAS。
- 当前代理池里还有 IP 时是否切换
这是一个很关键的问题。
假设:
provider-a 连续 5 次拉不到新 IP 但池里还有 20 个可用 IP
应该怎么处理?
建议:
停止继续从 A 拉取 已经获取的 A 代理继续用完 新的补充请求转到 B A 进入 draining
不要把 A 现有可用代理直接丢掉。
这会比“立刻完全切换”节省很多资源。
- 多个 routing 是否共享同一个 upstream
例如:
routing:
-
name: jd upstreams: [provider-a]
-
name: taobao upstreams: [provider-a]
那 provider-a 的:
maxSize 并发数 空结果计数 切换状态
是全局共享,还是每条 routing 独立?
建议:
upstream 代理池全局共享 routing 只维护自己的当前选择顺序 emptyFetchCount 放在 upstream 运行时 当前激活项放在 routing strategy 运行时
否则同一个代理商会被重复维护多个池。
- maxSize 的计数边界
必须明确哪些代理算入 maxSize:
建议算:
checking healthy busy draining
不算:
expired removed
正在 fetch 的请求可以单独算:
pendingFetch
实际限制:
currentProxyCount + expectedPending <= maxSize
否则多个并发拉取会突破上限。
- API 返回多个 IP 的处理
不能假设每次只返回一个。
需要明确:
返回数量超过剩余容量怎么办
建议:
去重 校验格式 只保留 maxSize 剩余容量以内的代理 多余代理丢弃并记录指标
还要处理:
同一个 API 返回重复 IP 不同供应商返回同一个 IP 同 IP 不同端口 同 IP 不同认证信息
建议代理唯一键使用:
scheme + host + port + username
不要只按 IP 去重。
- 代理检测不能只看“能连接”
健康检查至少区分:
连接成功 HTTP 响应成功 目标站可访问 出口 IP 正确
否则可能出现代理能访问百度,但访问目标域名被封。
第一版可以简单做两层:
check: connectivityUrl: http://connect.rom.miui.com/generate_204 targetUrl: https://目标域名/
目标检测失败时,不一定全局删除代理,可以只对该 routing 暂时不可用。
- 请求失败后的代理处理
业务请求失败,不应该一律把代理删除。
建议区分:
连接超时:代理可能有问题 407:认证错误 403:可能是目标站封禁 429:可能是目标站限流 5xx:可能是目标站问题 客户端主动取消:不能怪代理
至少维护:
consecutiveFailures lastFailureType
不要一次失败就淘汰。
- 是否允许直连必须明确配置
所有代理都不可用时:
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
因为有时候不是禁用代理商,而是临时禁用整条业务规则。
后续值得做的优化
- 延迟拉取
不要启动时就拉代理。
只有满足下面条件才拉:
请求到来 且没有足够可用容量
对于使用计费型供应商,可以提前少量预热。
- 请求合并
大量请求同时发现没代理时,不要每个请求都触发一次拉取。
应该合并为一个 fetch 任务:
100 个请求等待 ↓ 只触发 1 次或少量 API 获取 ↓ 结果广播给等待者
这叫 singleflight,很值得做。
- 负载感知
不要只按 IP 数量判断是否需要拉取。
应该看:
可用并发槽位
例如:
有 10 个 IP 但全部满载
实际上仍然需要拉新代理。
- 防抖
流量波动时,避免不停:
拉取 释放 拉取 释放
可以增加:
pool: idleReleaseDelay: 60s
不过对于有固定 TTL 的代理,通常直接让它自然过期更省事。
- 切换冷却
连续空结果切换后,不要马上又尝试旧代理商。
可以保留一个简单参数:
strategy: type: sequential switchAfterEmptyFetch: 5 switchCooldown: 10m
不是必须,但很实用。
- 最后一家用完后怎么办
顺序策略必须定义:
strategy: type: sequential switchAfterEmptyFetch: 5 endBehavior: stop
支持:
stop loop stayLast
含义:
stop:全部用完后停止 loop:重新从第一个开始 stayLast:停留在最后一个继续尝试
我建议默认:
endBehavior: stop
避免重复消耗已经没资源的供应商。
- 管理接口
后面最好有一个简单管理 API:
GET /status POST /upstreams/{name}/enable POST /upstreams/{name}/disable POST /routing/{name}/switch POST /reload
这样不用每次改配置重启。
- 指标监控
至少记录:
当前启用 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 模式。
十三、建议加入项目实施范围
第一版可以先做:
- JSON / 文本格式提取
- API Key 鉴权
- count 数量限制
- 共享容量分配
- 固定租约时间
- 自动过期释放
- 连续空 IP 自动切换供应商
- 用户级速率限制
- 提取、释放、查询三个接口
第二版再做:
- 临时代理账号
- Gateway 模式
- 地区和运营商筛选
- Sticky Session
- 用户套餐与计费
- 租约续期
- 使用量统计
- 多实例租约一致性
最终架构可以变成:
┌────────────────────┐
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
有些接口直接返回完整地址:
则配置:
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
那么也建议认证。
例如:
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、并发容量
剩下主要是下面这些。
一、必须先定清楚的核心语义
- “获取成功”到底怎么算
上游 API 返回了内容,不代表成功获取到代理。
建议完整流程是:
请求上游 API 成功 → 响应解析成功 → 代理格式合法 → 去重后还有新代理 → 代理通过基础检测 → 才算获取成功
例如上游返回:
1.2.3.4:8080 1.2.3.4:8080 非法内容
池里本来已经有 1.2.3.4:8080,那么这次实际新增数量仍然是 0。
这里要明确:
switchAfterEmptyFetch
是根据:
上游原始返回 0 个
还是:
最终成功加入池中 0 个
我更建议按:
上游响应解析后,没有得到任何合法代理。
不要因为全是重复 IP 就直接切换供应商,否则可能误判。重复 IP 可以单独记指标。
- 获取错误和空结果必须分开
这点非常重要:
空结果: API 正常返回,但没有 IP
获取错误: 超时、500、认证失败、解析错误、DNS 错误
只有空结果累加:
consecutiveEmptyFetch
错误要走另外的重试和退避逻辑:
fetchErrorCount
否则上游临时网络故障,也会被当成“代理已经用完”。
- 切换是针对谁生效
假设同一个上游被多个路由使用:
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 提取租约预留
- 正在分配但尚未建立的预留容量
- 分配过程需要临时预留
容易出现这种竞态:
代理剩余容量 = 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
否则以后做地区筛选、质量调度时需要大改数据结构。
- 去重键要设计好
不能只用 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
密码不要参与哈希日志展示,但内部身份判断可考虑凭证版本。
- TTL 来源要区分
代理过期时间可能来自:
上游明确返回 配置固定 TTL 根据获取时间估算 长效代理没有 TTL
建议优先级:
上游返回 expiresAt
上游返回 ttl 配置 lifecycle.ttl 不过期
还要避免服务器时间误差,最好内部统一使用 UTC。
四、健康检查还不够细 10. 健康检查需要区分全局和目标站点
代理可能:
能访问普通网站 但访问京东失败
所以最好区分:
globalHealth routeHealth
例如:
代理本身正常 但对 jd 路由不可用
这时不应把它从全局池删除,只需不再分配给 JD。
第一版可以先只做全局健康检查,但数据模型最好预留目标级失败记录。
- 健康检查不能造成流量风暴
假设有 1 万个代理,每 10 秒检查一次:
每秒约 1000 次检测
可能把自己和目标检测站打爆。
建议:
检测任务分散执行,不要同一时刻集中触发 添加随机抖动 限制最大并发 新代理优先检查 正常代理降低检查频率 失败代理短期加快复检
例如:
check: interval: 30s jitter: 20% maxInFlight: 100 12. 失败淘汰需要分级
一次失败就删除太激进。
建议:
第一次失败 → SUSPECT 连续 N 次失败 → UNHEALTHY 复检成功 → HEALTHY 超过一定时间仍失败 → REMOVE
不一定要暴露复杂状态给用户,但内部最好这样处理。
五、请求调度策略 13. 代理选择不要只做随机
后面至少会需要:
leastConnections leastLatency roundRobin random stickySession
推荐默认:
leastConnections
如果延迟差异较大,可以用:
综合评分 = 负载权重
- 延迟权重
- 最近失败惩罚
第一版不用做复杂评分,但接口应该可扩展。
- Sticky Session 要尽早考虑
某些网站登录后要求同一出口 IP。
例如用户传:
sessionId = abc
系统应该尽量把同一个 session 固定到同一个代理。
需要明确:
session 绑定多久 代理失效后是否自动重绑 多个路由是否共享 session
否则后面增加固定会话时,会影响整个分配接口。
- 请求失败是否自动换代理重试
这是关键行为。
例如业务请求失败后:
是否自动换另一个代理重试?
需要避免:
POST 支付请求
被重复发送。
建议默认:
GET、HEAD 可以自动重试 POST、PUT、PATCH、DELETE 默认不自动重试 用户可配置是否允许 每次重试必须换代理或根据错误类型决定
配置示例:
gateway: retry: maxAttempts: 2 retryMethods: - GET - HEAD 六、上游 API 调用机制 16. 多请求合并
大量客户端同时缺代理时,应使用 singleflight:
100 个请求发现池容量不足 → 只触发一次补池任务 → 其他请求等待结果
否则容易瞬间调用上游 API 100 次。
- 需要指数退避
请求上游失败时不能固定高速重试:
1s 2s 4s 8s
同时增加随机抖动,避免多实例同时重试:
fetch: retry: maxAttempts: 5 backoff: initial: 1s max: 30s jitter: 20%
空 IP 是否遵守 requestInterval 也要明确,不能为了尽快达到 5 次而连续轰炸代理商。
- API 模板功能要有限制
你们准备通过模板解析上游响应,这很灵活,但风险也大:
模板死循环 超大响应 正则灾难性回溯 模板访问敏感变量 CPU 和内存消耗过高
建议:
限制响应体大小 限制模板执行时间 限制可用函数 正则预编译 不允许任意文件或网络访问 七、多实例部署 19. 单机逻辑和多实例逻辑不一样
一旦部署多个节点,就会有这些问题:
两个实例同时补池 两个实例分别认为自己没超过 maxSize 两个实例同时切换上游 租约被重复分配
因此要明确项目第一版是:
单实例
还是:
支持集群
如果支持集群,需要共享:
当前活动 upstream consecutiveEmptyFetch 租约 客户端配额 上游 API 限速 代理池元数据,或明确每个节点维护独立池
我建议第一版明确为单实例,集群作为第二阶段,不要半支持。
- 重启后的恢复策略
重启后需要决定:
代理池是否恢复 租约是否恢复 当前切换到哪个供应商 空结果计数是否恢复 活跃连接如何处理
建议至少持久化:
当前 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
这项对公网部署是必须的。
- 访问日志不要泄露敏感信息
需要脱敏:
上游 API 密码 上游代理密码 客户端密码 API Key Bearer Token 带认证信息的代理 URL URL 查询参数中的密钥
最好统一做 Secret 类型,而不是靠每个日志调用者记得脱敏。
- 管理 API 和业务 API 要分开
不要让管理接口和提取 API 共用一套低权限认证。
建议:
Gateway 端口 Extract API 端口 Admin API 端口 Metrics 端口
可以逻辑分开,是否物理端口分开视部署需求决定。
管理接口必须有更严格权限。
九、配置和运维 24. 配置热更新要定义行为
修改配置时:
删除 upstream 怎么处理现有代理 修改 maxSize 是否立即缩容 修改认证是否影响已有连接 修改 routing 是否立即生效 修改 API 地址是否重置空计数
建议使用:
校验新配置 → 构建新配置快照 → 原子替换 → 旧任务优雅退出
不要边读配置边修改运行对象。
- 配置版本必须存在
建议:
version: 1
以后修改字段语义时可以做迁移,避免配置格式失控。
- 优雅停机
程序退出时要:
停止接受新请求 停止发起新 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 提取的代理只要保证“一次性独占发放”,就不需要租约、释放接口和实际使用量追踪。**保留提取记录和原子占用就够了。