proxy-pool/对话内容.md

9405 lines
154 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

可以做但先把目标拆清楚“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 分钟”“有效 12 分钟”等模式,这两类不能混着用。
固定时长代理
适合:
登录态
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
安全余量可以设置为 1530 秒。
上游代理不要“一请求一提取”
这是很多代理池最容易踩的坑。
错误流程:
客户端请求
→ 调供应商 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%
代理本身没有坏,只是被特定目标限制,因此不应直接全局淘汰。
失败切换
一次请求最多重试 12 次,不能无限重试。
请求代理 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 台48 核,主备或 ECMP
Proxy Worker
4 台起步
每台 816 核
1632 GB 内存
10 Gbps 网卡
控制面:
2 台48 核
Redis
3 节点或哨兵模式
PostgreSQL
主从或云数据库
单台 Go Proxy Worker 承担 5,00020,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. 只声明区间
例如截图里的
有效 12 分钟
有效 510 分钟
这种不能按最大值算应该保守处理例如 12 分钟
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
最大 QPS5
最大带宽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
有三种方案
方案 AIP 归属固定 Worker
proxyID 哈希到指定 Worker
owner := hash(proxy.ID) % workerCount
一个代理 IP 只由一个 Worker 使用
优点
不需要每请求访问 Redis
原子计数只在本机
性能最好
最适合一万以上并发
缺点
Worker 下线后需要重新分配
Worker 负载可能不完全均衡
这是我最推荐的方案
方案 B按容量切片分配
控制面把同一个 IP 的容量拆给不同 Worker
IP 最大并发20
Worker A8 个槽位
Worker B6 个槽位
Worker C6 个槽位
每个 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 被限流不会阻塞 BB 响应慢也不会影响 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 秒请求一次
最少约 34 秒完成
必须增加单 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 ConsumerNginx 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 可以是 0desiredSize 随流量增长对于使用才计费的代理商可以保留一定热代理减少首次请求等待
这个 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负责"哪些请求走哪里"支持 HostPathMethodHeader 等匹配并采用"自上而下首条命中"的规则
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 参数更有价值也是后续扩展多家代理商时最容易受益的地方
我觉得可以而且我会再往前走一步把它设计成一个真正可扩展的代理池配置而不是一个"配置项堆积"。
我会遵循几个原则
职责单一一个节点只负责一件事
支持未来扩展SOCKS5HTTP固定IP住宅IP等
配置名尽量语义化不用看源码也知道是什么意思
不让配置决定实现细节例如不要暴露 RedisWorker 这些概念
我建议目录结构如下
# 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&region=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 支付请求
被重复发送
建议默认
GETHEAD 可以自动重试
POSTPUTPATCHDELETE 默认不自动重试
用户可配置是否允许
每次重试必须换代理或根据错误类型决定
配置示例
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 提取的代理只要保证一次性独占发放”,就不需要租约释放接口和实际使用量追踪。**保留提取记录和原子占用就够了