From dab16fda1263668f6bd727ca557d62219c2c9524 Mon Sep 17 00:00:00 2001 From: youfak Date: Tue, 28 Jul 2026 18:08:59 +0800 Subject: [PATCH] docs: define proxy pool architecture from full conversation --- .gitignore | 25 + CONTEXT.md | 54 + docs/design/architecture.md | 458 ++ docs/development/implementation-plan.md | 217 + docs/requirements/traceability.md | 90 + findings.md | 85 + progress.md | 11 + task_plan.md | 45 + 对话内容.md | 9404 +++++++++++++++++++++++ 9 files changed, 10389 insertions(+) create mode 100644 .gitignore create mode 100644 CONTEXT.md create mode 100644 docs/design/architecture.md create mode 100644 docs/development/implementation-plan.md create mode 100644 docs/requirements/traceability.md create mode 100644 findings.md create mode 100644 progress.md create mode 100644 task_plan.md create mode 100644 对话内容.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d1fd622 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Local planning and browser artifacts +.playwright-mcp/ +temp/ + +# Build and test outputs +bin/ +dist/ +coverage/ +*.out +*.test +*.prof + +# Local configuration and secrets +.env +.env.* +!.env.example +configs/local.yaml +*.pem +*.key + +# Editors and operating systems +.idea/ +.vscode/ +.DS_Store +Thumbs.db diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..7d24dc6 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,54 @@ +# Proxy Pool 统一领域语言 + +## 核心实体 + +- **Client**:使用 Gateway 或 Distribution API 的调用主体。认证关闭时, + 由可信来源 IP 形成匿名 Client。 +- **Routing**:一组有序匹配规则和选择策略,决定某类请求使用哪些 Upstream。 +- **Upstream**:一个供应商配置及其聚合代理池。Upstream 运行时全局共享, + 不随 Routing 重复创建。 +- **Provider**:Upstream 背后的外部代理供应商及其获取接口能力。 +- **Proxy**:从 Provider 获取并标准化后的代理资源,不等同于简单 IP。 +- **Worker**:承载 Gateway 流量的数据面节点,仅使用本地快照选路。 +- **Controller**:集中管理 Provider 获取、代理生命周期、Routing 状态、 + 所有权和快照发布的控制面节点。 +- **Checker**:执行基础、出口和目标级健康检查的可扩缩执行节点。 +- **Extraction**:Distribution API 对一个或一组 Proxy 的一次性独占发放。 +- **Extraction Record**:Extraction 的审计事实,不承担租约或释放语义。 + +## 状态与计数 + +- **Proxy State**:`FETCHED`、`CHECKING`、`AVAILABLE`、`SUSPECT`、 + `DRAINING`、`UNHEALTHY`、`EXTRACTED`、`EXPIRED`、`REMOVED`。 +- **Active Concurrency**:已建立并正在使用 Proxy 的 Gateway 并发。 +- **Reserved Concurrency**:已选中、正在建连但尚未转为 Active 的并发。 +- **Available Slots**:所有可分配 Proxy 的有效并发上限减去 Active 与 + Reserved 后的总和。 +- **Consecutive Empty Fetch**:Provider 调用成功且解析成功,但解析后没有 + 任何合法代理的连续次数。 +- **Fetch Error Count**:超时、DNS、HTTP、认证、解析或模板执行错误次数。 +- **Current Upstream**:Sequential Routing 当前指向的 Upstream 索引。 + +## 行为术语 + +- **Gateway Allocation**:Worker 原子预留本地所有 Proxy 容量,建连成功后 + 转为 Active,结束后释放。 +- **Exclusive Extraction**:控制面原子把可提取 Proxy 从 `AVAILABLE` 改为 + `EXTRACTED`,成功后该 Proxy 永不再次被系统分配。 +- **Drain**:停止新分配,等待 Reserved 与 Active 归零后转换状态或撤销所有权。 +- **Empty Fetch**:不是错误、不是重复,而是有效 Provider 响应中没有任何 + 合法 Proxy 候选。 +- **Switch**:Routing 使用 CAS 从当前 Upstream 前进到下一个;不会销毁旧 + Upstream 已有 Proxy。 +- **Snapshot**:Controller 发布给 Worker 的不可变、版本化 Routing、Proxy + 所有权和策略视图。 + +## 配置语义 + +- **pool.maxSize**:当前系统维护且尚未被提取的 Proxy 硬上限。 +- **fetch.maxTotal**:可选的计费周期累计获取上限,和 pool.maxSize 无关。 +- **allocationSafetyMargin**:距离过期不足此时间时停止新分配。 +- **reserveForGateway**:共享池中不能被 Distribution 提取的最低可用数量。 +- **fulfillment.partial**:尽量返回,允许少于请求数量。 +- **fulfillment.allOrNothing**:不足时一个也不提取。 + diff --git a/docs/design/architecture.md b/docs/design/architecture.md new file mode 100644 index 0000000..0993b0e --- /dev/null +++ b/docs/design/architecture.md @@ -0,0 +1,458 @@ +# Proxy Pool 总体架构设计 + +## 1. 范围 + +Proxy Pool 是多供应商代理聚合平台,同时提供: + +- **Gateway**:接受 HTTP/HTTPS CONNECT 请求,选择上游代理并代转发。 +- **Distribution API**:一次性独占发放真实代理地址,发放后不再管理其使用。 +- **Admin API**:查询状态、启停 Upstream、切换 Routing、触发重载。 +- **Control Plane**:Provider 获取、健康、生命周期、容量、切换、持久化与分发。 + +首要容量目标是集群峰值 100,000 QPS。该数字是设计目标,必须通过后续 +容量测试证明,不能由文档直接宣称实现。 + +## 2. 选择的方案 + +采用“独立数据面 + 集中控制面 + 独立健康执行器”: + +```mermaid +flowchart LR + C[Clients] --> LB[HAProxy / Envoy / LVS] + LB --> G1[Gateway Worker] + LB --> GN[Gateway Worker N] + G1 --> PX[Owned Proxies] + GN --> PX + + E[Extract Clients] --> D[Distribution API] + A[Operators] --> ADM[Admin API] + + D --> CP[Controller] + ADM --> CP + CP --> PG[(PostgreSQL)] + CP --> R[(Redis)] + CP --> F[Provider APIs] + CP --> CK[Checker Workers] + CP --> G1 + CP --> GN +``` + +### 2.1 为什么不用单体 + +Provider、数据库和健康探测是不可控 I/O。把它们和 Gateway 放在一个进程, +会让供应商抖动、配置重载和数据库故障直接污染 100k QPS 热路径。 + +### 2.2 为什么不全面微服务化 + +Provider、Pool、Routing、Distribution 在首版需要共享事务和一致性规则。 +先保持 Controller 模块化单体,避免提前引入事件顺序和分布式事务;Checker +因负载特征不同独立扩缩,Gateway 因热路径独立部署。 + +## 3. 进程与职责 + +### 3.1 proxy-gateway + +- Client 认证、CIDR 访问控制和租户限流。 +- HTTP 正向代理与 HTTPS CONNECT。 +- 首条命中 Routing、Upstream 选择和 Proxy least-connections 选择。 +- 本地原子容量预留、建连、Active 计数和结果上报。 +- 本地不可变 Snapshot;热路径无数据库/Redis/Provider API。 +- SSRF 与 DNS Rebinding 防护。 + +### 3.2 proxy-controller + +- 配置加载、校验、热更新和版本管理。 +- Upstream/Provider fetch Leader、独立限流、singleflight 和退避。 +- Proxy 解析、去重、TTL、状态机、pool.maxSize 与 fetch.maxTotal。 +- Routing Sequential 当前选择和原子切换。 +- Worker 所有权/容量切片、Snapshot 发布、ACK 和重同步。 +- Distribution 与 Admin HTTP 接口。 +- PostgreSQL 权威状态和 Redis 短期协调。 + +### 3.3 proxy-checker + +- 消费检查任务,执行基础连接、出口和目标级探测。 +- 使用 jitter、maxInFlight、超时和分级复检。 +- 只上报 Observation,不直接修改最终状态。 +- Controller 的确定性 reducer 根据 Observation 更新状态。 + +### 3.4 proxy-loadgen + +- 分别生成 HTTP QPS、CONNECT 活跃连接、建连速率和 Extract 并发。 +- 输出环境、场景、延迟、错误、CPU、RSS、句柄和网络结果。 + +## 4. 模块边界 + +```text +domain/ + proxy Proxy、状态机、TTL、唯一键、容量 + routing 规则、匹配、策略与 Sequential 状态 + upstream Provider 能力、Fetch 分类与 Pool 需求 + extraction 独占提取命令、结果和审计事实 + client 认证主体、权限与限制 + +gateway/ + ingress HTTP/CONNECT 接入 + dispatch Routing、Proxy 选择、容量预留、重试资格 + transport 上游连接复用、握手、隧道和失败阶段 + snapshot 版本校验、后台构建和原子切换 + +controller/ + provider Fetch 计划、限流、singleflight、错误分类 + pool Pool reconcile、生命周期和所有权 + health 任务计划和 Observation reducer + routing Sequential 原子切换和恢复 + extraction 批量原子提取与审计 + distribution Worker 注册、Snapshot/Delta 与结果上报 + +adapters/ + postgres, redis, grpc, http, provider_template +``` + +领域模块不导入 HTTP、gRPC、SQL、Redis 或模板引擎。Adapter 依赖领域接口, +进程装配只发生在 `cmd`。 + +## 5. Proxy 模型 + +```go +type Proxy struct { + ID ProxyID + Scheme Scheme + Host string + Port uint16 + Username string + CredentialVersion string + SecretRef string + SourceUpstream UpstreamID + CreatedAt time.Time + ExpiresAt *time.Time + LastCheckedAt *time.Time + LastSuccessAt *time.Time + Latency time.Duration + MaxConcurrency int64 + State ProxyState + Tags map[string]string +} +``` + +密码不参与日志可见唯一键;`CredentialVersion` 区分同一用户名的凭据轮换。 +运行态 `active` 与 `reserved` 存在 Worker 本地、按 Proxy ID 分片,不写入 +不可变 Snapshot。 + +## 6. Proxy 状态机 + +```mermaid +stateDiagram-v2 + [*] --> FETCHED + FETCHED --> CHECKING + CHECKING --> AVAILABLE: basic check passed + CHECKING --> UNHEALTHY: check exhausted + AVAILABLE --> SUSPECT: first meaningful failure + SUSPECT --> AVAILABLE: recheck passed + SUSPECT --> UNHEALTHY: consecutive failures + AVAILABLE --> DRAINING: TTL margin / disable / ownership revoke + DRAINING --> EXPIRED: no active or reserved capacity + AVAILABLE --> EXTRACTED: atomic exclusive extraction + UNHEALTHY --> REMOVED + EXTRACTED --> EXPIRED: expires + EXPIRED --> REMOVED +``` + +`ACTIVE` 与 `BUSY` 不是持久状态,而是容量计数。一个 AVAILABLE Proxy 可以 +同时承载多个 Gateway 请求,直到有效并发上限。 + +## 7. Gateway 热路径 + +```mermaid +sequenceDiagram + participant C as Client + participant G as Gateway + participant D as Dispatch + participant T as Transport + participant P as Upstream Proxy + + C->>G: HTTP / CONNECT + G->>G: auth + access + admission + G->>D: RouteRequest + D->>D: first-match route + local candidate filter + D->>D: CAS reserved +1 + D-->>G: Allocation + G->>T: execute allocation + T->>P: dial / proxy handshake + alt connected + T->>D: reserved -1, active +1 + T-->>C: response / tunnel + T->>D: active -1 + outcome + else failed before commit + T->>D: reserved -1 + failure + G->>D: optional safe retry with exclusion + end +``` + +### 7.1 Dispatch 接口 + +```go +type Dispatcher interface { + Acquire(context.Context, RouteRequest) (*Allocation, error) + Commit(*Allocation) error + Release(*Allocation, Outcome) +} +``` + +不变量: + +- `Acquire` 只读本地 Snapshot 与本地分片运行态。 +- `Allocation` 创建前必须 CAS 预留成功。 +- Commit 将 Reserved 恰好一次转换为 Active。 +- 未 Commit 的失败释放 Reserved;已 Commit 的结束释放 Active。 +- Release 重复调用安全但产生错误指标。 + +### 7.2 重试提交点 + +- HTTP:响应头写给 Client 前可按方法与失败阶段重试。 +- CONNECT:上游 CONNECT 成功且向 Client 写 200 后不可重试。 +- GET/HEAD 默认最多尝试 2 个不同 Proxy。 +- POST/PUT/PATCH/DELETE 默认不自动重试。 + +## 8. Provider Fetch + +```mermaid +flowchart TD + S[Capacity signal] --> SF{singleflight running?} + SF -->|yes| W[Coalesce signal] + SF -->|no| R[Read current demand] + R --> L[Acquire Provider leader] + L --> C{Under maxSize/maxTotal?} + C -->|no| X[Stop] + C -->|yes| I[Wait requestInterval] + I --> M[Acquire maxInFlight] + M --> API[Call Provider API] + API --> CL{Classify} + CL -->|error| BO[Backoff + fetchErrorCount] + CL -->|empty| EC[emptyCount++] + CL -->|valid| DD[Deduplicate + reset empty] + DD --> HC[Create FETCHED and schedule check] +``` + +### 8.1 Empty、Duplicate 与 Error + +- **Empty**:HTTP/认证成功、模板执行成功,解析后合法 Proxy 数为 0。 +- **Duplicate-only**:合法 Proxy 数大于 0,但去重后新增为 0;不计 Empty, + 重置 Empty 连续计数并增加 duplicate 指标。 +- **Error**:超时、DNS、非预期 HTTP、认证、响应超限、模板或解析异常; + Empty 计数保持不变,增加 Error 并进入退避。 +- **Success**:至少一个合法候选;Empty 计数归零。是否最终入池由去重、 + maxSize、TTL 和健康结果决定。 + +## 9. Sequential 切换 + +```mermaid +sequenceDiagram + participant F as Provider Controller + participant U as Upstream Runtime + participant R as Routing Runtime + participant DB as PostgreSQL + + F->>U: EmptyFetch + U->>U: consecutiveEmpty++ + alt below threshold + U-->>F: continue current + else threshold reached + U->>DB: persist depleted generation + DB-->>R: notify affected routings + R->>DB: CAS current index A -> B + DB-->>R: switched once + R->>R: old A proxies drain naturally + end +``` + +Upstream 的 Empty 事实全局共享;每条 Routing 独立 CAS 当前索引。多个并发 +协程只能有一个成功从 A 切到 B,其他协程读取新版本,不会再切到 C。 + +## 10. Exclusive Extraction + +### 10.1 核心流程 + +```mermaid +sequenceDiagram + participant C as Extract Client + participant API as Distribution API + participant CP as Controller + participant W as Gateway Worker + participant DB as PostgreSQL + + C->>API: POST extract(count, filters) + API->>API: auth + access + limits + API->>CP: Extract command + CP->>DB: select eligible unowned proxies + alt candidate owned by Worker + CP->>W: revoke and drain + W-->>CP: active=0, reserved=0, ownership released + end + CP->>DB: transaction AVAILABLE -> EXTRACTED + DB->>DB: insert extraction records + DB-->>CP: committed rows + CP-->>API: proxy + expiry + API-->>C: requested/returned/proxies +``` + +### 10.2 原子批量提取 + +PostgreSQL Adapter 使用事务和 `FOR UPDATE SKIP LOCKED` 选择满足以下条件的 +行,并在同一事务更新状态与写审计记录: + +- State 为 AVAILABLE。 +- 未分配 Worker 所有权,Reserved/Active 为 0。 +- 剩余 TTL 不低于 `minRemainingTTL`。 +- 健康检查时间不早于 `maxHealthCheckAge`。 +- 提取后仍保留 `reserveForGateway`。 +- 符合 protocol、region、carrier 与 allowedUpstreams。 + +`partial` 提交实际可得数量;`allOrNothing` 在锁定数量不足时回滚。 + +### 10.3 与 Gateway 共池 + +Controller 优先维护两类库存:Worker-owned Gateway 容量和 unowned Extract +库存。Distribution 只直接提取 unowned Proxy。需要从 Gateway 回收时,先 +发布 DRAINING、等待 Worker ACK 和容量归零,再清除所有权并提取。这样避免 +Snapshot 传播延迟造成同一 Proxy 同时被 Gateway 新分配和 API 发放。 + +## 11. Cluster 所有权 + +```mermaid +flowchart LR + CP[Controller] -->|capacity lease| W1[Worker 1] + CP -->|capacity lease| W2[Worker 2] + P1[Proxy set A] --> W1 + P2[Proxy set B] --> W2 + U[Unowned extract reserve] --> CP +``` + +- 同一个 Proxy 同一时刻只归一个 Worker 所有。 +- Worker 仅对自身 Proxy 做本地原子计数。 +- 所有权租约有 epoch 与过期时间;Worker 失联后先等待租约过期,再分配给 + 其他 Worker,避免双主。 +- Snapshot 包含 Worker 专属 Proxy 集、Routing、策略和版本校验和。 +- Worker 断开控制面后在 `maxStaleAge` 内使用最后快照;超限停止接收新流量, + 已有隧道排空。 + +## 12. 存储与一致性 + +### 12.1 PostgreSQL + +权威保存:配置版本、Upstream、Proxy 生命周期、Routing 当前选择、Worker +所有权、Extraction Record、Client、审计与 outbox。 + +### 12.2 Redis + +保存可重建短期状态:Provider Leader 租约、分布式限流、singleflight 信号、 +短期 Client 限流和 Worker 心跳。Redis 不保存唯一权威业务状态。 + +### 12.3 Outbox + +任何需要发布 Snapshot/事件的 PostgreSQL 状态更新同时写 outbox。发布成功 +后标记;Worker 使用 epoch/version 幂等应用,缺口触发完整 Snapshot。 + +## 13. 健康模型 + +Health Observation 包含 Proxy、检查层级、Routing/目标组、阶段、结果、延迟、 +出口信息与时间。Reducer 规则: + +- 新 Proxy 必须通过基础检查才可 AVAILABLE。 +- 第一次有意义失败进入 SUSPECT,不立即删除。 +- 连续失败达到配置阈值进入 UNHEALTHY。 +- SUSPECT 复检成功恢复 AVAILABLE。 +- 目标级失败只影响对应 Routing/Target Profile,不直接全局删除。 +- 任务按稳定哈希分散,并对 interval 加 jitter。 + +## 14. 配置热更新 + +```mermaid +flowchart LR + F[Read file] --> P[Parse] + P --> V[Validate references and regex] + V --> B[Build immutable config] + B --> D[Diff old/new] + D --> S[Atomic swap] + S --> G[Drain removed tasks/resources] +``` + +热更新规则: + +- 删除/禁用 Upstream:停止 Fetch 和新分配,已有 Proxy/连接 Drain。 +- 减小 maxSize:不强杀连接,停止补池并按策略自然缩容。 +- Routing 立即对新请求生效;旧请求持有旧 Snapshot 完成。 +- 修改 API 地址或凭据版本会重建 Provider Adapter,但不会把错误计成 Empty。 +- 新配置任何校验失败时,保留旧版本并报告完整错误。 + +## 15. 安全 + +- Gateway、Distribution、Admin 认证相互独立。 +- `auth.mode` 支持 none、usernamePassword、apiKey、ipWhitelist 与组合 any。 +- `access.allowCIDRs` 独立于认证;代理头只在来源属于 trustedProxies 时接受。 +- 严格模式下,非回环监听且 auth=none、allowCIDRs 为空时启动失败。 +- 目的地址解析前后都拒绝 loopback、private、link-local、metadata 和配置禁区。 +- Secret 使用环境变量或文件引用;日志统一 Secret 类型脱敏。 +- Provider 模板只能使用白名单纯函数,禁止文件、网络和环境变量读取。 +- Distribution 返回真实凭据属于 Raw 弱控制模式,文档必须明确其风险。 + +## 16. 可观测性 + +关键指标: + +- Gateway 请求、连接、建连、字节、延迟、重试和拒绝。 +- Routing 当前 Upstream、切换、无可用和策略选择。 +- Upstream Proxy 状态、Available Slots、Active、Reserved、Pending Fetch。 +- Provider Fetch 成功、Empty、Duplicate、Error、429、退避和耗时。 +- Extraction requested、returned、insufficient、atomic conflict 和审计失败。 +- Snapshot epoch/version、陈旧时长、应用耗时和重同步。 + +Prometheus 标签不包含 Proxy IP、Client ID、session、完整 URL 或 request ID。 +这些信息进入受控、脱敏、可采样日志。 + +## 17. 性能与容量 + +### 17.1 集群容量公式 + +```text +worker_replicas = + ceil(peak_qps / (tested_worker_qps * target_utilization)) + + largest_failure_domain_replicas +``` + +默认 `peak_qps=100000`、`target_utilization<=0.60`。单 Worker 能力必须在相同 +CPU、内存、网络、Go 版本、配置和上游响应模型下测得。 + +### 17.2 热路径预算 + +- Dispatch 无 I/O、无全局锁,100k Proxy Snapshot 下 p99 小于 100 微秒。 +- 所有队列、buffer、重试和日志均有界。 +- Listener、Client、Routing、Worker 和 Proxy 均有独立准入限制。 +- 过载在路由/建连前快速拒绝,不允许请求堆积耗尽内存。 +- 结果上报批量、异步、有界,不反压请求热路径。 + +### 17.3 独立场景 + +必须分别测试:10k 稳态 QPS、100k 峰值 QPS、活跃 CONNECT、建连速率、 +大 Snapshot 更新、Provider 故障、Controller 断线和 Worker 故障域丢失。 + +## 18. 故障行为 + +| 故障 | 行为 | +|---|---| +| Provider 超时/500 | 计 Error、退避;不计 Empty,不影响已有 Proxy | +| Provider 合法空响应 | Empty++;达到阈值触发相关 Routing 原子切换 | +| Redis 不可用 | Gateway 不受影响;Controller 使用本地退化并停止高风险写操作 | +| PostgreSQL 不可用 | Gateway 使用最后快照;停止 Extract 和权威状态变更 | +| Controller 断线 | Worker 在 maxStaleAge 内继续;超限拒绝新流量并排空 | +| Worker 崩溃 | 所有权租约过期后重新分配;期间不双重所有 | +| Checker 积压 | 降低普通复检频率,优先新 Proxy 与 SUSPECT,不无限排队 | +| Snapshot 缺版本 | 丢弃增量并请求完整 Snapshot | +| 所有 Upstream 不可用 | 执行显式 onUnavailable,默认 reject | + +## 19. 交付边界 + +项目架构必须包含四个命令、领域模块、Gateway/Controller/Checker 模块、 +存储/协议 Adapter、OpenAPI/Proto、配置样例、Compose/Kubernetes、监控、 +迁移、测试 fixture、负载场景和开发文档。目录存在但没有契约或测试不算完成。 + diff --git a/docs/development/implementation-plan.md b/docs/development/implementation-plan.md new file mode 100644 index 0000000..467237b --- /dev/null +++ b/docs/development/implementation-plan.md @@ -0,0 +1,217 @@ +# Proxy Pool Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> `superpowers:subagent-driven-development` or `superpowers:executing-plans`. +> Every step is tracked with checkbox syntax and must preserve the requirement IDs in +> `docs/requirements/traceability.md`. + +**Goal:** Build a production-oriented Go repository whose domain behavior, interfaces, +configuration, contracts, documentation, and deployment layout implement the final +semantics in `对话内容.md`. + +**Architecture:** Separate Gateway, Controller, Checker, and Loadgen commands. Domain +packages remain transport-free. Gateway reads immutable local snapshots. Controller +owns provider fetch, pool lifecycle, routing state, extraction, persistence, and worker +distribution. PostgreSQL is authoritative; Redis stores rebuildable coordination state. + +**Tech Stack:** Go 1.26, `go.yaml.in/yaml/v4`, pgx/v5, go-redis/v9, gRPC/Protobuf, +Prometheus, PostgreSQL, Redis, Docker Compose, Kubernetes. + +--- + +## File Structure + +```text +cmd/ + proxy-gateway/main.go + proxy-controller/main.go + proxy-checker/main.go + proxy-loadgen/main.go +internal/ + config/{config.go,load.go,validate.go} + domain/proxy/{proxy.go,state.go,capacity.go} + domain/routing/{rule.go,strategy.go,sequential.go} + domain/upstream/{upstream.go,fetch_result.go,pool.go} + domain/extraction/{extraction.go,store.go} + domain/client/client.go + gateway/{server,dispatch,snapshot,transport}/ + controller/{provider,pool,routing,extraction,health,distribution}/ + adapters/{memory,postgres,redis,providerapi}/ + platform/{logging,metrics,shutdown}/ +api/{openapi,proto}/ +configs/ +deploy/{compose,kubernetes,haproxy,prometheus,grafana}/ +docs/{design,development,configuration,api,operations,testing,adr,requirements}/ +diagrams/ +examples/ +test/{fixtures,integration,e2e,load}/ +``` + +## Task 1: Repository and Build Baseline + +**Files:** `go.mod`, `.golangci.yml`, `README.md`, `scripts/verify.ps1`, +`.github/workflows/ci.yml` + +- [ ] Create module `github.com/proxy-pool/proxy-pool` with Go 1.26. +- [ ] Pin YAML v4, pgx/v5, go-redis/v9, gRPC, protobuf, Prometheus, and x/sync. +- [ ] Add `scripts/verify.ps1` that runs format check, `go vet`, unit tests, race tests, + and builds all commands, each test command bounded to 60 seconds. +- [ ] Add CI for Windows and Linux with unit/race/build jobs. +- [ ] Verify `go mod tidy`, `go test ./...`, and `go build ./cmd/...` succeed. + +## Task 2: Strict Configuration + +**Files:** `internal/config/config.go`, `load.go`, `validate.go`, corresponding tests, +`configs/default.yaml`, `docs/configuration/reference.md` + +- [ ] Define versioned types for security, gateway, distribution, admin, metrics, + storage, routing, upstream/provider/api/proxyAuth/pool/capacity/lifecycle/fetch/check. +- [ ] Decode one YAML document with known fields enabled and resolve `${ENV}` plus + secret file references without logging values. +- [ ] Validate listener protection, routing references/order, regexes, strategy fields, + positive limits, TTL margins, pool/fetch limits, auth modes, and exposure modes. +- [ ] Add table tests for every invalid condition in CFG requirements. + +## Task 3: Proxy Domain and Capacity + +**Files:** `internal/domain/proxy/*.go`, corresponding tests + +- [ ] Implement Proxy fields, UTC TTL precedence, canonical host/port, and unique key. +- [ ] Implement state transitions and reject illegal transitions. +- [ ] Implement sharded runtime counters with CAS Reserve, Commit, Cancel, Release. +- [ ] Prove with 1,000 concurrent goroutines that effective capacity is never exceeded. +- [ ] Add race coverage and duplicate-release invariant metrics hook. + +## Task 4: Routing and Sequential Switching + +**Files:** `internal/domain/routing/*.go`, corresponding tests + +- [ ] Compile first-match host/method/path/header rules into an immutable RuleSet. +- [ ] Implement random, round-robin, weighted, least-connections, and sequential. +- [ ] Model upstream empty counters separately from per-routing current indexes. +- [ ] Implement versioned CAS switch so simultaneous threshold observers advance once. +- [ ] Cover four-empty-then-success, five-empty, A-to-B-only, disabled references, + end behavior, and explicit onUnavailable. + +## Task 5: Provider Fetch Classification and Scheduling + +**Files:** `internal/controller/provider/*.go`, `internal/domain/upstream/*.go`, tests + +- [ ] Implement Valid, Empty, DuplicateOnly, and Error result classes exactly as the + traceability matrix defines. +- [ ] Implement one coalesced reconcile signal per Upstream using singleflight. +- [ ] Enforce requestInterval, maxInFlight, maxSize, maxTotal, timeout, retry, + exponential backoff, jitter, and Retry-After. +- [ ] Define ProviderAdapter and safe TemplateParser ports; add fixture adapters. +- [ ] Test that 100 concurrent capacity signals do not fan out 100 Provider calls. + +## Task 6: Pool Reconciliation and Ownership + +**Files:** `internal/controller/pool/*.go`, `internal/domain/upstream/pool.go`, tests + +- [ ] Compute Available Slots from eligible Proxy capacity, Active, Reserved, TTL, + health, ownership, pending expected fetch, and gateway reserve. +- [ ] Implement pool.maxSize and fetch.maxTotal as distinct counters. +- [ ] Allocate each Proxy to one Worker with epoch/version/expiry ownership. +- [ ] Implement revoke -> drain -> ACK -> unowned transition. +- [ ] Test Worker crash expiry and prevent simultaneous dual ownership. + +## Task 7: Exclusive Extraction + +**Files:** `internal/domain/extraction/*.go`, `internal/controller/extraction/*.go`, +`internal/adapters/memory/extraction.go`, tests + +- [ ] Implement POST extraction command with protocol/region/carrier/upstream filters. +- [ ] Enforce minRemainingTTL, maxHealthCheckAge, maxCount, client limits, and + reserveForGateway. +- [ ] Atomically transition AVAILABLE to EXTRACTED and append audit records. +- [ ] Implement partial and allOrNothing without Lease, release, or renewal concepts. +- [ ] Run 1,000 concurrent claim attempts and prove every Proxy ID appears at most once. + +## Task 8: Immutable Snapshot and Dispatch + +**Files:** `internal/gateway/snapshot/*.go`, `internal/gateway/dispatch/*.go`, tests + +- [ ] Define cluster/worker/epoch/version/checksum snapshot envelopes. +- [ ] Build indexes in the background and atomically swap complete snapshots. +- [ ] Reject version gaps and wrong epochs; request full resync. +- [ ] Implement Dispatch Acquire/Commit/Release over local owned Proxy runtime. +- [ ] Benchmark 100k Proxy snapshots and record allocations and latency. + +## Task 9: Gateway Transport + +**Files:** `internal/gateway/server/*.go`, `internal/gateway/transport/*.go`, tests + +- [ ] Implement HTTP forward proxy and HTTPS CONNECT through an upstream proxy. +- [ ] Add Client auth/access/admission and destination policy checks before routing. +- [ ] Implement safe retry commit points and prevent non-idempotent/established tunnel + replay. +- [ ] Use bounded buffers, deadlines, connection pools, and graceful shutdown. +- [ ] Add local fake upstream end-to-end tests for success, 407, timeout, cancel, half + close, retry, and blocked private destinations. + +## Task 10: Controller APIs and Persistence Ports + +**Files:** `internal/controller/distribution/*.go`, `admin/*.go`, +`internal/adapters/postgres/*.go`, `internal/adapters/redis/*.go`, migrations, tests + +- [ ] Define repository ports for Proxy, RoutingRuntime, Ownership, ExtractionRecord, + Client, ConfigVersion, and Outbox. +- [ ] Implement PostgreSQL extraction with one transaction and `FOR UPDATE SKIP LOCKED`. +- [ ] Implement Redis coordination for Provider leader, distributed rate, Client limit, + and Worker heartbeat; keep all state rebuildable. +- [ ] Expose Distribution extraction/status and Admin status/enable/disable/switch/reload. +- [ ] Add integration tests using Compose-backed PostgreSQL/Redis. + +## Task 11: Checker and Health Reducer + +**Files:** `internal/controller/health/*.go`, `cmd/proxy-checker/main.go`, tests + +- [ ] Schedule global and route health with jitter and bounded maxInFlight. +- [ ] Implement FETCHED -> CHECKING -> AVAILABLE and SUSPECT/UNHEALTHY transitions. +- [ ] Ensure target failures affect only the target profile. +- [ ] Add fixture target server and deterministic clock/scheduler tests. + +## Task 12: Machine-readable Contracts + +**Files:** `api/openapi/proxy-pool.yaml`, `api/proto/controlplane/v1/controlplane.proto`, +`docs/api/*.md` + +- [ ] Specify Distribution/Admin REST schemas, status codes, authentication, examples, + and idempotency behavior. +- [ ] Specify Worker register, snapshot, delta, ACK, report, heartbeat, ownership drain, + and resync messages. +- [ ] Validate OpenAPI and compile protobuf descriptors in CI. + +## Task 13: Deployment and Observability + +**Files:** `deploy/**`, `internal/platform/**`, `docs/operations/**` + +- [ ] Add Compose for local Controller/Gateway/Checker/PostgreSQL/Redis/Prometheus/ + Grafana/HAProxy. +- [ ] Add Kubernetes Deployments, Services, PDBs, HPA, NetworkPolicy, Secrets examples, + probes, resource limits, topology spread, and graceful termination. +- [ ] Add low-cardinality Prometheus metrics and structured secret-safe logs. +- [ ] Document backup, recovery, rollout, rollback, capacity, kernel, file descriptor, + NAT/conntrack, and incident runbooks. + +## Task 14: Documentation, Examples, and Diagrams + +**Files:** `docs/**`, `examples/**`, `diagrams/**` + +- [ ] Complete README navigation, design document, developer guide, configuration + reference, API guide, deployment guide, security model, testing guide, and roadmap. +- [ ] Provide at least 20 validated configuration examples. +- [ ] Provide at least 30 Mermaid architecture, flow, sequence, state, and failure diagrams. +- [ ] Generate `proxy-pool-docs-v1.0.zip` from versioned documentation assets. + +## Task 15: Completion Audit + +- [ ] Map every requirement ID to code, test, contract, document, or verified runtime evidence. +- [ ] Run `gofmt`, `go vet`, unit tests, race tests, builds, contract validation, and + documentation link/example validation. +- [ ] Run bounded local performance benchmarks; label 100k QPS as unverified until a + representative cluster load run exists. +- [ ] Confirm no TODO/TBD/placeholders, secrets, unbounded queues, high-cardinality metric + labels, extraction Lease APIs, or conflicting maxSize semantics remain. + diff --git a/docs/requirements/traceability.md b/docs/requirements/traceability.md new file mode 100644 index 0000000..a86d5e7 --- /dev/null +++ b/docs/requirements/traceability.md @@ -0,0 +1,90 @@ +# 需求追踪矩阵 + +本文将 `对话内容.md` 的演进讨论压缩为最终可验收需求。后出现的明确修订 +覆盖早期方案,尤其是 Distribution 的 Lease 设计。 + +## 架构 + +| ID | 最终需求 | 来源 | 验证证据 | +|---|---|---|---| +| ARCH-001 | 数据面 Worker 与控制面 Controller 分离 | 1-70 | 进程结构、架构图、构建产物 | +| ARCH-002 | 热路径只做认证、本地路由和网络转发 | 1-70, 380-430 | 依赖规则、测试、性能剖析 | +| ARCH-003 | Gateway、Distribution、Admin、Metrics 独立入口 | 8904-8958 | 配置、监听装配、端口测试 | +| ARCH-004 | Controller 集中 Provider 获取与切换 | 1403-1580 | Leader、singleflight、集成测试 | +| ARCH-005 | 100k QPS 峰值使用多 Worker 集群 | 当前会话 | 容量公式、负载场景、部署清单 | + +## Routing 与 Upstream + +| ID | 最终需求 | 来源 | 验证证据 | +|---|---|---|---| +| ROUTE-001 | Routing 自上而下匹配,首条命中停止 | 3534-3798, 5825-6467 | 路由单测 | +| ROUTE-002 | Routing 与 Upstream 生命周期解耦 | 3534-3798 | 包依赖与配置模型 | +| ROUTE-003 | 支持 sequential、random、roundRobin、weighted、leastConnections | 5825-6467 | 策略契约测试 | +| ROUTE-004 | Sequential 连续空结果达到阈值后原子切换一次 | 5295-5824, 6520-6617 | 并发切换测试 | +| ROUTE-005 | 空计数属于 Upstream,当前选择属于 Routing | 8442-8529 | 状态模型与多 Routing 测试 | +| ROUTE-006 | 旧 Upstream 已有 Proxy 继续耗尽,不因切换直接丢弃 | 6618-6641 | Drain 测试 | +| ROUTE-007 | 无可用 Upstream 时显式 reject、wait 或 direct,默认 reject | 5075-5294, 6743-6760 | 配置默认值与端到端测试 | + +## Provider 与补池 + +| ID | 最终需求 | 来源 | 验证证据 | +|---|---|---|---| +| FETCH-001 | 每个 Provider 有独立 requestInterval、maxInFlight、timeout 和 retry | 968-2394 | Fetcher 单测 | +| FETCH-002 | 大量缺池信号合并为 singleflight/容量 1 通知 | 2067-2136, 8808-8849 | 100 并发请求测试 | +| FETCH-003 | 错误使用指数退避和抖动,429 尊重 Retry-After | 1601-1831, 8808-8856 | 时钟驱动测试 | +| FETCH-004 | Provider 获取由单逻辑 Leader 执行 | 1403-1580 | 多实例锁测试 | +| FETCH-005 | Empty 与 Error 分开;只有合法候选为零时 Empty++ | 8442-8529 | 分类表驱动测试 | +| FETCH-006 | 重复候选不当作 Empty,记录独立指标 | 8442-8480 | 去重测试 | +| FETCH-007 | 模板限制响应大小、执行时间、函数集和外部访问 | 8808-8856 | 安全测试 | +| FETCH-008 | pool.maxSize 与 fetch.maxTotal 语义分离 | 9190-9280 | 配置校验与计数测试 | + +## Proxy 生命周期与容量 + +| ID | 最终需求 | 来源 | 验证证据 | +|---|---|---|---| +| PROXY-001 | Proxy 保存协议、地址、凭据引用、来源、TTL、健康、容量和标签 | 71-105, 8605-8678 | Domain 类型与序列化测试 | +| PROXY-002 | 唯一键包含 scheme、host、port、username、credentialVersion | 6655-6727, 8605-8678 | 去重单测 | +| PROXY-003 | TTL 来源优先级明确并统一 UTC | 681-747, 8655-8678 | TTL 表驱动测试 | +| CAP-001 | Gateway 分配使用 Reserved -> Active 原子转换 | 1203-1467, 8530-8597 | 高并发竞态测试 | +| CAP-002 | 补池依据 Available Slots,不只看 Proxy 数量 | 1203-1402, 8530-8597 | 容量单测 | +| CAP-003 | pool.maxSize 包括 FETCHED/CHECKING/AVAILABLE/SUSPECT/DRAINING 与 pending expected | 3001-3533, 6642-6680 | 并发 fetch 上限测试 | +| CAP-004 | TTL safety margin 内禁止新分配 | 173-220, 6728-6741 | 时钟测试 | +| CAP-005 | 多 Worker 不在热路径访问 Redis 计数 | 1403-1467 | 依赖审计与压测 | + +## Gateway + +| ID | 最终需求 | 来源 | 验证证据 | +|---|---|---|---| +| GW-001 | 支持 HTTP 与 HTTPS CONNECT,SOCKS5 保留扩展接口 | 1-70 | 协议端到端测试 | +| GW-002 | GET/HEAD 可配置安全重试,非幂等方法默认不重试 | 2600-2654, 8737-8807 | Retry 表驱动测试 | +| GW-003 | CONNECT 建立后不得透明重放 | 221-300 | 隧道故障测试 | +| GW-004 | Client 认证可关闭,但访问控制、身份识别和限流独立 | 8112-8441 | 配置矩阵测试 | +| GW-005 | 防私网、回环、链路本地、元数据地址和 DNS Rebinding | 8904-8931 | 目的地址策略测试 | + +## Distribution + +| ID | 最终需求 | 来源 | 验证证据 | +|---|---|---|---| +| DIST-001 | API 提取固定为一次性独占发放,不使用 Lease | 9083-9404 | Domain 状态机与 API 测试 | +| DIST-002 | AVAILABLE -> EXTRACTED 必须原子完成后才能返回 | 9083-9189 | 并发提取测试 | +| DIST-003 | 支持 partial 与 allOrNothing,默认 partial | 9190-9215 | API 契约测试 | +| DIST-004 | 保存审计记录,不提供释放接口 | 9216-9252 | Repository 测试与 OpenAPI | +| DIST-005 | 返回 expiresAt 与 remainingTtlSeconds | 9334-9360 | 响应测试 | +| DIST-006 | 提取前校验 minRemainingTTL 与 maxHealthCheckAge | 9334-9369 | 过滤测试 | +| DIST-007 | reserveForGateway 防止 Extract 清空共享池 | 9281-9333 | 共享池测试 | +| DIST-008 | 提取认证可关闭,关闭后仍有来源识别与全局限制 | 8112-8441 | 安全配置测试 | + +## 健康、安全、运维与测试 + +| ID | 最终需求 | 来源 | 验证证据 | +|---|---|---|---| +| HEALTH-001 | 全局健康与 Routing/目标健康分离 | 221-270, 8679-8708 | 健康 reducer 测试 | +| HEALTH-002 | 健康调度有 jitter、maxInFlight 和分级频率 | 8679-8736 | 调度测试 | +| HEALTH-003 | 失败分级 SUSPECT -> UNHEALTHY -> REMOVE | 8679-8736 | 状态机测试 | +| SEC-001 | API 认证与 Proxy 认证分离,Secret 统一脱敏 | 7528-8111, 8904-8945 | 配置类型与日志测试 | +| SEC-002 | 非回环监听无保护时严格模式启动失败 | 8112-8441 | 配置校验测试 | +| OPS-001 | 配置校验后构建不可变快照并原子替换 | 8959-8999 | 热更新并发测试 | +| OPS-002 | 优雅停机停止新请求/Fetch,等待现有流量后超时关闭 | 8981-9000 | 进程测试 | +| OBS-001 | 指标禁止 Proxy IP、session、Client、完整 URL 高基数标签 | 9001-9029 | 指标描述符测试 | +| TEST-001 | 覆盖对话中列出的 11 个关键并发与故障场景 | 9030-9082 | CI 测试清单 | + diff --git a/findings.md b/findings.md new file mode 100644 index 0000000..2f7b9c2 --- /dev/null +++ b/findings.md @@ -0,0 +1,85 @@ +# 对话内容提取结果 + +## 权威来源 + +- 文件:`对话内容.md` +- 大小:167,284 字节 +- 行数:9,404 行 +- 读取时间:2026-07-28 + +## 最终产品边界 + +系统同时提供两种入口: + +1. Gateway:系统选择上游代理并代转发 HTTP、HTTPS CONNECT,预留 + SOCKS5 扩展。 +2. Distribution API:一次性、独占地返回真实上游代理。代理成功提取后 + 立即从可分配池移除,不需要租约、释放接口或使用量追踪。 + +系统管理多个 Upstream Provider,并使用配置化 Routing 决定 Gateway 或 +Extract 请求使用哪些 Upstream。 + +## 后出现并覆盖早期建议的决策 + +- Distribution API 的 Lease/Release/Shared Allocation 方案被最终的 + Exclusive Extraction 取代。 +- `pool.maxSize` 表示当前系统维护的未提取代理硬上限;累计供应商提取额度 + 使用独立的 `fetch.maxTotal`,不能复用同一个字段。 +- `switchAfterEmptyFetch` 只统计“上游请求成功、解析成功,但解析后没有任何 + 合法代理”的结果;超时、HTTP 错误、认证错误、DNS 错误和模板错误只计 + `fetchErrorCount`。全重复结果不当作空结果,单独记录。 +- `consecutiveEmptyFetch` 属于 Upstream;Sequential 当前选择属于 Routing。 + 某 Upstream 达到阈值时,引用它的 Routing 原子切换;已有代理继续耗尽。 +- Extract API 默认部分满足 `partial`;也支持 `allOrNothing`。 +- Extract API 从 `AVAILABLE` 原子转换到 `EXTRACTED` 后才返回,保证同一代理 + 永不发放两次。 + +## 核心不变量 + +- 请求热路径不得调用 Provider API,也不得查询全量 Redis/PostgreSQL 后排序。 +- 代理分配必须原子预留容量,防止并发超卖。 +- 代理唯一键为 `scheme + host + port + username + credentialVersion`;日志 + 和指标不得暴露密码。 +- TTL 优先级为响应 `expiresAt`、响应 `ttl`、配置固定 TTL、不过期;内部 + 时间统一 UTC。 +- 健康检查至少区分全局健康和 Routing/目标健康,并使用抖动和并发上限。 +- GET/HEAD 可按配置安全重试;非幂等方法默认不自动重试;CONNECT 建立后 + 不透明重放。 +- 默认不直连;所有 Upstream 不可用时必须显式选择 reject、wait 或 direct。 +- Gateway、Distribution、Admin、Metrics 使用独立监听和认证/访问控制。 +- 非回环监听且无认证、无 CIDR 保护时,严格模式必须拒绝启动。 + +## 集群与性能 + +- 用户补充:高峰可能达到 100,000 请求/秒。 +- 数据面采用多 Worker,本地不可变代理快照和本地容量计数。 +- 同一代理必须由单个 Worker 所有,或由控制面下发容量切片;禁止每请求 + 访问 Redis 做全局并发计数。 +- 控制面集中 Provider 获取、独立限流、singleflight、Leader 选举、状态 + 持久化和快照分发。 +- 副本数必须由单 Worker 实测能力、目标利用率和故障域余量计算。 + +## 配置模型 + +顶层包含:`version`、`defaults`、`security`、`gateway`、`distribution`、 +`admin`、`metrics`、`storage`、`routing`、`upstreams`。 + +每个 Upstream 包含:`enabled`、`exposure`、`provider`、`api`、`proxyAuth`、 +`pool`、`capacity`、`lifecycle`、`fetch`、`check`。 + +Routing 自上而下匹配,首条命中停止;支持 Gateway 与 Extract 两种 purpose, +策略至少包括 sequential、random、roundRobin、weighted、leastConnections。 + +## 必测场景 + +- 连续 4 次空后成功不得切换;连续 5 次空只切换一次。 +- 100 个并发缺池请求只触发有限次 Provider fetch。 +- 并发切换不能从 A 一次跳到 C。 +- 并发 fetch 不得突破 pool.maxSize 或 fetch.maxTotal。 +- TTL safety margin 内不得分配。 +- Gateway 与 Extract 共享池时不得容量超卖或重复提取。 +- 配置原子热更新期间请求不中断。 +- Provider 超时不得计入 Empty Fetch。 +- 重复代理不得重复入池,也不得触发空结果切换。 +- 所有 Upstream 不可用时按显式策略执行。 + diff --git a/progress.md b/progress.md new file mode 100644 index 0000000..618fad4 --- /dev/null +++ b/progress.md @@ -0,0 +1,11 @@ +# 项目进度 + +## 2026-07-28 + +- 用户删除了先前基于不完整网页内容生成的设计文件。 +- 已重新读取当前 `对话内容.md` 全部 9,404 行。 +- 已按主题定位配置定稿、实施方案、Distribution API、认证、安全、并发、 + 故障语义和最终 Exclusive Extraction 修订。 +- 已建立新的任务计划与事实记录,旧网页摘要不再作为需求证据。 +- 尚未创建实现代码。 + diff --git a/task_plan.md b/task_plan.md new file mode 100644 index 0000000..e831991 --- /dev/null +++ b/task_plan.md @@ -0,0 +1,45 @@ +# Proxy Pool 项目交付计划 + +## 目标 + +以 `对话内容.md` 的 9404 行完整讨论为唯一需求基线,交付设计文案、 +开发文档和可编译、可测试、可扩展的 Go 项目架构。 + +## 需求覆盖原则 + +- 同一主题存在多轮修订时,以文件中最后出现的明确结论为准。 +- 用户补充的集群峰值为 100,000 QPS,覆盖文档早期 10,000 并发示例。 +- Extract API 固定采用一次性独占发放;早期 Lease/Release 设计作废。 +- 不保留空实现、TBD、TODO 或没有接口契约的占位目录。 + +## 阶段 + +1. [已完成] 完整读取对话并识别覆盖关系 +2. [进行中] 建立需求追踪矩阵与统一领域模型 +3. [待开始] 编写总体设计、详细设计和 ADR +4. [待开始] 编写开发、配置、API、测试和运维文档 +5. [待开始] 搭建 Go 模块、命令、核心包、契约和部署目录 +6. [待开始] 实现核心状态机、路由、容量、提取与配置校验 +7. [待开始] 执行单元测试、竞态检查、静态检查和构建 +8. [待开始] 按需求矩阵逐项审计并打包交付 + +## 串并行关系 + +```text +全文解析 ─> 需求矩阵 ─> 统一设计 ─┬─> 设计/开发文档 ─┐ + ├─> API/配置契约 ───┼─> 集成验证 + └─> Go 项目架构 ────┘ +``` + +## 测试约束 + +- 单条后台测试命令最长运行 60 秒。 +- 核心并发代码必须执行 `go test -race`。 +- 100k QPS 只能由可复现的集群压测结果证明,架构文档不得把设计容量 + 写成已验证性能。 + +## 已知环境限制 + +- Docker CLI 已安装,但 Linux daemon 状态需在集成验证前再次确认。 +- 当前仓库尚无提交;`对话内容.md` 和 `.gitignore` 为现有文件。 + diff --git a/对话内容.md b/对话内容.md new file mode 100644 index 0000000..2de881a --- /dev/null +++ b/对话内容.md @@ -0,0 +1,9404 @@ +可以做,但先把目标拆清楚:“1 万并发连接”不等于“每秒 1 万请求”。 + +你截图里的上游看起来支持直连池、转发池、混合池,以及固定时长和短效 IP。聚合代理池不要让业务端直接调用这些供应商 API,而是在中间做一层统一代理网关。 + +推荐架构 +业务客户端 + │ + │ HTTP / HTTPS CONNECT / SOCKS5 + ▼ +负载均衡层 +LVS / HAProxy / Envoy + │ + ├───────────────┐ + ▼ ▼ +Proxy Worker 1 Proxy Worker N + │ │ + └──── 本地可用代理缓存 ────┐ + │ + Proxy Control Plane + ├─ 上游供应商适配器 + ├─ IP 获取与续期 + ├─ 健康检查 + ├─ 评分与淘汰 + ├─ 会话绑定 + └─ Redis / PostgreSQL + │ + ┌───────────────┼───────────────┐ + ▼ ▼ ▼ + 上游代理商 A 上游代理商 B 自建节点 + +整个系统分成两个部分: + +1. 数据面 Proxy Worker + +真正转发流量,要求: + +无状态或弱状态 +不在每个请求中查询数据库 +不在每个请求中调用上游提取 API +使用内存中的代理列表完成选择 +支持 HTTP、HTTPS CONNECT,按需要增加 SOCKS5 +使用异步网络模型 +连接池复用上游连接 +支持失败快速切换 + +推荐使用: + +Go:开发速度快,处理数万连接比较轻松 +Rust + Tokio:性能和资源控制更强,开发成本较高 +前置负载均衡:HAProxy、Envoy 或 LVS + +不建议用普通 Python Web 框架直接承担核心转发流量。 + +2. 控制面 Control Plane + +负责管理上游代理资源: + +定时从多个供应商拉取代理 +统一不同供应商的 API 格式 +测试代理是否可用 +判断出口国家、地区、运营商 +维护过期时间 +管理失败次数和封禁情况 +将变更推送给 Proxy Worker +做供应商额度和费用统计 + +控制面可以使用 Go、Java、Python,压力通常远低于代理转发层。 + +核心数据结构 + +不要只保存 IP:端口,至少要保存: + +{ + "id": "provider-a-123456", + "provider": "provider-a", + "host": "1.2.3.4", + "port": 8080, + "protocol": "http", + "username": "user", + "password": "pass", + "region": "jp-tokyo", + "carrier": "mobile", + "pool_type": "rotating", + "expires_at": 1785220000, + "last_check_at": 1785219900, + "latency_ms": 143, + "active_connections": 28, + "success_count": 2395, + "failure_count": 61, + "consecutive_failures": 0, + "score": 87.4, + "status": "healthy" +} + +代理状态建议: + +NEW +CHECKING +HEALTHY +DEGRADED +COOLDOWN +DEAD +EXPIRED +代理选择算法 + +不要纯随机。纯随机很容易把大量请求压到已经变慢的节点上。 + +可以使用加权最少连接: + +effective_score = + 基础成功率权重 + + 延迟权重 + + 剩余有效期权重 + - 当前连接数权重 + - 连续失败惩罚 + - 最近封禁惩罚 + +例如: + +score = + success_rate * 50 + + max(0, 30 - latency_ms / 50) + + expiry_score + - active_connections * 0.2 + - consecutive_failures * 10 + +选择时,从当前得分最高的一组代理中随机挑选,而不是永远选第一名: + +候选代理 Top 20 + ↓ +按 score 加权随机 + ↓ +选中代理 + +这样既能均衡,也避免热点。 + +短效代理和固定时长代理怎么处理 + +截图里有“固定 1 分钟”“有效 1~2 分钟”等模式,这两类不能混着用。 + +固定时长代理 + +适合: + +登录态 +Cookie 会话 +连续分页 +同一个任务要求固定出口 IP +WebSocket 或长连接 + +创建一个会话绑定: + +session_id -> upstream_proxy_id + +例如客户端使用: + +Proxy-Authorization: Basic xxx +X-Proxy-Session: order-182736 + +或者把 session 放进代理用户名: + +username-session-order182736 + +会话绑定不能只存在 Redis。Proxy Worker 应有本地缓存,Redis 作为共享和恢复层。 + +短效代理 + +适合: + +无状态抓取 +单次请求 +高频轮换 +不要求保持 Cookie 会话 + +短效代理应在过期前提前停止分配。例如代理还有 10 秒过期,而请求预计可能运行 30 秒,就不能继续使用。 + +可分配条件: + +expires_at - now > expected_request_time + safety_margin + +安全余量可以设置为 15~30 秒。 + +上游代理不要“一请求一提取” + +这是很多代理池最容易踩的坑。 + +错误流程: + +客户端请求 + → 调供应商 API 提取一个 IP + → 等待返回 + → 使用代理 + +这会造成: + +上游 API 成为瓶颈 +请求延迟显著增加 +供应商接口被限频 +上游暂时故障时整个系统停摆 + +正确流程: + +后台提前获取代理 + → 健康检查 + → 放进内存池 + → 请求直接挑选 + → 代理数量低于阈值时补充 + +例如: + +目标池容量:5000 +低水位:3000 +高水位:6000 + +低于 3000 时批量补充到 6000 + +固定时长代理还需要控制购买速度,避免一次买太多后同时过期。 + +健康检查设计 + +不要只检查“能不能访问百度”。不同目标网站的可用性可能完全不同。 + +建议分三层。 + +第一层:基础连接检查 + +检查: + +TCP 是否能建立 +代理认证是否成功 +HTTP 请求是否成功 +HTTPS CONNECT 是否成功 +第二层:出口检查 + +访问自有检测接口,返回: + +{ + "ip": "1.2.3.4", + "country": "JP", + "asn": 12345, + "protocol": "https", + "timestamp": 1785219900 +} + +最好自己部署检测服务,避免公共 IP 查询服务成为瓶颈。 + +第三层:目标域名检查 + +针对不同业务建立独立评分: + +proxy_id + target_group + +例如: + +代理 1: + 通用网站成功率 99% + Google 成功率 96% + 某业务站点成功率 32% + +代理本身没有坏,只是被特定目标限制,因此不应直接全局淘汰。 + +失败切换 + +一次请求最多重试 1~2 次,不能无限重试。 + +请求代理 A + ├─ 连接失败:立刻切换 + ├─ 407:代理认证或额度异常,不重试该供应商 + ├─ 429:目标限流,切换 IP,并降低该目标评分 + ├─ 403:根据业务规则决定是否切换 + ├─ 5xx:区分目标站异常和代理异常 + └─ 超时:切换,并增加代理失败计数 + +POST 等非幂等请求不能默认自动重试,否则可能造成重复提交。 + +HTTPS CONNECT 建立后,如果中间连接断开,也不能透明重放里面的数据。 + +1 万并发的容量估算 + +假设: + +10,000 个同时在线连接 +平均每个连接 100 KB/s +流量需要一进一出 +不考虑额外协议开销 + +则总转发量大约: + +10,000 × 100 KB/s = 1 GB/s + +也就是约: + +8 Gbps 入站 + 8 Gbps 出站 + +因此代理系统真正的瓶颈往往不是 CPU,而是: + +网卡带宽 +上游带宽 +NAT 端口 +文件描述符 +内核连接跟踪 +TLS 和 CONNECT 建连 +上游供应商并发限制 + +如果每个连接平均只有 10 KB/s,则总流量约 800 Mbps,要求会低很多。 + +初始部署建议 + +先以如下规模压测: + +负载均衡: +2 台,4~8 核,主备或 ECMP + +Proxy Worker: +4 台起步 +每台 8~16 核 +16~32 GB 内存 +10 Gbps 网卡 + +控制面: +2 台,4~8 核 + +Redis: +3 节点或哨兵模式 + +PostgreSQL: +主从或云数据库 + +单台 Go Proxy Worker 承担 5,000~20,000 个空闲或低流量连接通常不难,但实际吞吐量必须按响应大小、TLS、上游延迟和带宽压测。不要根据“连接数”直接估算机器数量。 + +Linux 参数 + +至少检查这些设置: + +ulimit -n 1000000 + +常用内核参数示例: + +fs.file-max = 2000000 + +net.core.somaxconn = 65535 +net.core.netdev_max_backlog = 65535 + +net.ipv4.ip_local_port_range = 10240 65535 +net.ipv4.tcp_fin_timeout = 15 +net.ipv4.tcp_max_syn_backlog = 65535 + +net.ipv4.tcp_keepalive_time = 60 +net.ipv4.tcp_keepalive_intvl = 15 +net.ipv4.tcp_keepalive_probes = 4 + +不要盲目启用网上流传的所有 tcp_tw_reuse、conntrack 优化参数。具体设置取决于机器承担的是入口代理、出口代理还是 NAT 网关。 + +如果 Proxy Worker 通过单一出口 IP 主动连接大量相同目标,可能出现临时端口耗尽。解决方法包括: + +多个源 IP +多台 Worker +上游连接复用 +避免无意义短连接 +扩大目标代理数量 +合理控制 TIME_WAIT +Redis 应该存什么 + +Redis 可以保存: + +Session 到代理的绑定 +代理状态快照 +上游额度 +限流计数 +黑名单 +分布式锁 +Worker 注册信息 + +但请求转发时不要做: + +每个请求 → Redis 查询全部代理 → 排序 → 选一个 + +正确方式: + +控制面更新 Redis + ↓ +通过 Pub/Sub、Stream 或 gRPC 推送变化 + ↓ +Worker 在内存中维护可用代理表 + ↓ +请求只访问本机内存 + +Redis 临时故障时,现有 Worker 仍应能使用已有代理继续工作。 + +上游供应商适配层 + +每个供应商写一个 Adapter: + +type Provider interface { + Fetch(ctx context.Context, req FetchRequest) ([]Proxy, error) + Balance(ctx context.Context) (Balance, error) + ParseError(err error) ProviderError + Supports() Capabilities +} + +统一请求参数: + +type FetchRequest struct { + Count int + Region string + Protocol string + PoolType string + Lifetime time.Duration +} + +供应商返回格式即使是 TXT、JSON、HTML,也在 Adapter 中转换成统一结构。 + +身份认证和租户控制 + +对下游客户端至少需要: + +API Key 或用户名密码 +单租户并发限制 +每秒请求限制 +每日流量限制 +可访问目标限制 +来源 IP 白名单 +独立会话数限制 + +例如: + +租户 A: +最大并发 500 +新建连接速率 1000/s +流量 200 Mbps +最大固定会话 100 + +否则某一个客户就可能把整个池打满。 + +还应禁止访问: + +127.0.0.0/8 +私有网段 +云平台元数据地址 +本机管理端口 +内部数据库 +未授权的内网服务 + +特别是: + +169.254.169.254 + +否则开放代理很容易变成 SSRF 和内网攻击入口。 + +监控指标 + +Prometheus 至少采集: + +proxy_active_connections +proxy_new_connections_total +proxy_requests_total +proxy_request_duration_seconds +proxy_bytes_in_total +proxy_bytes_out_total +proxy_connect_errors_total +proxy_upstream_timeout_total +proxy_retry_total +proxy_pool_healthy +proxy_pool_degraded +proxy_pool_expired +proxy_provider_fetch_errors_total +proxy_provider_balance +proxy_target_status_total + +日志中记录: + +request_id +tenant_id +session_id +provider +proxy_id +target_host +connect_time +first_byte_time +total_time +bytes_in +bytes_out +status +error_type +retry_count + +不要默认记录完整 URL 参数、Authorization、Cookie 和请求体,避免泄露敏感信息。 + +推荐的第一版技术栈 + +比较务实的一套是: + +入口: +HAProxy + +核心代理: +Go +net/http + 自定义 CONNECT 转发 +或基于成熟代理组件二次开发 + +控制面: +Go + +配置和元数据: +PostgreSQL + +运行状态: +Redis + +配置推送: +gRPC Stream 或 Redis Stream + +监控: +Prometheus + Grafana + +日志: +Loki / Elasticsearch + +部署: +Docker Compose 起步 +规模扩大后再上 Kubernetes + +第一版不用急着做特别复杂的微服务,可以先拆成三个程序: + +proxy-gateway +proxy-controller +proxy-checker +一个合理的请求流程 +1. 客户端连接 Proxy Gateway +2. 验证客户端身份和并发额度 +3. 根据租户、地区、协议、目标域名查找本地代理池 +4. 如果存在 session_id,优先获取绑定代理 +5. 根据评分和当前连接数选择上游 +6. 建立上游连接 +7. 转发数据 +8. 记录成功率、延迟、字节数 +9. 失败时按规则切换一次代理 +10. 异步更新代理评分 + +最重要的原则是: + +转发热路径只做认证、内存选路和网络转发;提取代理、健康检查、数据库操作、评分计算全部放在后台。 + +按照你截图里的上游模式,我会优先设计成“固定会话池”和“短效轮换池”两个完全独立的资源池,再通过统一网关对外提供服务。这样比把所有 IP 混在一个列表里稳定得多。 + +可以,而且这正是控制面应该做的核心能力。建议每个上游代理商独立配置一个“有时效的弹性缓存池”。 + +每个代理商的配置 +providers: + provider_a: + enabled: true + + pool: + min_size: 1000 # 最少保持的可分配 IP + max_size: 3000 # 最多缓存的未过期 IP + target_size: 2000 # 正常情况下希望维持的数量 + + fetch: + batch_size: 200 # 每次向代理商提取多少个 + min_interval: 2s # 两次提取的最小间隔 + timeout: 5s + max_retries: 2 + max_qps: 1 # 代理商接口限速 + + lifetime: + default_ttl: 120s # 上游未返回有效期时使用 + safety_margin: 20s # 到期前 20 秒停止分配 + delete_margin: 5s # 到期后清理 + min_remaining_ttl: 30s + + health_check: + interval: 20s + timeout: 3s + max_failures: 3 + concurrency: 100 + + capacity: + max_connections_per_ip: 50 + max_requests_per_second_per_ip: 10 + +其中三个值含义不同: + +minSize 触发补充的最低水位 +targetSize 每次补充后希望达到的数量 +maxSize 绝对不能超过的缓存上限 + +例如: + +minSize = 1000 +targetSize = 2000 +maxSize = 3000 + +当可用代理降到 1000 以下时,补充到 2000,而不是直接补到 3000。 + +不能只看缓存总数 + +因为 IP 有时效,池容量至少要分为: + +total_count 当前缓存总量 +available_count 当前可分配数量 +reserved_count 已被固定会话绑定的数量 +expiring_count 即将过期数量 +checking_count 正在检测数量 +degraded_count 降级数量 +dead_count 已失效数量 + +真正决定是否补充的是 available_count,而不是 Redis 或内存中一共有多少条记录。 + +可用代理应满足: + +status == HEALTHY +并且 expires_at - now > safety_margin +并且 active_connections < max_connections_per_ip +并且没有进入 cooldown +补池计算 + +建议不要简单写成: + +available < minSize 时获取 batchSize 个 + +应该预测即将过期的代理: + +effectiveAvailable = + availableCount + - expiringSoonCount + - reservedDemand + +需要补充的数量: + +need = targetSize - effectiveAvailable + +然后限制在允许范围内: + +func calculateFetchCount( + total int, + available int, + expiringSoon int, + minSize int, + targetSize int, + maxSize int, + batchSize int, +) int { + effective := available - expiringSoon + + if effective >= minSize { + return 0 + } + + need := targetSize - effective + room := maxSize - total + + if need > room { + need = room + } + if need > batchSize { + need = batchSize + } + if need < 0 { + return 0 + } + + return need +} + +例如: + +minSize = 1000 +targetSize = 2000 +maxSize = 3000 + +当前缓存总数 = 1800 +当前可用数 = 1100 +未来 30 秒过期 = 300 + +则: + +effectiveAvailable = 1100 - 300 = 800 +need = 2000 - 800 = 1200 +maxSize 剩余空间 = 3000 - 1800 = 1200 + +最终需要补充 1200 个,可以按每批 200 个分 6 次获取。 + +时效性处理 + +代理商可能用三种方式表达有效期: + +1. 返回绝对过期时间 +{ + "ip": "1.2.3.4", + "port": 8000, + "expire_time": "2026-07-28 16:30:00" +} + +直接转换为: + +expires_at = 绝对时间 +2. 返回 TTL +{ + "ip": "1.2.3.4", + "ttl": 120 +} + +保存: + +expires_at = received_at + ttl +3. 只声明区间 + +例如截图里的: + +有效 1~2 分钟 +有效 5~10 分钟 + +这种不能按最大值算,应该保守处理。例如 1~2 分钟: + +estimated_ttl = 60 秒 +safety_margin = 15 秒 + +也就是收到约 45 秒后就停止给新请求分配,但已经建立的连接不强制立即断开。 + +代理生命周期 +FETCHED + │ + ▼ +CHECKING + │ + ├── 检测失败 ──► DEAD + │ + ▼ +HEALTHY + │ + ├── 即将过期 ──► DRAINING + │ + ├── 连续失败 ──► COOLDOWN + │ + └── 已过期 ──► EXPIRED + ▼ +DRAINING + │ + └── 无活动连接 ──► EXPIRED + +DRAINING 很重要: + +不再分配给新请求 +已经建立的连接允许继续使用 +固定会话需要提前迁移或等待结束 +活跃连接归零后删除 +定时补充 + 事件触发补充 + +不要只依赖定时任务,建议两种机制一起用。 + +定时检查 + +例如每秒检查一次池容量: + +ticker := time.NewTicker(time.Second) + +检查: + +当前可用数 +未来一段时间将过期数 +正在提取数 +代理商 API 限额 +当前缓存总数 +事件触发 + +以下情况可以立即触发补池: + +代理批量过期 +健康检查批量失败 +流量突然升高 +固定会话大量创建 +availableCount 低于 emergencySize + +但多个 Worker 不能同时直接去调用供应商 API。提取操作必须集中在控制面,并加单飞或分布式锁: + +provider_id -> 一个时刻只能有一个 fetch 任务 +防止重复提取和超出 maxSize + +提取是异步的,所以必须统计“正在获取但尚未入池”的数量: + +projectedSize = totalCount + fetchingCount + +判断空间: + +room = maxSize - projectedSize + +否则会出现: + +当前 2800 +Worker A 判断还能拿 200 +Worker B 判断还能拿 200 +最终变成 3200 + +建议控制面维护: + +type ProviderRuntime struct { + TotalCount int64 + AvailableCount int64 + FetchingCount int64 + ExpiringCount int64 + LastFetchAt time.Time + ConsecutiveError int +} + +使用数据库锁、Redis 锁或单控制器 Leader 保证获取操作串行化。 + +高并发时不要按 IP 数量盲目配置 + +minSize/maxSize 最好还要结合每个代理可承载的并发量。 + +例如: + +目标总并发:10000 +每个 IP 最大并发:20 +安全系数:1.5 + +理论最少代理数量: + +10000 ÷ 20 × 1.5 = 750 + +所以可以设置: + +minSize = 800 +targetSize = 1200 +maxSize = 2000 + +但代理商可能限制单 IP QPS、带宽或目标站请求频率,因此真正容量应按下面三项中的最小值计算: + +连接容量 +QPS 容量 +带宽容量 + +例如某个代理允许: + +最大并发:20 +最大 QPS:5 +最大带宽:2 MB/s + +即使连接没有达到 20,也可能先达到 QPS 或带宽限制。 + +多代理商独立池 + +建议每家供应商各自维护池,不要一开始就混成一张无差别列表: + +Provider A Pool +Provider B Pool +Provider C Pool + +每个池都有自己的: + +minSize +targetSize +maxSize +TTL +获取频率 +获取批次 +单 IP 并发 +余额 +错误率 +优先级 +成本 + +在其上再做一个聚合调度层: + +routing: + strategy: weighted_score + + providers: + provider_a: + weight: 50 + priority: 1 + provider_b: + weight: 30 + priority: 2 + provider_c: + weight: 20 + priority: 3 + +调度时可以考虑: + +可用率 +延迟 +剩余有效时间 +当前负载 +供应商成本 +供应商余额 +目标站成功率 +建议增加动态水位 + +固定 minSize 能工作,但遇到流量波动会比较迟钝。可以根据最近几分钟的使用速率动态计算: + +required = + 当前活跃代理需求 + + 未来 refillWindow 内预计消耗量 + + 安全冗余 + +例如: + +最近每秒消耗 10 个短效代理 +代理商获取并验证需要 15 秒 +安全冗余 300 个 + +动态 minSize = 10 × 15 + 300 = 450 + +最终水位: + +effectiveMinSize = max(configuredMinSize, dynamicMinSize) + +这样流量增加时会提前补充,流量下降时又不会一直购买过多 IP。 + +推荐的配置模型 +type ProviderConfig struct { + ID string + Enabled bool + + MinSize int + TargetSize int + MaxSize int + + BatchSize int + FetchInterval time.Duration + FetchTimeout time.Duration + MaxFetchRetries int + FetchQPS float64 + + DefaultTTL time.Duration + SafetyMargin time.Duration + ExpiringWindow time.Duration + + MaxConnectionsPerIP int + MaxQPSPerIP int + + CheckInterval time.Duration + CheckTimeout time.Duration + MaxFailures int +} + +核心补池逻辑可以概括为: + +func reconcileProvider(ctx context.Context, p *ProviderRuntime) { + p.RemoveExpired() + p.MarkExpiringAsDraining() + p.ScheduleHealthChecks() + + effectiveAvailable := + p.AvailableCount() - + p.ExpiringWithin(p.Config.ExpiringWindow) + + if effectiveAvailable >= p.Config.MinSize { + return + } + + projectedTotal := p.TotalCount() + p.FetchingCount() + room := p.Config.MaxSize - projectedTotal + need := p.Config.TargetSize - effectiveAvailable + + count := min(need, room, p.Config.BatchSize) + if count <= 0 { + return + } + + p.FetchAsync(ctx, count) +} + +所以答案是:完全可以配置 minSize/maxSize,但最好再加 targetSize、fetchingCount、expiringCount 和安全过期窗口。 单纯使用最大、最小两个值,在短效 IP 大批量同时过期时很容易补池不及时。 + + +对,这里你说得对。batch_size 不应该被当作“系统要求上游一次返回多少个”,而应该拆成两类概念: + +fetch: + request_count_supported: false # 上游是否支持指定提取数量 + upstream_batch_size: 200 # 上游每次实际固定返回数量 + max_fetch_calls_per_cycle: 5 # 一轮最多调用几次 + min_interval: 2s + +如果上游接口每次固定返回 200 个,那系统只能决定: + +调用几次 +什么时候调用 +返回后保留多少 +超出 maxSize 怎么处理 + +而不能决定单次提取数量。 + +正确的补池计算 + +假设: + +minSize = 1000 +targetSize = 1500 +maxSize = 2000 +上游每次固定返回 200 个 +当前有效可用 = 850 +当前缓存总量 = 1200 + +希望补到 1500: + +need = 1500 - 850 = 650 + +因为每次固定返回 200: + +调用次数 = ceil(650 / 200) = 4 次 +预计获取 = 800 个 + +但是还要检查 maxSize: + +剩余容量 = 2000 - 1200 = 800 + +所以刚好可以调用 4 次。 + +计算逻辑: + +need := targetSize - effectiveAvailable +room := maxSize - totalCount - fetchingCount + +maxReceivable := min(need, room) + +calls := int(math.Ceil( + float64(maxReceivable) / float64(upstreamBatchSize), +)) + +不过这里有个坑:上游按固定批次返回时,不能只检查 need,必须检查整个批次能否放下。 + +例如: + +剩余空间 room = 150 +上游固定返回 = 200 + +此时不能再调用,否则会超过 maxSize。 + +if room < upstreamBatchSize { + return +} + +完整一点: + +func calculateFetchCalls( + effectiveAvailable int, + totalCount int, + fetchingCount int, + minSize int, + targetSize int, + maxSize int, + upstreamBatchSize int, + maxCallsPerCycle int, +) int { + if effectiveAvailable >= minSize { + return 0 + } + + room := maxSize - totalCount - fetchingCount + if room < upstreamBatchSize { + return 0 + } + + need := targetSize - effectiveAvailable + if need <= 0 { + return 0 + } + + callsByNeed := int(math.Ceil( + float64(need) / float64(upstreamBatchSize), + )) + + callsByRoom := room / upstreamBatchSize + + calls := min(callsByNeed, callsByRoom, maxCallsPerCycle) + return max(calls, 0) +} +更合适的配置方式 + +每家供应商单独描述它的提取能力: + +providers: + provider_a: + pool: + min_size: 1000 + target_size: 1500 + max_size: 2000 + + fetch: + mode: fixed_batch + fixed_batch_size: 200 + max_calls_per_cycle: 5 + min_call_interval: 2s + max_calls_per_minute: 20 + +支持指定数量的供应商: + +fetch: + mode: variable_batch + min_request_size: 1 + max_request_size: 500 + preferred_request_size: 200 + +一次只返回一个的供应商: + +fetch: + mode: single + fixed_batch_size: 1 + max_calls_per_cycle: 100 + concurrency: 10 +建议定义统一能力模型 +type FetchMode string + +const ( + FetchModeFixedBatch FetchMode = "fixed_batch" + FetchModeVariableBatch FetchMode = "variable_batch" + FetchModeSingle FetchMode = "single" +) + +type FetchCapability struct { + Mode FetchMode + + FixedBatchSize int + + MinRequestSize int + MaxRequestSize int + + MaxCallsPerCycle int + MaxCallsPerMinute int + MinCallInterval time.Duration +} + +调用接口也不要强制每家供应商都接收 count: + +type Provider interface { + Capability() FetchCapability + Fetch(ctx context.Context, req FetchRequest) ([]Proxy, error) +} +type FetchRequest struct { + RequestedCount int +} + +对于固定批次供应商,RequestedCount 可以忽略: + +func (p *ProviderA) Fetch( + ctx context.Context, + req FetchRequest, +) ([]Proxy, error) { + // 上游固定返回 200 个,不使用 req.RequestedCount + return p.callUpstream(ctx) +} +超出 maxSize 怎么办 + +实际接口偶尔可能: + +固定说返回 200,实际返回 203 +返回重复 IP +一次调用返回数量不稳定 +请求重试导致重复返回 + +因此入池时还要做二次限制: + +remaining := maxSize - currentTotal + +for _, proxy := range fetched { + if remaining <= 0 { + break + } + + if pool.Exists(proxy.UniqueKey()) { + continue + } + + pool.Add(proxy) + remaining-- +} + +但如果这些 IP 是按次收费,拿到后丢掉就浪费了。所以更好的做法是预留一个“软上限”和“硬上限”: + +pool: + min_size: 1000 + target_size: 1500 + soft_max_size: 2000 + hard_max_size: 2200 + +含义: + +softMaxSize:正常情况下不再主动提取 +hardMaxSize:允许固定批次造成的小幅溢出 + +例如上游固定每次 200 个: + +softMaxSize = 2000 +hardMaxSize = 2200 + +当前 1900 个时,若确实低于最低可用水位,可以再提取一批,最终达到 2100,而不是把多出的 100 个直接丢掉。 + +所以这里建议把原来的: + +batch_size: 200 + +改成: + +fixed_batch_size: 200 + +并明确它表示: + +上游单次固定返回量,而不是本系统要求的提取数量。 + + +对,这两个必须进入核心调度模型,不能当普通配置项: + +单个代理 IP 的并发容量限制 +上游代理商提取 API 的调用频率限制 + +否则会出现“池里明明有很多 IP,但全部已满载”以及“多个控制器同时补池,把上游 API 打到限流”的情况。 + +一、单 IP 并发容量 + +每个代理记录容量和实时负载: + +type ProxyNode struct { + ID string + Provider string + + Host string + Port int + ExpireAt time.Time + Status ProxyStatus + + MaxConcurrency int64 + ActiveConcurrency atomic.Int64 + + ReservedConcurrency atomic.Int64 +} + +是否可分配不能只判断健康状态: + +func (p *ProxyNode) Allocatable(now time.Time, safetyMargin time.Duration) bool { + return p.Status == StatusHealthy && + p.ExpireAt.Sub(now) > safetyMargin && + p.ActiveConcurrency.Load()+p.ReservedConcurrency.Load() < + p.MaxConcurrency +} + +剩余容量为: + +remainingCapacity = + maxConcurrency + - activeConcurrency + - reservedConcurrency + +例如: + +代理 IP 数量:1000 +每个 IP 最大并发:10 +理论总容量:10000 + +但若其中: + +200 个即将过期 +100 个健康检查失败 +剩余 700 个 + +实际总容量只有: + +700 × 10 = 7000 并发 + +所以补池触发条件不能只看: + +availableIPCount < minSize + +还要看: + +availableConcurrency < minConcurrency +二、池水位改成双水位 + +每个供应商同时配置 IP 数量水位和并发容量水位: + +providers: + provider_a: + pool: + min_ip_size: 1000 + target_ip_size: 1500 + soft_max_ip_size: 2000 + hard_max_ip_size: 2200 + + min_concurrency: 8000 + target_concurrency: 12000 + + proxy: + default_max_concurrency: 10 + allocation_safety_ratio: 0.8 + +allocation_safety_ratio: 0.8 表示上游声称单 IP 支持 10 并发,内部只按 8 并发分配。 + +这样可以给延迟回收、统计误差和突发流量留余地: + +effectiveMaxConcurrency = + upstreamMaxConcurrency × safetyRatio + +例如: + +上游限制 20 +安全系数 0.8 +内部最大并发 16 +三、补池条件 + +建议满足任意一个条件就补池: + +有效 IP 数量 < minIpSize + +或者 + +可用并发容量 < minConcurrency + +计算: + +type PoolSnapshot struct { + TotalIPs int + AvailableIPs int + ExpiringIPs int + FetchingExpectedIPs int + + TotalConcurrency int64 + AvailableConcurrency int64 +} +func shouldRefill( + snapshot PoolSnapshot, + cfg ProviderPoolConfig, +) bool { + return snapshot.AvailableIPs < cfg.MinIPSize || + snapshot.AvailableConcurrency < cfg.MinConcurrency +} + +可用并发容量应该逐个代理计算: + +func availableConcurrency(proxies []*ProxyNode) int64 { + var total int64 + + for _, proxy := range proxies { + if !proxy.Allocatable(time.Now(), 20*time.Second) { + continue + } + + remaining := + proxy.MaxConcurrency - + proxy.ActiveConcurrency.Load() - + proxy.ReservedConcurrency.Load() + + if remaining > 0 { + total += remaining + } + } + + return total +} +四、并发槽位必须原子预占 + +不能先读: + +active = 9 +max = 10 + +然后多个请求同时认为还有一个位置。需要使用原子 CAS 预占: + +func (p *ProxyNode) TryAcquire() bool { + for { + current := p.ActiveConcurrency.Load() + + if current >= p.MaxConcurrency { + return false + } + + if p.ActiveConcurrency.CompareAndSwap(current, current+1) { + return true + } + } +} + +func (p *ProxyNode) Release() { + value := p.ActiveConcurrency.Add(-1) + + if value < 0 { + p.ActiveConcurrency.Store(0) + // 记录严重告警 + } +} + +请求流程: + +proxy := selector.Select() + +if !proxy.TryAcquire() { + // 代理刚刚被其他请求占满,重新选择 + continue +} + +conn, err := connect(proxy) + +if err != nil { + proxy.Release() + markFailure(proxy) + continue +} + +defer proxy.Release() + +“选择代理”和“占用槽位”不能完全分开,否则高并发下会产生超卖。 + +五、多个 Proxy Worker 的全局并发问题 + +如果同一个 IP 能同时被多个 Worker 使用,仅使用进程内 atomic 不够。 + +例如: + +IP 最大并发 10 + +Worker A 认为用了 7 +Worker B 认为用了 6 + +真实并发变成 13 + +有三种方案。 + +方案 A:IP 归属固定 Worker +proxyID 哈希到指定 Worker +owner := hash(proxy.ID) % workerCount + +一个代理 IP 只由一个 Worker 使用。 + +优点: + +不需要每请求访问 Redis +原子计数只在本机 +性能最好 +最适合一万以上并发 + +缺点: + +Worker 下线后需要重新分配 +各 Worker 负载可能不完全均衡 + +这是我最推荐的方案。 + +方案 B:按容量切片分配 + +控制面把同一个 IP 的容量拆给不同 Worker: + +IP 最大并发:20 + +Worker A:8 个槽位 +Worker B:6 个槽位 +Worker C:6 个槽位 + +每个 Worker 只管理自己的本地额度: + +type ProxyLease struct { + ProxyID string + WorkerID string + CapacityQuota int + LeaseExpireAt time.Time +} + +控制面定期调整配额,Worker 宕机后租约自动过期。 + +这个方案性能和均衡性都不错,但实现比固定归属复杂。 + +方案 C:每次请求操作 Redis 计数 +INCR proxy:{id}:active + +超过上限则回滚。 + +不建议热路径使用。每秒一万次以上获取和释放会产生大量 Redis 操作,还会把网络延迟放进代理转发路径。 + +六、提取 API 每秒最多 5 次 + +这个限制必须在整个集群范围内生效,而不是每个控制器各自 5 次。 + +配置: + +providers: + provider_a: + fetch: + rate_limit: + requests_per_second: 5 + burst: 5 + + max_in_flight: 2 + request_timeout: 5s + retry: + max_attempts: 3 + base_delay: 500ms + max_delay: 10s + +这里有两个不同限制: + +requests_per_second:单位时间内最多发起多少次 +max_in_flight:同一时刻最多有多少个请求尚未完成 + +即使每秒允许请求 5 次,也不代表允许积压 100 个未完成请求。 + +七、使用令牌桶限流 + +Go 可以使用 golang.org/x/time/rate: + +type ProviderFetcher struct { + limiter *rate.Limiter + semaphore chan struct{} + provider Provider +} + +func NewProviderFetcher(provider Provider) *ProviderFetcher { + return &ProviderFetcher{ + // 每秒 5 个令牌,最多积攒 5 个 + limiter: rate.NewLimiter(rate.Limit(5), 5), + + // 同时最多两个提取请求 + semaphore: make(chan struct{}, 2), + + provider: provider, + } +} + +调用上游前: + +func (f *ProviderFetcher) Fetch(ctx context.Context) ([]Proxy, error) { + if err := f.limiter.Wait(ctx); err != nil { + return nil, err + } + + select { + case f.semaphore <- struct{}{}: + defer func() { <-f.semaphore }() + case <-ctx.Done(): + return nil, ctx.Err() + } + + return f.provider.Fetch(ctx) +} + +但它只限制单进程。为了保证全局每秒最多 5 次,最好让每个供应商只有一个负责提取的逻辑实例。 + +八、推荐单 Provider 单 Fetch Leader + +不要让所有 Worker 自己补池: + +错误结构: + +Worker 1 ──调用供应商 +Worker 2 ──调用供应商 +Worker 3 ──调用供应商 + +正确结构: + +所有 Proxy Worker + │ + ▼ +报告容量和需求 + │ + ▼ +Provider Controller Leader + │ + ├─ 全局限流器 + ├─ 补池计划 + ├─ 请求去重 + └─ 调用供应商 API + +每个代理商一个逻辑调度器: + +type ProviderController struct { + Config ProviderConfig + Fetcher *ProviderFetcher + Pool *ProviderPool + + reconcileSignal chan struct{} +} + +即使控制面部署多实例,也通过 Leader Election 保证同一供应商只有一个实例执行 Fetch。 + +可选方式: + +Kubernetes Lease +etcd 租约 +PostgreSQL advisory lock +Redis 带租约锁 +九、不能因为缺口大就瞬间请求 5 次 + +上游限制是每秒 5 次,但最好不要总是在一秒开始时瞬间打满。 + +例如缺 1000 个 IP、上游每次返回 100 个,需要请求 10 次: + +错误: +00ms 连续请求 5 次 +1000ms 再连续请求 5 次 + +更平滑: + +0ms +200ms +400ms +600ms +800ms +1000ms +... + +也就是: + +5 次/秒 = 平均每 200ms 一次 + +令牌桶可以允许突发。如果上游严格采用滑动窗口,应配置: + +requests_per_second: 5 +burst: 1 + +对应: + +rate.NewLimiter(rate.Limit(5), 1) + +这样基本会平滑成每 200ms 一次。 + +十、429 或限流错误的处理 + +上游报错后不能立即疯狂重试: + +func retryDelay(attempt int) time.Duration { + base := 500 * time.Millisecond + maxDelay := 30 * time.Second + + delay := base * time.Duration(1< maxDelay { + delay = maxDelay + } + + jitter := time.Duration(rand.Int63n( + int64(delay / 4), + )) + + return delay + jitter +} + +例如: + +第 1 次:约 500ms +第 2 次:约 1s +第 3 次:约 2s +第 4 次:约 4s + +对于上游 429: + +读取 Retry-After +暂停该供应商 Fetch +不计为代理 IP 本身失败 +提高补池紧急度,但不能绕过限流器 +必要时从其他供应商补容量 +十一、补池计划要考虑获取速度 + +假设: + +上游每次返回 20 个 IP +最多 5 次/秒 + +最大获取速度: + +20 × 5 = 100 个 IP/秒 + +每个 IP 可用并发为 10,则理论容量补充速度: + +100 × 10 = 1000 并发槽位/秒 + +如果当前容量缺口是 5000: + +最快需要约 5 秒 + +还没有算: + +API 响应时间 +IP 健康检查 +重复 IP +无效 IP +提前过期 IP + +因此不能等容量跌破最低值才开始获取,必须预测获取提前量。 + +十二、动态补池触发线 + +假设: + +当前可用并发容量:12000 +当前并发:9000 +最近增长速度:每秒增加 500 +获取并验证新容量需要:5 秒 +安全冗余:2000 + +未来 5 秒需求: + +500 × 5 = 2500 + +触发补池所需容量: + +预计需求 + 安全冗余 += 2500 + 2000 += 4500 + +当前剩余: + +12000 - 9000 = 3000 + +由于: + +3000 < 4500 + +应立即开始补池,即使 IP 数量还没有低于 minSize。 + +可以使用: + +requiredReserve = + concurrencyGrowthRate × refillLeadTime + + safetyConcurrency + +补池条件: + +availableConcurrency - activeConcurrency + < requiredReserve +十三、完整配置示例 +providers: + provider_a: + enabled: true + + pool: + min_ip_size: 1000 + target_ip_size: 1500 + soft_max_ip_size: 2000 + hard_max_ip_size: 2200 + + min_available_concurrency: 8000 + target_available_concurrency: 12000 + safety_concurrency: 2000 + + proxy_capacity: + default_max_concurrency: 10 + safety_ratio: 0.8 + + lifetime: + default_ttl: 120s + allocation_safety_margin: 20s + expiring_window: 30s + + fetch: + mode: fixed_response + + estimated_ips_per_call: 100 + + rate_limit: + requests_per_second: 5 + burst: 1 + + max_in_flight: 2 + max_calls_per_reconcile: 10 + + timeout: 5s + cooldown_after_429: 10s + + retry: + max_attempts: 3 + base_delay: 500ms + max_delay: 30s + + health_check: + timeout: 3s + concurrency: 100 + max_failures: 3 + +注意这里用了: + +estimated_ips_per_call: 100 + +因为上游不一定保证每次准确返回多少个。实际补池数量必须根据历史平均值动态估算: + +最近 100 次调用平均返回 93 个 +重复率 5% +健康检查通过率 90% + +每次有效新增约: +93 × 0.95 × 0.90 ≈ 79 个 + +调度器应该按约 79 个有效 IP/次计算,而不是按接口宣称的 100 个。 + +十四、最终补池计算 +type RefillPlan struct { + Calls int + ExpectedIPs int +} + +func calculateRefillPlan( + capacityDeficit int64, + avgConcurrencyPerIP float64, + effectiveIPsPerCall float64, + maxCalls int, +) RefillPlan { + if capacityDeficit <= 0 || + avgConcurrencyPerIP <= 0 || + effectiveIPsPerCall <= 0 { + return RefillPlan{} + } + + neededIPs := int(math.Ceil( + float64(capacityDeficit) / avgConcurrencyPerIP, + )) + + calls := int(math.Ceil( + float64(neededIPs) / effectiveIPsPerCall, + )) + + if calls > maxCalls { + calls = maxCalls + } + + return RefillPlan{ + Calls: calls, + ExpectedIPs: int( + float64(calls) * effectiveIPsPerCall, + ), + } +} + +不过 Calls 只是加入提取队列,真正执行仍必须经过: + +全局限流器 +→ 并发请求限制 +→ 上游冷却状态 +→ soft/hard maxSize 检查 +→ Leader 权限检查 + +核心设计可以概括成一句话: + +代理池管理的不是 IP 数量,而是“未过期、健康且尚未占用的并发槽位”;上游提取管理的也不是调用次数,而是“受全局速率限制的容量补充通道”。 + + +对,所以限流一定要做成“每个上游代理商独立能力配置”,不能全局写死 5 次/秒。 + +建议把每个 Provider 的提取限制建模为以下几种模式: + +UNLIMITED 上游声明不限制调用频率 +RATE_LIMITED 按每秒/每分钟限制 +INTERVAL 两次调用之间必须间隔固定时间 +WINDOW_LIMITED 固定窗口或滑动窗口内最多 N 次 +配置示例 +上游 A:每秒最多 5 次 +providers: + provider_a: + fetch: + rate_limit: + mode: rate_limited + requests: 5 + period: 1s + burst: 1 + + max_in_flight: 2 + timeout: 5s +上游 B:每秒最多 1 次 +providers: + provider_b: + fetch: + rate_limit: + mode: rate_limited + requests: 1 + period: 1s + burst: 1 + + max_in_flight: 1 + timeout: 5s +上游 C:每分钟最多 20 次 +providers: + provider_c: + fetch: + rate_limit: + mode: rate_limited + requests: 20 + period: 1m + burst: 1 + + max_in_flight: 1 +上游 D:最少间隔 3 秒 +providers: + provider_d: + fetch: + rate_limit: + mode: interval + min_interval: 3s + + max_in_flight: 1 +上游 E:不限制 +providers: + provider_e: + fetch: + rate_limit: + mode: unlimited + + max_in_flight: 10 + +不过“无限”不能真的无限制发送。系统仍然要有自己的保护值: + +providers: + provider_e: + fetch: + rate_limit: + mode: unlimited + + max_in_flight: 10 + internal_max_qps: 100 + +避免程序异常时进入死循环,把上游和自己都打挂。 + +Go 配置结构 +type RateLimitMode string + +const ( + RateLimitUnlimited RateLimitMode = "unlimited" + RateLimitRequests RateLimitMode = "rate_limited" + RateLimitInterval RateLimitMode = "interval" +) + +type FetchRateLimitConfig struct { + Mode RateLimitMode + + // rate_limited 模式使用 + Requests int + Period time.Duration + Burst int + + // interval 模式使用 + MinInterval time.Duration + + // unlimited 模式下的内部保护上限 + InternalMaxQPS float64 +} + +type ProviderFetchConfig struct { + RateLimit RateLimitConfig + + MaxInFlight int + Timeout time.Duration + + MaxQueueSize int +} +每个 Provider 都有独立限流器 + +不能共用一个全局 limiter: + +type ProviderFetcher struct { + providerID string + + limiter FetchLimiter + + semaphore chan struct{} + queue chan FetchTask +} + +控制面内部结构: + +Provider A → 独立队列 → 5 次/秒限流器 +Provider B → 独立队列 → 1 次/秒限流器 +Provider C → 独立队列 → 20 次/分钟限流器 +Provider D → 独立队列 → 3 秒最小间隔 +Provider E → 独立队列 → 内部保护限制 + +这样 A 被限流不会阻塞 B,B 响应慢也不会影响 C。 + +统一限流接口 +type FetchLimiter interface { + Wait(ctx context.Context) error +} +不限流实现 +type UnlimitedLimiter struct{} + +func (l *UnlimitedLimiter) Wait(ctx context.Context) error { + return ctx.Err() +} + +这里要注意,ctx.Err() 在正常情况下是 nil,所以可以直接通过。 + +更清晰地写: + +func (l *UnlimitedLimiter) Wait(ctx context.Context) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + return nil + } +} +令牌桶实现 +type TokenBucketLimiter struct { + limiter *rate.Limiter +} + +func NewTokenBucketLimiter( + requests int, + period time.Duration, + burst int, +) *TokenBucketLimiter { + tokensPerSecond := + float64(requests) / period.Seconds() + + return &TokenBucketLimiter{ + limiter: rate.NewLimiter( + rate.Limit(tokensPerSecond), + burst, + ), + } +} + +func (l *TokenBucketLimiter) Wait(ctx context.Context) error { + return l.limiter.Wait(ctx) +} + +例如: + +5 次/秒: +requests = 5 +period = 1s + +1 次/秒: +requests = 1 +period = 1s + +20 次/分钟: +requests = 20 +period = 1m +固定间隔实现 + +对于“每次调用必须至少间隔 3 秒”,用普通令牌桶不一定完全符合上游规则,可以单独实现: + +type IntervalLimiter struct { + mu sync.Mutex + nextAllowed time.Time + interval time.Duration +} + +func (l *IntervalLimiter) Wait(ctx context.Context) error { + l.mu.Lock() + + now := time.Now() + wait := time.Duration(0) + + if now.Before(l.nextAllowed) { + wait = l.nextAllowed.Sub(now) + } + + reservedAt := now.Add(wait) + l.nextAllowed = reservedAt.Add(l.interval) + + l.mu.Unlock() + + timer := time.NewTimer(wait) + defer timer.Stop() + + select { + case <-timer.C: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} +限流和并发限制是两回事 + +例如上游配置: + +每秒最多请求 5 次 +每次响应耗时 10 秒 + +如果只限制 QPS,最终可能同时堆积 50 个未完成请求。 + +所以还需要: + +rate_limit: + requests: 5 + period: 1s + +max_in_flight: 2 + +执行顺序建议是: + +任务进入 Provider 队列 + ↓ +等待并发槽位 + ↓ +等待速率令牌 + ↓ +调用上游 API + ↓ +释放并发槽位 + +示例: + +func (f *ProviderFetcher) execute( + ctx context.Context, +) ([]Proxy, error) { + select { + case f.semaphore <- struct{}{}: + defer func() { <-f.semaphore }() + case <-ctx.Done(): + return nil, ctx.Err() + } + + if err := f.limiter.Wait(ctx); err != nil { + return nil, err + } + + callCtx, cancel := context.WithTimeout( + ctx, + f.config.Timeout, + ) + defer cancel() + + return f.provider.Fetch(callCtx) +} +不要让补池任务无限排队 + +假设上游每秒只能调用一次,但容量计算不断产生补池任务: + +第 1 秒:需要调用 10 次 +第 2 秒:又添加 10 次 +第 3 秒:又添加 10 次 + +很快就会积压几千个过时任务。 + +更合理的做法不是“每缺一批就添加一个任务”,而是维护每家上游的目标缺口: + +type ProviderDemand struct { + DesiredEffectiveIPs int + CurrentEffectiveIPs int + PendingExpectedIPs int +} + +调度器每次重新计算: + +实时缺口 = + 目标容量 + - 当前有效容量 + - 正在请求预计获得的容量 + +不要累积历史任务,只保留最新需求。 + +可以给每个 Provider 一个容量为 1 的通知通道: + +reconcileSignal := make(chan struct{}, 1) + +触发时: + +select { +case reconcileSignal <- struct{}{}: +default: + // 已有待处理信号,不重复排队 +} + +Controller 醒来后读取实时池状态,再决定是否调用上游。 + +429 后动态降速 + +即使配置写的是每秒 5 次,上游实际规则可能发生变化,或者它使用严格滑动窗口。 + +收到限流错误后,应自动进入降速状态: + +正常:5 次/秒 +收到 429:降到 2.5 次/秒 +再次 429:降到 1.25 次/秒 +稳定一段时间后逐步恢复 + +可以维护: + +type AdaptiveRateState struct { + ConfiguredRate float64 + EffectiveRate float64 + + Consecutive429 int + CooldownUntil time.Time +} + +但配置值仍是绝对上限: + +effectiveRate <= configuredRate + +恢复可以采用缓慢增加: + +每 30 秒无 429: +effectiveRate += configuredRate × 10% + +遇到 429: + +effectiveRate *= 0.5 + +如果响应带有 Retry-After,优先遵守。 + +“无限制供应商”的处理 + +即使上游说不限制,建议仍配置: + +rate_limit: + mode: unlimited + +safety: + internal_max_qps: 100 + max_in_flight: 10 + max_calls_per_reconcile: 20 + max_queue_wait: 30s + +因为实际瓶颈可能是: + +上游服务器连接数 +本机文件描述符 +DNS +出口端口 +提取费用 +上游突然增加隐藏限制 +程序 Bug 造成死循环 + +所以 unlimited 应理解为: + +没有供应商明确限制,但仍接受本系统保护限制。 + +最终配置模型 +providers: + provider_a: + pool: + min_ip_size: 500 + target_ip_size: 800 + soft_max_ip_size: 1000 + hard_max_ip_size: 1100 + + min_available_concurrency: 8000 + target_available_concurrency: 12000 + + proxy_capacity: + default_max_concurrency: 16 + + fetch: + response_mode: fixed + estimated_ips_per_call: 100 + + limit: + mode: rate_limited + requests: 5 + period: 1s + burst: 1 + + max_in_flight: 2 + max_calls_per_reconcile: 10 + timeout: 5s + + provider_b: + pool: + min_ip_size: 200 + target_ip_size: 400 + soft_max_ip_size: 500 + hard_max_ip_size: 550 + + proxy_capacity: + default_max_concurrency: 8 + + fetch: + response_mode: fixed + estimated_ips_per_call: 20 + + limit: + mode: rate_limited + requests: 1 + period: 1s + burst: 1 + + max_in_flight: 1 + max_calls_per_reconcile: 5 + + provider_c: + fetch: + limit: + mode: unlimited + + safety: + internal_max_qps: 100 + + max_in_flight: 10 + max_calls_per_reconcile: 20 + +还需要一个校验规则,防止错误配置: + +func validate(cfg ProviderFetchConfig) error { + switch cfg.Limit.Mode { + case RateLimitRequests: + if cfg.Limit.Requests <= 0 { + return errors.New("requests must be greater than zero") + } + if cfg.Limit.Period <= 0 { + return errors.New("period must be greater than zero") + } + + case RateLimitInterval: + if cfg.Limit.MinInterval <= 0 { + return errors.New("min_interval must be greater than zero") + } + + case RateLimitUnlimited: + if cfg.Safety.InternalMaxQPS <= 0 { + return errors.New( + "unlimited provider requires internal_max_qps", + ) + } + + default: + return errors.New("unsupported rate limit mode") + } + + if cfg.MaxInFlight <= 0 { + return errors.New("max_in_flight must be greater than zero") + } + + return nil +} + +因此最终原则就是: + +每家上游都有独立的获取队列、独立限流器、独立并发限制和独立冷却状态;配置写的是该供应商能力,不是整个代理池的统一规则。 + + +这种配置思路是可行的,而且比前面那种复杂模型更适合先做第一版。不过按你现在这份配置,能跑,但扛不住高并发代理池场景。主要缺几个关键能力: + +只有 maxSize,没有 minSize +没有单 IP 最大并发 +requestInterval 只能表达固定间隔,不能很好表达“每秒 5 次、允许突发 2 次” +没有区分正在提取、正在检测、可分配、即将过期 +apiRetryNumber 和业务请求重试概念容易混淆 +多个代理商写成 proxy: proxy1,proxy2,但没有明确选择策略 +提取 API 返回多个 IP 时,模板可能把地址黏在一起 +上游失败后直接直连,对部分业务可能有安全风险 +你当前配置表达的含义 + +例如: + +upstream: + proxy: + lifecycle: 20 + maxSize: 1 + requestInterval: 100ms + +大致表示: + +最多缓存 1 个代理 IP +代理使用 20 秒后主动失效 +两次请求代理 API 至少间隔 100ms + +从理论上说,100ms 对应最高: + +1000ms ÷ 100ms = 10 次/秒 + +所以: + +requestInterval: 1000ms + +相当于每秒最多一次。 + +requestInterval: 200ms + +相当于每秒最多五次。 + +这个设计本身没有问题,甚至很直观。 + +建议保留 requestInterval + +对于你的配置风格,不一定要引入复杂的: + +requests: 5 +period: 1s + +直接这样就够清晰: + +requestInterval: 200ms + +含义: + +同一个上游代理商的两次 API 调用至少间隔 200ms。 + +对应关系: + +无限制:0s +每秒 10 次:100ms +每秒 5 次:200ms +每秒 2 次:500ms +每秒 1 次:1s +每 3 秒一次:3s + +但 0s 最好不要真无限,建议增加系统保护值: + +requestInterval: 0s +internalRequestInterval: 10ms + +或者规定: + +requestInterval <= 0 时,内部默认最低间隔 10ms + +否则程序 Bug 可能瞬间疯狂调用 API。 + +需要加入 minSize + +你目前只有: + +maxSize: 5 + +这只能表示“最多缓存几个”,无法表示什么时候补充。 + +建议: + +minSize: 3 +maxSize: 5 + +含义: + +可用 IP 少于 3 个时开始补充 +最多缓存 5 个有效 IP + +不过由于你的代理 API 一次只提取一个,这个模型刚好很适合。 + +补池逻辑: + +availableSize < minSize + ↓ +按 requestInterval 调用 API + ↓ +直到 availableSize 达到 maxSize + +例如: + +minSize: 3 +maxSize: 5 +requestInterval: 1000ms + +当前只剩 1 个可用代理,需要补到 5 个: + +第 0 秒请求一次 +第 1 秒请求一次 +第 2 秒请求一次 +第 3 秒请求一次 + +最少约 3~4 秒完成。 + +必须增加单 IP 并发限制 + +这是你当前配置里最重要的缺失项。 + +建议增加: + +maxConcurrencyPerIP: 10 + +完整一点: + +maxConcurrencyPerIP: 10 +concurrencySafetyRatio: 0.8 + +如果上游说单个代理支持 10 并发,内部只使用: + +10 × 0.8 = 8 + +也可以直接只配置最终限制: + +maxConcurrencyPerIP: 8 + +代理选择时需要判断: + +activeConcurrency < maxConcurrencyPerIP + +如果全部代理都满了,则: + +等待空闲槽位 +使用其他上游代理商 +根据策略直连 +返回“代理容量不足” + +不能继续把流量塞给已经满载的 IP。 + +maxSize 不等于最大并发 + +例如: + +maxSize: 5 +maxConcurrencyPerIP: 10 + +理论最大并发: + +5 × 10 = 50 + +如果目标是 10,000 并发,而每个代理 IP 最大 10 并发,则至少需要: + +10000 ÷ 10 = 1000 个代理 IP + +考虑失效、检测、网络波动,实际可能要配置: + +minSize: 1200 +maxSize: 1500 +maxConcurrencyPerIP: 10 + +当然前提是上游允许你同时保有这么多 IP。 + +你的模板有一个潜在问题 + +第一段模板是: + +template: '{{$x := regexFindAll "\\d{1,3}(\\.\\d{1,3}){3}:\\d{2,5}" . -1}}{{range $s := $x}}{{printf "http://%s" $s}}{{end}}' + +如果 API 返回多个 IP,最终可能变成: + +http://1.1.1.1:8000http://2.2.2.2:8000 + +中间没有换行。 + +建议始终输出换行: + +template: | + {{$x := regexFindAll "\d{1,3}(\.\d{1,3}){3}:\d{2,5}" . -1}} + {{range $s := $x}}{{printf "http://%s\n" $s}}{{end}} + +不过既然注释明确写了“提取 1 个”,可以不依赖循环,最好直接解析第一个: + +template: | + {{$x := regexFind "\d{1,3}(\.\d{1,3}){3}:\d{2,5}" .}} + {{if $x}}{{printf "http://%s" $x}}{{end}} + +这样更明确。 + +但正则只能匹配 IPv4,不支持: + +用户名密码认证 +HTTPS 代理 +SOCKS5 +域名代理地址 +IPv6 + +建议允许上游模板直接输出完整 URI: + +http://username:password@1.2.3.4:8000 +socks5://username:password@example.com:1080 +checkRetryNumber 的语义需要重新定义 + +现在注释: + +checkRetryNumber: 2 #检测代理次数,超过次数则获取新代理 + +这里容易有歧义: + +总共检查两次? +第一次失败后再重试两次,总共三次? +代理生命周期内累计失败两次? +连续失败两次? + +建议改为: + +checkMaxAttempts: 2 + +表示一次健康检查任务最多尝试两次。 + +再增加: + +maxConsecutiveFailures: 2 + +表示代理连续多少轮健康检查失败后淘汰。 + +例如: + +check: + urls: + - http://connect.rom.miui.com/generate_204 + - https://www.baidu.com/ + timeout: 2s + maxAttempts: 2 + retryWait: 500ms + maxConsecutiveFailures: 2 + interval: 10s + +这样含义更清楚。 + +apiRetryNumber 也容易混淆 + +当前: + +apiRetryNumber: 5 #上游重试次数,超过次数则采用直连 + +这里至少混合了两个逻辑: + +调用代理提取 API 失败后的重试 +业务请求通过代理失败后的重试 + +应该分开。 + +fetch: + maxAttempts: 5 + +表示获取代理 API 最多尝试几次。 + +request: + proxyMaxAttempts: 2 + fallback: direct + +表示业务请求最多尝试几个代理,失败后是否直连。 + +建议不要让“代理 API 获取失败”直接决定“业务请求直连”。这两者不是同一层。 + +正确逻辑: + +提取 API 失败 + ↓ +已有代理池仍可继续使用 + ↓ +池耗尽后根据 fallback 决定 +直连降级要谨慎 + +目前是: + +超过次数则采用直连 + +对于京东域名,如果业务的目的就是必须使用代理,那么自动直连可能会: + +暴露服务器真实出口 IP +破坏 IP 隔离 +导致账号或会话环境变化 +让原本应代理的请求绕过代理 +产生不可预期的风控行为 + +建议做成显式配置: + +fallback: + mode: reject + +支持: + +reject 无代理时直接返回错误 +direct 无代理时直连 +next 尝试下一个代理池 +wait 等待代理槽位 + +更合理的组合: + +fallback: + order: + - next + - wait + - reject + waitTimeout: 3s + +如果确实允许直连: + +fallback: + order: + - next + - direct + +不要把直连写死在 apiRetryNumber 里面。 + +多代理池的选择策略 + +你现在写: + +proxy: proxy1,proxy2 + +需要明确它表示什么: + +按顺序尝试 +随机 +轮询 +加权轮询 +优先使用 proxy1,失败才使用 proxy2 +选择剩余并发最多的池 + +建议不要使用逗号字符串,改成数组: + +changeRequest: + - hostRegex: '(^|\.)jd\.com$|(^|\.)isvjd\.com$|(^|\.)isvjcloud\.com$' + proxies: + - name: proxy1 + weight: 80 + - name: proxy2 + weight: 20 + strategy: weighted + +或者主备: + +changeRequest: + - hostRegex: '(^|\.)jd\.com$' + proxies: + - proxy1 + - proxy2 + strategy: failover + +策略支持: + +roundRobin +weighted +random +leastConnections +failover +capacityWeighted + +对于代理池,最推荐: + +strategy: leastConnections + +或者: + +strategy: capacityWeighted + +根据剩余并发槽位选择。 + +你的域名正则也需要调整 + +当前: + +(.+\.jd\.com)|(.+\.isvjd\.com)|(.+\.isvjcloud\.com) + +它能匹配: + +api.jd.com +www.jd.com + +但不匹配裸域名: + +jd.com +isvjd.com +isvjcloud.com + +而且如果程序使用的是“查找匹配”而不是“完整匹配”,可能出现不够严格的问题。 + +建议: + +hostRegex: '(^|\.)jd\.com$|(^|\.)isvjd\.com$|(^|\.)isvjcloud\.com$' + +它可以匹配: + +jd.com +www.jd.com +api.m.jd.com +isvjd.com +xxx.isvjd.com + +但不会匹配: + +fakejd.com +jd.com.example.org + +如果请求里的 host 包含端口,比如: + +www.jd.com:443 + +最好程序先使用 net.SplitHostPort 去掉端口,再做正则匹配,而不是让正则兼容端口。 + +checkUrl 不建议用逗号字符串 + +现在: + +checkUrl: http://jd.com/,http://baidu.com/,http://bilibili.com/ + +建议改数组: + +checkUrls: + - https://www.jd.com/ + - http://connect.rom.miui.com/generate_204 + +逗号字符串存在问题: + +URL 参数中可能包含逗号 +需要额外切割和去空格 +YAML 可读性差 +不方便为不同检测地址配置超时和预期状态码 + +更完善的写法: + +check: + targets: + - url: http://connect.rom.miui.com/generate_204 + expectedStatus: + - 204 + + - url: https://www.jd.com/ + expectedStatus: + - 200 + - 301 + - 302 + +不过不要每次都同时检查所有 URL,否则 1000 个代理 × 3 个 URL 会产生大量额外请求。 + +建议: + +check: + strategy: any + +含义是任意一个检测成功就算通过。 + +或: + +check: + strategy: targetFirst + +优先检查目标业务域名,失败后再检查通用地址,以判断: + +代理完全不可用 +仅目标站不可用 +lifecycle: -1 有风险 + +如果上游 IP 是有时效的,但配置成: + +lifecycle: -1 + +意味着只依赖健康检查淘汰。可能出现: + +IP 已经过期 +尚未到下一轮检测 +新业务请求仍被分配给它 +请求失败后才发现过期 + +最好支持三种生命周期来源: + +lifecycle: + mode: fixed + ttl: 20s +lifecycle: + mode: response + +表示从 API 响应中解析过期时间。 + +lifecycle: + mode: healthCheckOnly + +表示确实没有有效期信息,只靠检测。 + +兼容旧配置也可以: + +lifecycle: 20s + +但不要让 -1 同时表达太多含义。 + +更适合你当前项目的配置版本 + +可以改成下面这样: + +defaultCheck: + targets: + - url: http://connect.rom.miui.com/generate_204 + expectedStatus: [204] + - url: https://www.baidu.com/ + expectedStatus: [200, 301, 302] + strategy: any + timeout: 2s + maxAttempts: 2 + retryWait: 500ms + interval: 10s + maxConsecutiveFailures: 2 + +changeRequest: + - hostRegex: '(^|\.)jd\.com$|(^|\.)isvjd\.com$|(^|\.)isvjcloud\.com$' + + upstreams: + - proxy1 + - proxy2 + + strategy: leastConnections + + fallback: + order: + - next + - wait + - reject + waitTimeout: 3s + +upstream: + proxy1: + apiUrl: '' + + template: | + {{$x := regexFind "\d{1,3}(\.\d{1,3}){3}:\d{2,5}" .}} + {{if $x}}{{printf "http://%s" $x}}{{end}} + + pool: + minSize: 3 + maxSize: 5 + + capacity: + maxConcurrencyPerIP: 10 + + lifecycle: + mode: fixed + ttl: 20s + allocationSafetyMargin: 3s + + fetch: + requestInterval: 1s + timeout: 3s + maxAttempts: 5 + maxInFlight: 1 + + check: + targets: + - url: https://www.jd.com/ + expectedStatus: [200, 301, 302] + timeout: 2s + maxAttempts: 2 + retryWait: 500ms + interval: 10s + maxConsecutiveFailures: 2 + + proxy2: + apiUrl: '' + + template: | + {{$x := regexFind "\d{1,3}(\.\d{1,3}){3}:\d{2,5}" .}} + {{if $x}}{{printf "http://%s" $x}}{{end}} + + pool: + minSize: 1 + maxSize: 2 + + capacity: + maxConcurrencyPerIP: 5 + + lifecycle: + mode: healthCheckOnly + + fetch: + requestInterval: 200ms + timeout: 3s + maxAttempts: 5 + maxInFlight: 1 +如果想尽量保持你原来的配置格式 + +最少只需要增加这些字段: + +upstream: + proxy: + template: | + {{$x := regexFind "\d{1,3}(\.\d{1,3}){3}:\d{2,5}" .}} + {{if $x}}{{printf "http://%s" $x}}{{end}} + + apiUrl: '' + + lifecycle: 20s + + minSize: 3 + maxSize: 5 + + maxConcurrencyPerIP: 10 + + requestInterval: 1s + maxFetchInFlight: 1 + fetchTimeout: 3s + apiRetryNumber: 5 + + checkUrls: + - https://www.jd.com/ + - http://connect.rom.miui.com/generate_204 + + checkRetryNumber: 2 + checkTimeout: 2s + checkRetryWait: 500ms + checkInterval: 10s + + fallback: reject + +核心补池判断应是: + +可分配 IP 数量 < minSize +或者 +可用并发槽位不足 + +而不是只有: + +当前缓存数量 < maxSize + +你这套配置方向没问题,关键是把它从“维护几个代理地址”,升级成“维护多少个可分配并发槽位”。requestInterval 也完全可以继续用,简单、直观,而且天然适配每家供应商不同的 API 限速。 + + +那这样的话,maxSize 的语义就完全变了,它不是"缓存数量",而是允许从该上游代理商维护的最大代理数量。我觉得这个定义更合理。 + +那我建议整个配置改成下面这种语义,会非常清晰。 + +upstream: + proxy1: + apiUrl: xxx + + lifecycle: 120s + + maxSize: 100 # 最多维护100个来自该代理商的IP + + requestInterval: 200ms # 两次请求API最小间隔 + + maxConcurrencyPerIP: 10 + + checkRetryNumber: 2 + apiRetryNumber: 5 + +这里 maxSize 的意思就是: + +这个 Provider 最终允许存在多少个可用 IP。 + +例如: + +Provider A +maxSize = 100 + +当前: +Healthy = 82 +Checking = 5 +Fetching = 3 +Expired = 10 + +实际总维护数量: +82 + 5 + 3 = 90 + +那么还能继续获取: + +100 - 90 = 10 + +而不是: + +100 - 82 + +因为正在检测、正在获取的也占用了容量。 + +我建议不要叫 minSize + +因为你的补池逻辑和很多代理池不一样。 + +更建议叫: + +targetSize: 100 + +或者继续保留 + +maxSize: 100 + +然后系统永远尝试保持: + +当前数量 < maxSize + ↓ +继续获取 + +例如: + +maxSize = 100 + +当前 97 + +继续获取3个 + +如果: + +当前95 + +上游一次只能返回5个 + +最终100 + +如果: + +当前98 + +上游一次固定返回5个 + +最终103 + +这种情况下可以: + +获取5个 +↓ + +保留2个 + +剩余3个丢弃 + +或者更好的: + +maxSize:100 +allowOverflow:5 + +允许轻微溢出。 + +但是我觉得还缺一个参数 + +因为现在只有: + +maxSize + +并不知道什么时候开始补。 + +例如: + +maxSize=100 + +当前99 + +是不是立即获取? + +如果每次都等少一个就获取,会不停请求API。 + +我建议增加: + +refillThreshold: 80 + +表示: + +当可用IP <=80 + +开始补 + +一直补到100 + +这样: + +100 +99 +98 +97 +... + +81 + +80 ← 开始补 + +81 +82 +... +100 + +API调用次数会少很多。 + +更推荐的命名 + +我会设计成: + +upstream: + proxy1: + + capacity: + + targetSize: 100 # 最终维护多少个 + + refillThreshold: 80 # 少于80开始补 + + maxConcurrencyPerIP: 10 + + fetch: + + requestInterval: 200ms + + apiRetryNumber: 5 + + timeout: 3s + + lifecycle: + + ttl: 120s + + check: + + retryNumber: 2 + timeout: 2s + +这样职责很清晰: + +capacity + 管数量 + +fetch + 管怎么获取 + +check + 管怎么检测 + +lifecycle + 管生命周期 +我还有一个建议(这是我觉得整个代理池最重要的一点) + +不要让 maxSize 代表"缓存数量",而应该代表"许可证(License)"。 + +什么意思? + +假设: + +proxy1 + +maxSize = 100 + +实际上系统里面应该维护: + +Healthy = 60 + +Checking = 5 + +Fetching = 10 + +Reserved = 15 + +Expired = 10 + +其中: + +Healthy:可分配 +Checking:健康检查中 +Fetching:已经向上游请求,还没返回 +Reserved:已经分配给固定会话,但暂时不可复用 +Expired:等待清理 + +真正参与容量计算的是: + +Healthy ++ Checking ++ Fetching ++ Reserved +<= maxSize + +而不是只有 Healthy。 + +这样不会出现多个协程同时判断: + +当前60 + +还能拉40 + +最后一下子拉成140个。 + +如果是我来设计这个代理池(目标 1 万+ 并发),我甚至不会再用 maxSize 这个名字,而会改成: + +capacity: + desired: 100 # 希望维持100个 + lowWatermark: 80 # 低水位 + highWatermark:100 # 高水位 + +这是大型连接池(数据库连接池、Kafka Consumer、Nginx Upstream 等)普遍采用的"水位线"设计,比单独一个 maxSize 更容易扩展,以后加多个代理商、动态扩容都不用改配置结构。 + +对,这就说明不能把 maxSize 理解成“平时一直维持这么多”,而应该只是绝对上限。 + +空闲时应该缩池,忙时再扩池。关键还要区分代理商的计费模式。 + +两类代理商要用不同策略 +1. 提取即计费 / 提取即消耗 IP + +这种最怕预拉太多。 + +建议: + +billingMode: fetch + +策略: + +空闲时只保留很少,甚至 0 个 +有请求时按需拉取 +只保留必要的安全余量 +不主动补到 maxSize + +例如: + +minIdleSize: 0 +warmSize: 2 +maxSize: 100 + +含义: + +空闲时允许 0 个 +有少量流量时维持 2 个热代理 +高峰时最多扩到 100 个 +2. 使用时计费 / 建连或流量计费 + +这种提前获取本身不花钱,可以适当预热。 + +建议: + +billingMode: usage + +策略: + +可以维持一定空闲代理 +提前完成健康检查 +降低首个请求延迟 +但仍不要无脑维持到 maxSize + +例如: + +minIdleSize: 5 +warmSize: 20 +maxSize: 100 +maxSize 应该只表示硬上限 + +建议最终语义是: + +maxSize: 100 + +表示: + +该代理商最多允许同时维护 100 个尚未淘汰的代理 IP。 + +它不表示平时要维持 100 个。 + +真正决定当前应该维护多少的是动态目标: + +desiredSize + +并且满足: + +0 <= desiredSize <= maxSize +动态目标数量怎么计算 + +可以按当前并发和每个 IP 的并发容量算: + +neededByLoad = +ceil( + 当前并发 × 安全系数 + ÷ 单IP有效并发 +) + +例如: + +当前并发:240 +单 IP 最大并发:10 +安全系数:1.25 + +则: + +ceil(240 × 1.25 ÷ 10) += 30 个代理 + +再加上少量预热数量: + +desiredSize = +max(minIdleSize, neededByLoad + warmSpareSize) + +最后限制: + +desiredSize = +min(desiredSize, maxSize) +更适合你的配置 +upstream: + proxy1: + billingMode: fetch + + pool: + minIdleSize: 0 + warmSpareSize: 1 + maxSize: 100 + scaleDownDelay: 30s + + capacity: + maxConcurrencyPerIP: 10 + utilizationTarget: 0.8 + + fetch: + requestInterval: 1s + maxInFlight: 1 + +解释: + +maxConcurrencyPerIP = 10 +utilizationTarget = 0.8 + +内部按每个 IP 只承载 8 并发计算。 + +如果当前有 80 并发: + +80 ÷ 8 = 10 个代理 + +再加一个备用: + +desiredSize = 11 + +而不是直接拉满 100 个。 + +提取即计费的代理商,建议更保守 +billingMode: fetch + +pool: + minIdleSize: 0 + warmSpareSize: 0 + maxSize: 100 + scaleUpStep: 1 + scaleDownDelay: 10s + +它的策略应该是: + +当前容量不够 + ↓ +只补最少需要的数量 + +不要一次补很多。 + +例如当前缺 3 个 IP: + +就拉 3 个 + +而不是补到某个高水位。 + +不过这里还受 requestInterval 限制。如果每秒只能拉一个,就需要提前预测,不能等全部占满才开始拉。 + +使用时计费的代理商,可以积极一点 +billingMode: usage + +pool: + minIdleSize: 5 + warmSpareSize: 10 + maxSize: 100 + scaleDownDelay: 120s + +这种可以让一些代理提前处于健康状态,因为单纯获取不产生费用。 + +最好再区分第三种:包量或周期计费 + +有些代理商可能是: + +按天套餐 +按月套餐 +固定 IP 数量套餐 +已购买额度内提取不额外计费 + +可以定义: + +billingMode: prepaid + +策略类似 usage,甚至可以更积极预热。 + +最终三种: + +fetch 提取即计费 +usage 使用、流量或成功请求计费 +prepaid 套餐或已付费额度 +缩池也很重要 + +代理空闲后不能立刻删除,否则流量轻微波动就会反复: + +拉取 → 删除 → 拉取 → 删除 + +这会浪费 IP 和 API 配额。 + +应该设置: + +scaleDownDelay: 30s + +只有代理持续空闲 30 秒才淘汰。 + +对于提取即计费而且 IP 生命周期很短的供应商,可以不主动删除,直接停止补充,让它自然过期: + +scaleDownMode: expireOnly + +这通常更合理。 + +可支持: + +expireOnly 不主动删,等待自然过期 +immediate 立即释放 +delayed 空闲一段时间后释放 +推荐的完整配置模型 +upstream: + proxy1: + billingMode: fetch + + lifecycle: + ttl: 60s + safetyMargin: 10s + + pool: + minIdleSize: 0 + warmSpareSize: 1 + maxSize: 100 + + scaleUpStep: 1 + scaleDownMode: expireOnly + scaleDownDelay: 30s + + capacity: + maxConcurrencyPerIP: 10 + utilizationTarget: 0.8 + + fetch: + requestInterval: 1s + maxInFlight: 1 + timeout: 3s + +另一个按使用计费的供应商: + +upstream: + proxy2: + billingMode: usage + + lifecycle: + ttl: 300s + safetyMargin: 20s + + pool: + minIdleSize: 5 + warmSpareSize: 10 + maxSize: 200 + + scaleUpStep: 10 + scaleDownMode: delayed + scaleDownDelay: 120s + + capacity: + maxConcurrencyPerIP: 20 + utilizationTarget: 0.75 + + fetch: + requestInterval: 200ms + maxInFlight: 2 +核心结论 + +你这里需要把三个概念彻底分开: + +maxSize += 最多允许维护多少个代理 + +desiredSize += 当前流量下应该维护多少个代理 + +minIdleSize += 空闲时至少保留多少个代理 + +对于“提取即计费”的代理商,minIdleSize 可以是 0,desiredSize 随流量增长;对于“使用才计费”的代理商,可以保留一定热代理,减少首次请求等待。 + +这个 changeRequest 我觉得还可以再设计一下,因为它本质上不是代理配置,而是"路由规则(Routing Rule)"。 + +你现在的意思是: + +changeRequest: + - hostRegex: .+\.xxx\.com + proxy: proxy + + - hostRegex: .* + proxy: proxy + +实际上表达的是: + +请求 + ↓ +匹配 host + ↓ +找到对应代理池 + ↓ +转发 + +所以它应该和 upstream 解耦。 + +我建议先定义规则 + +例如: + +rules: + - match: + hostRegex: '(^|\.)jd\.com$' + upstream: proxy1 + + - match: + hostRegex: '(^|\.)taobao\.com$' + upstream: proxy2 + + - match: + hostRegex: '.*' + upstream: direct + +这样很清楚: + +Rule + ↓ +决定走哪个 upstream + +而不是: + +Rule + ↓ +直接决定 proxy + +因为以后可能还有: + +direct +block +proxy group +load balance +failover +如果还想兼容现在的写法 + +完全可以: + +changeRequest: + - hostRegex: '(^|\.)jd\.com$' + proxy: proxy1 + + - hostRegex: '.*' + proxy: direct + +程序内部转换成: + +Rule{ + Match: HostRegex, + Target: Upstream, +} +我更关心的是多个 Proxy + +你现在已经支持: + +proxy: proxy1,proxy2 + +那规则应该约束什么? + +例如: + +changeRequest: + - hostRegex: '(^|\.)jd\.com$' + proxy: proxy1,proxy2 + +到底表示: + +proxy1 优先 + +还是 + +随机 + +还是 + +轮询 + +还是 + +按权重 + +还是 + +剩余容量最多 + +这里必须配置。 + +例如: + +changeRequest: + - hostRegex: '(^|\.)jd\.com$' + + strategy: failover + + proxies: + - proxy1 + - proxy2 + +或者 + +strategy: roundRobin + +或者 + +strategy: weighted +我甚至建议 Rule 支持条件 + +以后很多人都会提这种需求: + +同一个域名 + +POST 走代理 + +GET 不走 + +或者: + +/api + +走代理A + +/login + +走代理B + +所以 Rule 最好设计成: + +rules: + + - match: + hostRegex: '(^|\.)jd\.com$' + method: + - GET + - POST + pathRegex: '^/api' + + upstream: proxy1 + +以后不用改结构。 + +再往后可以支持 Header + +例如: + +match: + + hostRegex: xxx + + headers: + + X-Test: abc + + User-Agent: Chrome.* + +甚至: + +clientIP: + + - 192.168.1.0/24 + +所以 Rule 应该是: + +Rule + +↓ + +Match + +↓ + +Action +Action 不一定只有 Proxy + +例如: + +action: + + type: proxy + + upstream: proxy1 + +以后还能: + +action: + + type: direct +action: + + type: reject +action: + + type: rewrite +我建议增加优先级 + +例如: + +rules: + + - priority: 100 + + match: + hostRegex: '^api\.jd\.com$' + + upstream: proxy1 + + - priority: 10 + + match: + hostRegex: '.*' + + upstream: direct + +否则以后只能依赖配置顺序。 + +最后还有一个容易忽略的点:规则匹配方式 + +建议明确规定: + +规则从上往下匹配 + +命中第一条立即停止 + +例如: + +changeRequest: + + - hostRegex: '^api\.jd\.com$' + proxy: proxy1 + + - hostRegex: '(^|\.)jd\.com$' + proxy: proxy2 + + - hostRegex: '.*' + proxy: direct + +请求: + +api.jd.com + +应该: + +命中第一条 + +使用 proxy1 + +后面的不再判断 + +这一点一定要写进文档,否则用户会疑惑为什么配置了两条都能匹配,但最终只走了第一条。 + +所以如果让我设计,我会把整个配置拆成两个完全独立的部分: + +rules:负责"哪些请求走哪里",支持 Host、Path、Method、Header 等匹配,并采用"自上而下、首条命中"的规则。 +upstream:负责"代理池如何维护",包括 API、生命周期、最大数量、请求间隔、健康检查、单 IP 并发等。 + +这样路由和代理池各司其职,以后扩展也不会互相影响。 + +我会把它设计成两层: + +Routing(请求路由):决定请求走哪个代理池。 +Upstream(代理池):决定如何维护代理。 + +两层完全独立,这样以后加 SOCKS5、直连、多个代理商都不用改 Routing。 + +下面是我觉得比较完整的 V1 配置设计。 + +# Proxy Pool Configuration + +## 配置结构 + +```yaml +version: 1 + +defaults: + check: + urls: + - http://connect.rom.miui.com/generate_204 + timeout: 2s + retry: 2 + retryInterval: 500ms + +routing: [] + +upstreams: {} +``` + +--- + +# Routing(请求路由) + +用于决定某个请求应该使用哪个 Upstream。 + +按照**从上到下**匹配。 + +命中第一条规则后立即停止。 + +## 示例 + +```yaml +routing: + + - name: JD + + match: + hostRegex: '(^|\.)jd\.com$|(^|\.)isvjd\.com$|(^|\.)isvjcloud\.com$' + + upstreams: + - proxy-jd + + - name: Github + + match: + hostRegex: '(^|\.)github\.com$' + + upstreams: + - github + + - name: Default + + match: + hostRegex: '.*' + + action: direct +``` + +--- + +## Match + +目前支持: + +```yaml +match: + + hostRegex: + + method: + + pathRegex: + + headers: +``` + +例如: + +```yaml +match: + + hostRegex: '(^|\.)jd\.com$' + + method: + + - GET + + - POST + + pathRegex: '^/api' +``` + +--- + +## Action + +一个规则只能执行一个 Action。 + +支持: + +```yaml +action: direct +``` + +```yaml +action: reject +``` + +或者: + +```yaml +upstreams: + + - proxy-jd +``` + +多个 Upstream: + +```yaml +upstreams: + + - proxy-a + + - proxy-b +``` + +--- + +## Upstream Selection + +当存在多个 Upstream 时,需要指定选择策略。 + +```yaml +strategy: random +``` + +支持: + +```text +random + +roundRobin + +weighted + +leastConnections + +failover +``` + +例如: + +```yaml +routing: + + - match: + hostRegex: '(^|\.)jd\.com$' + + upstreams: + + - proxy-a + + - proxy-b + + strategy: leastConnections +``` + +--- + +# Upstream + +每个 Upstream 表示一个代理池。 + +```yaml +upstreams: + + proxy-jd: + + api: + url: + + template: + + pool: + + capacity: + + lifecycle: + + fetch: + + check: +``` + +--- + +# API + +代理提取接口。 + +```yaml +api: + + url: https://xxx/api + + template: | + {{$x := regexFind "\d{1,3}(\.\d{1,3}){3}:\d{2,5}" .}} + {{printf "http://%s" $x}} +``` + +--- + +# Pool + +代理池维护策略。 + +```yaml +pool: + + maxSize: 100 + + shrinkDelay: 30s +``` + +说明: + +| 参数 | 说明 | +|------|------| +|maxSize|允许同时维护的最大代理数量| +|shrinkDelay|代理空闲多久开始释放| + +--- + +# Capacity + +代理容量。 + +```yaml +capacity: + + maxConcurrencyPerIP: 10 +``` + +说明: + +一个代理最多同时承载多少请求。 + +超过后不会继续分配。 + +--- + +# Lifecycle + +代理生命周期。 + +```yaml +lifecycle: + + ttl: 120s + + allocationSafetyMargin: 15s +``` + +说明: + +```text +ttl +``` + +主动失效时间。 + +```text +allocationSafetyMargin +``` + +距离失效不足该时间,不再分配新请求。 + +例如: + +```text +TTL = 120s + +Safety = 15s +``` + +代理使用: + +```text +0~105 秒 + +105~120 秒 + +仅允许已有连接 + +120 秒 + +删除 +``` + +--- + +# Fetch + +控制如何向代理商提取代理。 + +```yaml +fetch: + + requestInterval: 200ms + + timeout: 5s + + retry: 5 + + maxInFlight: 1 +``` + +说明: + +| 参数 | 说明 | +|------|------| +|requestInterval|两次请求 API 的最小时间间隔| +|timeout|API 超时时间| +|retry|API 最大重试次数| +|maxInFlight|同时最多几个请求正在提取| + +例如: + +```yaml +requestInterval: 1s +``` + +表示: + +```text +最多约 1 次/秒 +``` + +```yaml +requestInterval: 200ms +``` + +表示: + +```text +最多约 5 次/秒 +``` + +--- + +# Health Check + +代理健康检查。 + +```yaml +check: + + urls: + + - http://connect.rom.miui.com/generate_204 + + - https://www.baidu.com/ + + timeout: 2s + + retry: 2 + + retryInterval: 500ms + + interval: 10s +``` + +--- + +# 完整示例 + +```yaml +version: 1 + +defaults: + + check: + + urls: + + - http://connect.rom.miui.com/generate_204 + + timeout: 2s + + retry: 2 + + retryInterval: 500ms + +routing: + + - name: JD + + match: + + hostRegex: '(^|\.)jd\.com$|(^|\.)isvjd\.com$|(^|\.)isvjcloud\.com$' + + upstreams: + + - jd-primary + + - jd-backup + + strategy: leastConnections + + - name: Default + + match: + + hostRegex: '.*' + + action: direct + +upstreams: + + jd-primary: + + api: + + url: https://provider-a/api + + template: | + {{$x := regexFind "\d{1,3}(\.\d{1,3}){3}:\d{2,5}" .}} + {{printf "http://%s" $x}} + + pool: + + maxSize: 100 + + shrinkDelay: 30s + + capacity: + + maxConcurrencyPerIP: 10 + + lifecycle: + + ttl: 120s + + allocationSafetyMargin: 15s + + fetch: + + requestInterval: 1s + + timeout: 5s + + retry: 5 + + maxInFlight: 1 + + check: + + urls: + + - http://connect.rom.miui.com/generate_204 + + timeout: 2s + + retry: 2 + + retryInterval: 500ms + + interval: 10s + + jd-backup: + + api: + + url: https://provider-b/api + + template: | + {{$x := regexFind "\d{1,3}(\.\d{1,3}){3}:\d{2,5}" .}} + {{printf "http://%s" $x}} + + pool: + + maxSize: 20 + + capacity: + + maxConcurrencyPerIP: 5 + + lifecycle: + + ttl: 60s + + fetch: + + requestInterval: 200ms + + retry: 3 + + maxInFlight: 2 +``` +我还建议增加一个 Provider Metadata(代理商能力) 节点 + +我们前面讨论了很多,其实不同代理商最大的区别不是 API,而是能力。 + +例如: + +provider: + + billingMode: fetch # fetch / usage / prepaid + + supportsSticky: false + + supportsIPv6: true + + supportsUsernamePassword: true + + supportsHTTPS: true + +甚至可以再加: + +provider: + + maxReturnPerRequest: 100 + + fixedReturn: false + + ttlSource: response # fixed / response + + duplicatePolicy: allow # allow / reject + +这样调度器可以根据不同代理商能力自动调整补池策略,而不是把这些逻辑硬编码在代码里。 + +我认为这一层比继续增加 pool 参数更有价值,也是后续扩展多家代理商时最容易受益的地方。 + +我觉得可以,而且我会再往前走一步,把它设计成一个真正可扩展的代理池配置,而不是一个"配置项堆积"。 + +我会遵循几个原则: + +职责单一(一个节点只负责一件事) +支持未来扩展(SOCKS5、HTTP、固定IP、住宅IP等) +配置名尽量语义化(不用看源码也知道是什么意思) +不让配置决定实现细节(例如不要暴露 Redis、Worker 这些概念) + +我建议目录结构如下: + +# Configuration + +```yaml +version: 1 + +defaults: {} + +routing: [] + +upstreams: {} +``` + +--- + +# defaults + +全局默认配置。 + +所有 Upstream 可继承,支持覆盖。 + +```yaml +defaults: + + fetch: + + timeout: 5s + + retry: 3 + + check: + + interval: 10s + + timeout: 2s + + retry: 2 + + urls: + + - http://connect.rom.miui.com/generate_204 +``` + +--- + +# routing + +请求路由规则。 + +负责: + +> 一个请求应该使用哪个 Upstream。 + +不负责代理维护。 + +按照**从上到下**匹配。 + +第一条命中立即停止。 + +```yaml +routing: + + - name: JD + + match: + + hostRegex: '(^|\.)jd\.com$' + + upstreams: + + - jd-primary + + - jd-backup + + strategy: failover + + - name: Github + + match: + + hostRegex: '(^|\.)github\.com$' + + upstreams: + + - github + + - name: Default + + match: + + hostRegex: '.*' + + action: direct +``` + +--- + +## match + +目前支持: + +```yaml +match: + + hostRegex: + + method: + + pathRegex: + + headers: +``` + +以后还能扩展: + +```yaml +query: + +clientIP: + +scheme: + +port: +``` + +--- + +## strategy + +当有多个 Upstream 时。 + +支持: + +```text +random + +roundRobin + +weighted + +leastConnections + +leastLoad + +failover +``` + +--- + +## action + +支持: + +```yaml +action: direct +``` + +```yaml +action: reject +``` + +或者: + +```yaml +upstreams: + + - proxy-a +``` + +--- + +# upstreams + +每个 Upstream 对应一个代理池。 + +```yaml +upstreams: + + jd-primary: + + provider: + + api: + + pool: + + capacity: + + lifecycle: + + fetch: + + check: +``` + +--- + +# provider + +描述代理商能力。 + +不是运行参数。 + +```yaml +provider: + + billingMode: usage + + protocol: + + - http + + - https + + stickySession: false + + ipv6: true + + auth: + + usernamePassword: true + + duplicatePolicy: allow +``` + +--- + +## billingMode + +支持: + +```text +fetch + +usage + +prepaid +``` + +说明: + +|模式|说明| +|------|------| +|fetch|提取即计费| +|usage|使用时计费| +|prepaid|套餐计费| + +--- + +# api + +代理提取接口。 + +```yaml +api: + + url: + + method: GET + + headers: {} + + body: {} + + template: +``` + +template: + +```yaml +template: | + {{$x := regexFind "\d{1,3}(\.\d{1,3}){3}:\d{2,5}" .}} + {{printf "http://%s" $x}} +``` + +--- + +# pool + +代理池策略。 + +```yaml +pool: + + maxSize: 100 + + shrinkDelay: 60s +``` + +说明: + +|maxSize|允许维护的最大代理数量| + +不是: + +> 启动立即拉满。 + +而是: + +> 系统最多允许维护多少个代理。 + +--- + +# capacity + +代理容量。 + +```yaml +capacity: + + maxConcurrencyPerProxy: 10 +``` + +表示: + +每个代理允许同时承载多少请求。 + +超过以后不再分配。 + +--- + +# lifecycle + +生命周期。 + +```yaml +lifecycle: + + ttl: 120s + + allocationSafetyMargin: 15s +``` + +说明: + +```text +TTL + +代理生命周期 +``` + +```text +allocationSafetyMargin + +距离过期多少秒停止分配新请求 +``` + +--- + +# fetch + +代理获取策略。 + +```yaml +fetch: + + timeout: 5s + + retry: 3 + + requestInterval: 1s + + maxInFlight: 1 +``` + +说明: + +|参数|说明| +|------|------| +|timeout|API超时| +|retry|API重试次数| +|requestInterval|最小请求间隔| +|maxInFlight|最大同时提取数| + +--- + +# check + +健康检查。 + +```yaml +check: + + interval: 10s + + timeout: 2s + + retry: 2 + + retryInterval: 500ms + + urls: + + - http://connect.rom.miui.com/generate_204 + + - https://www.baidu.com/ +``` + +--- + +# Runtime State(非配置) + +运行期间每个 Upstream 内部维护: + +```text +Healthy + +Checking + +Fetching + +Reserved + +Expired +``` + +调度器仅从: + +```text +Healthy +``` + +选择代理。 + +--- + +# Scheduling + +代理选择流程: + +```text +Request + │ + ▼ +Routing + │ + ▼ +Select Upstream + │ + ▼ +Healthy Proxy + │ + ▼ +Capacity Check + │ + ▼ +Allocate +``` + +--- + +# Fetch Flow + +```text +Pool Need Proxy + │ + ▼ +Check requestInterval + │ + ▼ +Acquire Fetch Slot + │ + ▼ +Call Provider API + │ + ▼ +Template Parse + │ + ▼ +Health Check + │ + ▼ +Join Healthy Pool +``` + +--- + +# Health Flow + +```text +Healthy + │ +Periodic Check + │ + ├────Success──────────────┐ + │ │ + ▼ │ +Keep Healthy │ + │ +Failure │ + │ │ +Retry │ + │ │ +Still Fail │ + ▼ │ +Remove Proxy──────────────────┘ +``` + +--- + +# Complete Example + +```yaml +version: 1 + +defaults: + + fetch: + + timeout: 5s + + retry: 3 + + check: + + interval: 10s + + timeout: 2s + + retry: 2 + + urls: + + - http://connect.rom.miui.com/generate_204 + +routing: + + - name: JD + + match: + + hostRegex: '(^|\.)jd\.com$' + + upstreams: + + - jd-primary + + - jd-backup + + strategy: failover + + - name: Default + + match: + + hostRegex: '.*' + + action: direct + +upstreams: + + jd-primary: + + provider: + + billingMode: usage + + protocol: + + - http + + - https + + api: + + url: https://provider-a/api + + template: | + {{$x := regexFind "\d{1,3}(\.\d{1,3}){3}:\d{2,5}" .}} + {{printf "http://%s" $x}} + + pool: + + maxSize: 100 + + shrinkDelay: 60s + + capacity: + + maxConcurrencyPerProxy: 10 + + lifecycle: + + ttl: 120s + + allocationSafetyMargin: 15s + + fetch: + + requestInterval: 1s + + maxInFlight: 1 + + check: + + urls: + + - http://connect.rom.miui.com/generate_204 +``` +我会再改两个地方(我认为这是 V2 最值得做的) +① pool 可以改名为 policy + +因为它配置的是策略,不仅仅是数量。 + +以后可以自然加入: + +policy: + maxSize: 100 + shrinkDelay: 60s + selection: leastLoad +② provider 和 api 可以合并 + +实际上一个 upstream 就对应一个代理供应商,大多数情况下: + +provider: + billingMode: usage + protocol: [http] + +api: + url: ... + +完全可以整合为: + +provider: + billingMode: usage + protocol: [http] + + api: + url: ... + method: GET + template: ... + +这样 provider 同时描述供应商能力和供应商接口,结构会更紧凑,也更符合语义: + +一个 Upstream = 一个 Provider + 一套运行策略。 + +我个人更倾向于这个版本作为最终配置架构。 + +对,配置存在 ≠ 默认启用。这里最好把两个概念彻底拆开: + +enabled:这个上游代理商是否参与运行 +routing / strategy:启用后,按什么顺序参与调度 + +建议把启停状态放在每个 upstream 自身: + +upstreams: + provider-a: + enabled: true + + provider-b: + enabled: false + +这样: + +enabled: false + +表示它仍然保留在配置里,但: + +不拉取代理 +不做健康检查 +不参与路由 +不占用运行资源 +可以随时重新启用 +上游代理商运行状态 + +除了配置里的 enabled,运行时还需要状态: + +DISABLED +READY +DEGRADED +EXHAUSTED +COOLDOWN +ERROR + +含义可以定义为: + +状态 含义 +DISABLED 配置禁用 +READY 正常可用 +DEGRADED 可用,但容量不足或错误率较高 +EXHAUSTED 额度、IP 数量或套餐已耗尽 +COOLDOWN 因 429、限流或连续错误暂时停用 +ERROR 配置错误或长期无法连接 + +真正可被调度的条件: + +enabled == true +&& runtimeState in [READY, DEGRADED] +&& availableCapacity > 0 +自动切换到下一个代理商 + +这其实是一种明确的调度策略,建议叫: + +strategy: sequentialFailover + +或者简单一点: + +strategy: failover + +例如: + +routing: + - name: jd + match: + hostRegex: '(^|\.)jd\.com$' + + upstreams: + - provider-a + - provider-b + - provider-c + + strategy: failover + +语义: + +优先使用 provider-a + +provider-a 不可用或耗尽 + ↓ +切换 provider-b + +provider-b 不可用或耗尽 + ↓ +切换 provider-c +什么叫“耗尽” + +这里必须定义清楚,不能只看“当前没有可用 IP”。 + +建议把耗尽拆成几种: + +quotaExhausted +fetchLimitReached +noAvailableProxy +providerReturnedExhausted +budgetReached + +例如代理商 API 返回: + +{ + "code": "QUOTA_EXHAUSTED" +} + +就可以把运行状态设为: + +EXHAUSTED + +如果只是暂时没拉到 IP: + +DEGRADED + +或者: + +COOLDOWN + +不要误判为永久耗尽。 + +建议增加 exhaustedPolicy + +每个上游代理商可以定义自己耗尽后的行为: + +upstreams: + provider-a: + enabled: true + + exhaustion: + mode: auto + retryAfter: 10m + +支持: + +auto +manual +dailyReset +monthlyReset +responseDriven + +例如: + +exhaustion: + mode: dailyReset + resetAt: "00:00" + timezone: Asia/Shanghai + +或者: + +exhaustion: + mode: responseDriven + retryAfter: 30m + +含义是上游明确返回“额度耗尽”后,30 分钟再尝试。 + +需要区分“切换”和“回切” + +这是非常重要的一点。 + +假设: + +provider-a 耗尽 + ↓ +切换 provider-b + +后来 provider-a 又恢复了,是不是立刻切回? + +建议在路由规则里配置: + +failover: + fallbackMode: sticky + recoveryCheckInterval: 5m + switchBack: true + switchBackDelay: 2m + +支持两种模式: + +自动回切 +switchBack: true + +主代理商恢复后,等待一段时间再切回。 + +不自动回切 +switchBack: false + +切到下一个以后,继续使用,直到人工干预或当前代理商也耗尽。 + +这个模式适合“按顺序消耗套餐”。 + +你说的“一个用完再换下一个” + +这个不是普通 failover,而是更像: + +顺序消费 + +我建议单独定义策略: + +strategy: sequential + +例如: + +routing: + - name: jd + match: + hostRegex: '(^|\.)jd\.com$' + + upstreams: + - provider-a + - provider-b + - provider-c + + strategy: sequential + + sequential: + switchOn: + - exhausted + - disabled + - error + + switchBack: false + +含义: + +始终只使用列表中的一个代理商 + +provider-a 用完后切 provider-b + +provider-b 用完后切 provider-c + +默认不自动切回 + +这和 failover 有区别: + +策略 说明 +failover 主代理商暂时异常时切备用,恢复后可以回切 +sequential 当前代理商消耗完后永久推进到下一个 +loadBalance 多个代理商同时使用 +weighted 按权重分配 +leastLoad 选择负载最低的 +推荐的完整结构 +routing: + - name: jd + match: + hostRegex: '(^|\.)jd\.com$' + + upstreams: + - provider-a + - provider-b + - provider-c + + strategy: sequential + + sequential: + switchOn: + - exhausted + - disabled + - unavailable + + switchBack: false + + onAllUnavailable: reject + +upstreams: + provider-a: + enabled: true + priority: 10 + + provider: + billingMode: fetch + + exhaustion: + detection: + httpStatus: + - 402 + - 429 + responseCodes: + - QUOTA_EXHAUSTED + - NO_BALANCE + + retryAfter: 1h + + provider-b: + enabled: true + priority: 20 + + provider: + billingMode: usage + + exhaustion: + retryAfter: 10m + + provider-c: + enabled: false +onAllUnavailable + +所有上游都不可用时,也必须明确: + +onAllUnavailable: reject + +支持: + +reject +direct +wait + +例如: + +onAllUnavailable: wait +waitTimeout: 5s + +不要默认直连,尤其代理是业务强约束时。 + +最终建议 + +配置里加这几个字段就够了: + +upstreams: + provider-a: + enabled: true + +路由里加: + +strategy: sequential + +以及: + +sequential: + switchOn: + - exhausted + - disabled + - unavailable + switchBack: false + +这样就能同时解决: + +配置了但不启用 +手动禁用某个代理商 +当前代理商额度耗尽后自动切换 +控制是否恢复后自动切回 +所有代理商不可用时如何处理 + +其中最关键的是:“暂时不可用”和“真正耗尽”必须分开判断,否则上游偶尔超时一次,系统就会错误地永久切到下一家。 + +我反而不建议把"运行状态"和"判定条件"绑死。 + +你说的这个: + +连续多少次获取这个上游代理商都没有 IP 返回了 + +它其实是一种判定规则(Detection),而不是状态本身。 + +例如: + +API 连续返回空IP + │ + ▼ +判定: +NoAvailableProxy + │ + ▼ +Runtime State = EXHAUSTED + +这样以后就可以扩展很多种判定方式,而不是只有一种。 + +我建议把运行状态保持简单 + +例如: + +DISABLED +READY +UNAVAILABLE +EXHAUSTED + +其中: + +READY + +正常工作。 + +UNAVAILABLE + +临时不可用,例如: + +API 超时 +HTTP 500 +网络异常 +健康检查失败 +被限流(429) + +这种状态是短暂的。 + +EXHAUSTED + +真正没有资源了。 + +例如: + +连续 5 次获取都返回空 +API 返回"余额不足" +API 返回"套餐已用完" +API 返回"今日额度已用完" + +这种状态通常需要等待恢复。 + +然后增加 Detection + +例如: + +upstreams: + + provider-a: + + exhaustion: + + consecutiveEmptyFetch: 5 + + retryAfter: 10m + +表示: + +连续5次获取代理 + +↓ + +全部返回0个IP + +↓ + +RuntimeState = EXHAUSTED + +↓ + +10分钟后再尝试 + +这个就比写死在代码里灵活。 + +以后还能增加 + +例如: + +exhaustion: + + consecutiveEmptyFetch: 5 + + consecutiveErrors: 20 + + responseCodes: + + - NO_BALANCE + + - QUOTA_EXHAUSTED + + httpStatus: + + - 402 + + - 403 + +任意满足即可: + +余额不足 + │ + ├────────────┐ + │ │ +HTTP402 连续5次空IP + │ │ + └──────┬─────┘ + ▼ + EXHAUSTED +还有一种我觉得很有必要 + +有的代理商不会返回: + +没有IP + +而是一直返回: + +success + +但是: + +IPA + +一直重复。 + +例如: + +1.1.1.1 + +1.1.1.1 + +1.1.1.1 + +1.1.1.1 + +实际上说明: + +代理池已经没有新的 IP 可以给你了。 + +所以我还会加: + +exhaustion: + + consecutiveDuplicateFetch: 20 + +表示: + +连续20次获取 + +全部都是重复IP + +↓ + +认为代理池耗尽 +或者直接做成 Detection 规则 + +以后可以扩展任何条件: + +upstreams: + + provider-a: + + exhaustion: + + detect: + + consecutiveEmptyFetch: 5 + + consecutiveDuplicateFetch: 20 + + responseCodes: + + - NO_BALANCE + + - QUOTA_EXHAUSTED + + httpStatus: + + - 402 + + retryAfter: 30m + +这样: + +连续空 IP +连续重复 IP +返回余额不足 +返回额度耗尽 + +都可以进入: + +EXHAUSTED + +代码里就不用写一堆 if provider == xxx 了。 + +我还有一个建议 + +如果你的系统以后支持很多代理商,我甚至不会把这个叫 EXHAUSTED(耗尽)。 + +因为"连续获取不到 IP"并不一定是真的耗尽,有可能只是: + +当前地区没有 IP +当前运营商没有 IP +当前时间段库存为空 +上游正在补货 + +所以更准确一点可以叫: + +DEPLETED + +或者: + +NO_RESOURCE + +意思是: + +当前没有可分配资源。 + +然后由 retryAfter 或定时探测决定什么时候恢复,而不是认为它已经永久不可用。 + +我个人更倾向于 DEPLETED,因为它表达的是资源暂时被消耗完,比 EXHAUSTED 更符合代理池这种会周期性恢复库存的场景。 + +我反而觉得你这个思路更符合大多数人的使用习惯。 + +很多代理商实际上根本不会告诉你: + +额度是否耗尽 +套餐是否用完 +什么时候恢复 + +它只会一直返回: + +没有可用IP + +所以没必要搞那么复杂。 + +我建议就设计一个简单的切换策略 + +例如: + +routing: + - match: + hostRegex: '(^|\.)jd\.com$' + + upstreams: + - provider-a + - provider-b + - provider-c + + strategy: sequential + + switchAfterEmptyFetch: 5 + +意思就是: + +当前代理商连续 5 次获取不到 IP,就切换到下一个代理商。 + +运行流程 + +例如: + +provider-a + +第1次 获取 +↓ + +0 IP +(empty=1) + +第2次 +↓ + +0 IP +(empty=2) + +第3次 +↓ + +获取成功 +(empty=0) + +第4次 +↓ + +0 IP +(empty=1) + +只要获取成功一次,计数就清零。 + +如果: + +provider-a + +0IP +(empty=1) + +0IP +(empty=2) + +0IP +(empty=3) + +0IP +(empty=4) + +0IP +(empty=5) + +达到: + +switchAfterEmptyFetch: 5 + +立即: + +provider-a + │ + ▼ +标记跳过 + │ + ▼ +切换 provider-b +什么时候再回来? + +这个也不用复杂。 + +两种方式即可。 + +第一种:永远不回来(推荐) +strategy: sequential + +就是: + +A +↓ + +没IP + +↓ + +B +↓ + +没IP + +↓ + +C + +一直往后走。 + +直到: + +用户重启 +用户手动切换 +用户重新加载配置 + +很多人就是这么用套餐的。 + +第二种:循环 +strategy: roundRobin + +例如: + +A + +↓ + +没IP + +↓ + +B + +↓ + +没IP + +↓ + +C + +↓ + +没IP + +↓ + +A + +不断循环。 + +我觉得可以把这个配置放到 strategy 下面 + +例如: + +routing: + - match: + hostRegex: '(^|\.)jd\.com$' + + upstreams: + - provider-a + - provider-b + - provider-c + + strategy: + type: sequential + + switchAfterEmptyFetch: 5 + +以后如果要扩展: + +strategy: + type: sequential + + switchAfterEmptyFetch: 5 + + switchAfterErrors: 10 + +也不会破坏结构。 + +每个 Upstream 不需要状态 + +实际上只维护一个运行时变量就够了: + +type UpstreamRuntime struct { + EmptyFetchCount int +} + +逻辑非常简单: + +if fetchSuccess { + EmptyFetchCount = 0 +} + +if fetchResult == 0 { + EmptyFetchCount++ +} + +if EmptyFetchCount >= SwitchAfterEmptyFetch { + SwitchNextProvider() +} + +不需要什么 READY、EXHAUSTED、DEGRADED 等状态机。 + +我还建议增加一个很实用的小配置 + +因为有些代理商偶尔会返回空 IP,所以可以加一个冷却时间: + +strategy: + type: sequential + + switchAfterEmptyFetch: 5 + + retryCurrentAfter: 30m + +意思是: + +provider-a +↓ + +连续5次没IP +↓ + +切到provider-b +↓ + +30分钟后再尝试provider-a + +如果还是没 IP,再继续使用 provider-b。 + +这样既简单,又避免某个代理商恢复后永远不会再被使用。 + +我觉得整个配置只保留这两个参数就够了: + +strategy: + type: sequential + switchAfterEmptyFetch: 5 + +这是最符合直觉、实现成本也最低的方案。 + +我会按开源项目的设计文档来写,而不是用户使用文档。重点描述为什么这么设计、配置项的含义以及运行逻辑。下面是一个第一版实施文档示例。 + +# Proxy Pool 实施方案 + +## 一、项目简介 + +Proxy Pool 是一个支持多代理供应商(Upstream)的代理池管理组件。 + +主要功能: + +- 多代理供应商管理 +- 按规则自动路由 +- 自动维护代理池 +- 自动健康检测 +- 自动切换代理供应商 +- 支持 HTTP / HTTPS / SOCKS5(后续扩展) +- 支持固定 IP、动态 IP 等不同代理类型 + +--- + +## 二、设计目标 + +### 1、解耦 + +整个系统拆分为两部分: + +``` + Request + │ + ▼ + Routing Rule + │ + ▼ + Select Upstream + │ + ▼ + Proxy Pool + │ + ▼ + Provider API +``` + +Routing 只负责: + +> 请求应该走哪个代理池。 + +Proxy Pool 负责: + +> 如何维护代理。 + +Provider 负责: + +> 如何获取代理。 + +三者互不影响。 + +--- + +### 2、多供应商支持 + +一个代理池可以配置多个代理供应商。 + +例如: + +``` +京东 + +Provider A +Provider B +Provider C +``` + +也可以: + +``` +Github + +Provider D +``` + +Routing 不关心代理来源。 + +只关心使用哪个 Upstream。 + +--- + +## 三、配置结构 + +``` +config.yaml + +├── defaults +├── routing +└── upstreams +``` + +说明: + +|节点|说明| +|--------|------------| +|defaults|全局默认配置| +|routing|请求路由规则| +|upstreams|代理供应商配置| + +--- + +## 四、Routing + +Routing 用于决定: + +> 一个请求应该使用哪个 Upstream。 + +例如: + +```yaml +routing: + + - name: JD + + match: + + hostRegex: '(^|\.)jd\.com$' + + upstreams: + + - jd-a + + - jd-b + + - jd-c + + strategy: + + type: sequential + + switchAfterEmptyFetch: 5 +``` + +--- + +### 匹配方式 + +目前支持: + +```yaml +match: + + hostRegex: +``` + +后续可扩展: + +``` +method + +path + +header + +clientIP +``` + +--- + +## 五、Upstream + +每一个 Upstream 表示一个代理供应商。 + +例如: + +```yaml +upstreams: + + jd-a: + + enabled: true + + api: + + fetch: + + check: + + lifecycle: + + capacity: +``` + +--- + +## 六、启用状态 + +配置存在,不代表启用。 + +```yaml +enabled: true +``` + +表示参与运行。 + +```yaml +enabled: false +``` + +表示: + +- 不获取代理 +- 不健康检查 +- 不参与路由 + +仅保留配置。 + +--- + +## 七、代理获取 + +代理通过 API 获取。 + +例如: + +```yaml +api: + + url: https://xxx/api + + template: | + {{$x := regexFind "\d{1,3}(\.\d{1,3}){3}:\d{2,5}" .}} + {{printf "http://%s" $x}} +``` + +--- + +## 八、获取控制 + +```yaml +fetch: + + requestInterval: 1s + + retry: 5 + + timeout: 3s + + maxInFlight: 1 +``` + +参数说明: + +|参数|说明| +|------|------| +|requestInterval|两次请求 API 最小时间间隔| +|retry|获取失败最大重试次数| +|timeout|API 超时时间| +|maxInFlight|同时最多几个获取任务| + +--- + +## 九、健康检查 + +```yaml +check: + + urls: + + - http://connect.rom.miui.com/generate_204 + + interval: 10s + + timeout: 2s + + retry: 2 +``` + +健康检查失败: + +``` +重新检测 + +↓ + +仍失败 + +↓ + +移出代理池 +``` + +--- + +## 十、生命周期 + +```yaml +lifecycle: + + ttl: 120s + + allocationSafetyMargin: 10s +``` + +说明: + +``` +TTL + +代理生命周期 +``` + +``` +SafetyMargin + +距离过期多少秒停止分配新请求 +``` + +--- + +## 十一、容量控制 + +```yaml +capacity: + + maxConcurrencyPerProxy: 10 +``` + +表示: + +一个代理同时允许多少请求。 + +达到上限以后: + +不再继续分配。 + +--- + +## 十二、供应商切换 + +当一个 Routing 配置多个 Upstream 时: + +```yaml +upstreams: + + - jd-a + + - jd-b + + - jd-c +``` + +系统按照 Strategy 调度。 + +目前支持: + +``` +sequential + +random + +roundRobin + +leastConnections + +weighted +``` + +--- + +### Sequential + +Sequential 表示: + +按顺序使用代理供应商。 + +例如: + +``` +jd-a + +↓ + +jd-b + +↓ + +jd-c +``` + +默认始终使用第一个。 + +--- + +### 自动切换 + +```yaml +strategy: + + type: sequential + + switchAfterEmptyFetch: 5 +``` + +表示: + +连续 5 次获取不到代理。 + +自动切换到下一个 Upstream。 + +例如: + +``` +jd-a + +↓ + +第1次 + +无IP + +↓ + +第2次 + +无IP + +↓ + +第3次 + +无IP + +↓ + +第4次 + +无IP + +↓ + +第5次 + +无IP + +↓ + +切换 jd-b +``` + +只要成功获取一次代理。 + +计数立即清零。 + +例如: + +``` +无IP + +↓ + +无IP + +↓ + +获取成功 + +↓ + +计数清零 +``` + +--- + +## 十三、运行流程 + +``` +Request + │ + ▼ +Routing + │ + ▼ +Select Upstream + │ + ▼ +Proxy Pool + │ + ▼ +Allocate Proxy + │ + ▼ +Business Request +``` + +--- + +## 十四、代理获取流程 + +``` +Pool Need Proxy + │ + ▼ +Request Interval + │ + ▼ +Call API + │ + ▼ +Template Parse + │ + ▼ +Health Check + │ + ▼ +Join Pool +``` + +--- + +## 十五、切换流程 + +``` +Need Proxy + │ + ▼ +Call Provider API + │ + ├──────────────┐ + │ │ +Get Proxy Empty Result + │ │ + ▼ ▼ +Join Pool Empty Count++ + │ + ▼ + >= switchAfterEmptyFetch ? + │ + Yes ───┘ + │ + ▼ + Switch Next Upstream +``` + +--- + +## 十六、完整示例 + +```yaml +defaults: + + check: + + urls: + + - http://connect.rom.miui.com/generate_204 + + interval: 10s + + timeout: 2s + + retry: 2 + +routing: + + - name: JD + + match: + + hostRegex: '(^|\.)jd\.com$' + + upstreams: + + - jd-a + + - jd-b + + - jd-c + + strategy: + + type: sequential + + switchAfterEmptyFetch: 5 + +upstreams: + + jd-a: + + enabled: true + + api: + + url: https://provider-a/api + + template: | + {{$x := regexFind "\d{1,3}(\.\d{1,3}){3}:\d{2,5}" .}} + {{printf "http://%s" $x}} + + fetch: + + requestInterval: 1s + + retry: 5 + + timeout: 3s + + maxInFlight: 1 + + check: + + urls: + + - http://connect.rom.miui.com/generate_204 + + interval: 10s + + timeout: 2s + + retry: 2 + + lifecycle: + + ttl: 120s + + allocationSafetyMargin: 10s + + capacity: + + maxConcurrencyPerProxy: 10 + + jd-b: + + enabled: true + + api: + + url: https://provider-b/api + + jd-c: + + enabled: false + + api: + + url: https://provider-c/api +``` + +--- + +## 十七、设计原则 + +- **配置即声明**:配置描述“希望系统如何工作”,而不是实现细节。 +- **职责单一**:Routing 只负责路由,Upstream 只负责代理管理,Provider 只负责代理获取。 +- **按需扩展**:支持多个代理供应商,并可通过策略灵活调度。 +- **自动切换**:连续获取不到代理时,按配置自动切换到下一个 Upstream,无需人工干预。 +- **易于扩展**:后续增加新的调度策略、匹配条件或代理类型,不需要修改整体配置结构。 + +我建议再加一章 「调度策略(Scheduling Strategy)」,把 sequential、random、roundRobin、weighted、leastConnections 等策略分别画成流程图说明。这样文档不仅说明配置怎么写,还能清楚表达系统在不同策略下的运行方式,对后续开发和维护都会更直观。 + +有,而且还不少。你们现在已经把“多上游、路由、按顺序切换、健康检查、生命周期、容量”这些骨架搭起来了,下一步最容易踩坑的是边界条件、并发一致性、失败语义和可观测性。 + +我建议分成“第一版必须补”和“后续优化”两层。 + +第一版必须补的内容 +1. 规则冲突与默认行为 + +现在规则是从上往下匹配,那就必须明确: + +第一条命中即停止 + +还要定义没有任何规则命中时怎么办: + +routingDefault: + action: direct + +或者: + +routingDefault: + action: reject + +不要让程序自己猜。 + +还要在启动时检测: + +重复规则 +永远不会命中的规则 +.* 出现在前面导致后续规则失效 +引用了不存在的 upstream +所有 upstream 都被禁用 +2. enabled 不能只在启动时读取 + +最好支持运行时热更新: + +enabled: false + +修改后应做到: + +停止新代理拉取 +停止分配新请求 +已有连接允许完成 +代理池进入 draining +最终释放资源 + +不要直接粗暴断开已有连接。 + +3. 顺序切换状态必须持久化 + +例如: + +provider-a +→ provider-b +→ provider-c + +如果程序重启后又从 provider-a 开始,会重复消耗已经耗尽的供应商。 + +所以至少要保存: + +当前激活 upstream +连续空结果次数 +最后切换时间 + +可以放: + +本地文件 +Redis +数据库 + +单机可以先用本地持久化,多实例建议放 Redis 或数据库。 + +4. 连续空结果计数必须定义准确 + +“连续多少次获取不到 IP”需要明确什么算空。 + +建议只有下面情况才计数: + +API 请求成功 +响应解析成功 +最终得到 0 个有效代理 + +这些不应该算空: + +API 超时 +HTTP 500 +DNS 失败 +模板解析异常 +认证失败 +返回格式变更 + +否则代理商接口偶发故障,也会被误判成“没 IP”。 + +建议分开两个计数: + +emptyFetchCount +fetchErrorCount + +即使第一版不根据错误次数切换,也要至少分开记录。 + +5. 切换时的并发竞争 + +多个协程同时发现: + +emptyFetchCount >= 5 + +可能同时切换多次: + +A → B → C + +本来只该切到 B,结果直接跳到 C。 + +所以切换必须是原子的。 + +逻辑类似: + +lock() +if currentUpstream == expected { + switchNext() +} +unlock() + +或者使用 CAS。 + +6. 当前代理池里还有 IP 时是否切换 + +这是一个很关键的问题。 + +假设: + +provider-a 连续 5 次拉不到新 IP +但池里还有 20 个可用 IP + +应该怎么处理? + +建议: + +停止继续从 A 拉取 +已经获取的 A 代理继续用完 +新的补充请求转到 B +A 进入 draining + +不要把 A 现有可用代理直接丢掉。 + +这会比“立刻完全切换”节省很多资源。 + +7. 多个 routing 是否共享同一个 upstream + +例如: + +routing: + - name: jd + upstreams: [provider-a] + + - name: taobao + upstreams: [provider-a] + +那 provider-a 的: + +maxSize +并发数 +空结果计数 +切换状态 + +是全局共享,还是每条 routing 独立? + +建议: + +upstream 代理池全局共享 +routing 只维护自己的当前选择顺序 +emptyFetchCount 放在 upstream 运行时 +当前激活项放在 routing strategy 运行时 + +否则同一个代理商会被重复维护多个池。 + +8. maxSize 的计数边界 + +必须明确哪些代理算入 maxSize: + +建议算: + +checking +healthy +busy +draining + +不算: + +expired +removed + +正在 fetch 的请求可以单独算: + +pendingFetch + +实际限制: + +currentProxyCount + expectedPending <= maxSize + +否则多个并发拉取会突破上限。 + +9. API 返回多个 IP 的处理 + +不能假设每次只返回一个。 + +需要明确: + +返回数量超过剩余容量怎么办 + +建议: + +去重 +校验格式 +只保留 maxSize 剩余容量以内的代理 +多余代理丢弃并记录指标 + +还要处理: + +同一个 API 返回重复 IP +不同供应商返回同一个 IP +同 IP 不同端口 +同 IP 不同认证信息 + +建议代理唯一键使用: + +scheme + host + port + username + +不要只按 IP 去重。 + +10. 代理检测不能只看“能连接” + +健康检查至少区分: + +连接成功 +HTTP 响应成功 +目标站可访问 +出口 IP 正确 + +否则可能出现代理能访问百度,但访问目标域名被封。 + +第一版可以简单做两层: + +check: + connectivityUrl: http://connect.rom.miui.com/generate_204 + targetUrl: https://目标域名/ + +目标检测失败时,不一定全局删除代理,可以只对该 routing 暂时不可用。 + +11. 请求失败后的代理处理 + +业务请求失败,不应该一律把代理删除。 + +建议区分: + +连接超时:代理可能有问题 +407:认证错误 +403:可能是目标站封禁 +429:可能是目标站限流 +5xx:可能是目标站问题 +客户端主动取消:不能怪代理 + +至少维护: + +consecutiveFailures +lastFailureType + +不要一次失败就淘汰。 + +12. 是否允许直连必须明确配置 + +所有代理都不可用时: + +onUnavailable: + action: reject + +可选: + +reject +direct +wait + +不要默认直连。 + +尤其是必须隐藏真实出口 IP 的业务,默认直连很危险。 + +建议优化的配置结构 + +你们现在的结构可以再整理一下: + +version: 1 + +defaults: + fetch: + timeout: 3s + retry: 3 + + check: + interval: 10s + timeout: 2s + retry: 2 + +routing: + - name: jd + enabled: true + + match: + hostRegex: '(^|\.)jd\.com$' + + upstreams: + - provider-a + - provider-b + + strategy: + type: sequential + switchAfterEmptyFetch: 5 + + onUnavailable: + action: reject + +upstreams: + provider-a: + enabled: true + + api: + url: https://provider-a.example/api + method: GET + template: | + ... + + pool: + maxSize: 100 + + capacity: + maxConcurrencyPerProxy: 10 + + lifecycle: + ttl: 120s + allocationSafetyMargin: 10s + + fetch: + requestInterval: 1s + maxInFlight: 1 + + check: + interval: 10s + +这里建议 routing 也增加: + +enabled: true + +因为有时候不是禁用代理商,而是临时禁用整条业务规则。 + +后续值得做的优化 +1. 延迟拉取 + +不要启动时就拉代理。 + +只有满足下面条件才拉: + +请求到来 +且没有足够可用容量 + +对于使用计费型供应商,可以提前少量预热。 + +2. 请求合并 + +大量请求同时发现没代理时,不要每个请求都触发一次拉取。 + +应该合并为一个 fetch 任务: + +100 个请求等待 +↓ +只触发 1 次或少量 API 获取 +↓ +结果广播给等待者 + +这叫 singleflight,很值得做。 + +3. 负载感知 + +不要只按 IP 数量判断是否需要拉取。 + +应该看: + +可用并发槽位 + +例如: + +有 10 个 IP +但全部满载 + +实际上仍然需要拉新代理。 + +4. 防抖 + +流量波动时,避免不停: + +拉取 +释放 +拉取 +释放 + +可以增加: + +pool: + idleReleaseDelay: 60s + +不过对于有固定 TTL 的代理,通常直接让它自然过期更省事。 + +5. 切换冷却 + +连续空结果切换后,不要马上又尝试旧代理商。 + +可以保留一个简单参数: + +strategy: + type: sequential + switchAfterEmptyFetch: 5 + switchCooldown: 10m + +不是必须,但很实用。 + +6. 最后一家用完后怎么办 + +顺序策略必须定义: + +strategy: + type: sequential + switchAfterEmptyFetch: 5 + endBehavior: stop + +支持: + +stop +loop +stayLast + +含义: + +stop:全部用完后停止 +loop:重新从第一个开始 +stayLast:停留在最后一个继续尝试 + +我建议默认: + +endBehavior: stop + +避免重复消耗已经没资源的供应商。 + +7. 管理接口 + +后面最好有一个简单管理 API: + +GET /status +POST /upstreams/{name}/enable +POST /upstreams/{name}/disable +POST /routing/{name}/switch +POST /reload + +这样不用每次改配置重启。 + +8. 指标监控 + +至少记录: + +当前启用 upstream +当前代理数量 +可用代理数量 +活跃并发 +拉取次数 +空返回次数 +拉取失败次数 +切换次数 +健康检查失败次数 +请求成功率 +请求延迟 + +尤其是这几个最重要: + +upstream_switch_total +upstream_empty_fetch_total +proxy_available_total +proxy_active_connections +9. 配置校验 + +启动时应阻止明显错误: + +switchAfterEmptyFetch <= 0 +maxSize <= 0 +maxConcurrencyPerProxy <= 0 +requestInterval < 0 +routing 引用不存在的 upstream +顺序策略只有一个 upstream +所有 upstream 都 disabled +正则无法编译 + +宁愿启动失败,也不要运行中悄悄出错。 + +最值得优先实现的 8 项 + +按开发优先级,我会这样排: + +配置校验 +首条命中路由 +upstream 启用/禁用 +连续空结果自动切换 +切换操作原子化 +maxSize 并发防超限 +singleflight 合并拉取 +状态与指标接口 + +做到这 8 项,第一版就已经比较稳了。 + +你们现在最大的设计风险不是“功能少”,而是多个请求同时触发拉取、切换和计数时出现竞态。这部分最好一开始就设计好,不然后面压力一上来,最容易出现重复拉取、越过 maxSize、连续跳过多个供应商等问题。 + +可以,这相当于系统除了“替用户转发请求”,还要再提供一种模式: + +用户通过 API 从聚合代理池领取代理地址,然后自行连接该代理。 + +这时项目就不只是代理网关,还多了一个 代理分发服务(Proxy Distribution API)。 + +建议把两种能力分开: + +模式一:Gateway +用户请求 → 系统选择代理 → 系统代转发 + +模式二:Extract API +用户调用 API → 系统返回代理地址 → 用户自行使用 +一、增加对外提取 API + +例如: + +GET /api/v1/proxies + +请求参数: + +count 提取数量 +protocol http / https / socks5 +region 地区 +carrier 运营商 +ttl 希望最低剩余有效期 +session 固定会话标识 + +示例: + +GET /api/v1/proxies?count=5&protocol=http®ion=shanghai +Authorization: Bearer xxxx + +返回: + +{ + "code": 0, + "data": { + "proxies": [ + { + "id": "px_01", + "url": "http://user:pass@1.2.3.4:8000", + "expiresAt": "2026-07-28T18:30:00+08:00", + "region": "shanghai" + } + ] + } +} + +也可以支持纯文本: + +GET /api/v1/proxies?count=5&format=text + +返回: + +http://user:pass@1.2.3.4:8000 +http://user:pass@5.6.7.8:9000 +二、不能简单把池里的 IP 直接返回 + +这是最重要的一点。 + +系统内部转发时,系统知道: + +这个代理当前有多少并发 +什么时候释放 +是否已经失效 + +但把代理地址交给外部用户后,系统无法天然知道: + +用户什么时候开始使用 +使用多少并发 +用了多久 +是否还在使用 +是否把代理转发给其他人 + +所以必须引入: + +Lease(租约) + +每次通过 API 提取代理,都创建一条租约。 + +用户 + ↓ +领取代理 + ↓ +创建 Lease + ↓ +占用代理容量 + ↓ +租约到期后释放 +三、租约模型 + +建议运行时维护: + +leaseId +clientId +proxyId +issuedAt +expiresAt +reservedConcurrency +status + +例如: + +{ + "leaseId": "lease_123", + "proxyId": "px_01", + "expiresAt": "2026-07-28T18:30:00+08:00", + "reservedConcurrency": 1 +} + +默认可以规定: + +distribution: + leaseDuration: 60s + concurrencyPerLease: 1 + +表示: + +用户提取一个代理 +默认占用一个并发槽位 +60 秒后自动释放租约 +四、提取数量和并发容量不能混为一谈 + +例如: + +一个代理最多支持 10 并发 + +用户提取一次,不一定意味着这个 IP 彻底不能再分配。 + +可以有两种分配模式。 + +独占模式 +allocationMode: exclusive + +一个代理只分配给一个用户。 + +适合: + +固定 IP +登录会话 +对 IP 隔离要求高 +单用户独占套餐 +共享模式 +allocationMode: shared + +同一个代理可以被多个用户领取,但不能超过容量。 + +例如: + +maxConcurrencyPerProxy = 10 +已经预留 = 7 +剩余 = 3 + +还可以继续分配 3 个租约。 + +建议默认: + +allocationMode: shared +concurrencyPerLease: 1 +五、用户提取后是否从内部代理池移除 + +不应该直接移除,而应该变成: + +Healthy + ↓ +Leased + +如果还有剩余容量,仍然可以继续参与分配。 + +例如: + +Proxy A +最大并发:10 +内部网关占用:4 +外部租约占用:3 +剩余容量:3 + +统一计算: + +availableConcurrency = +maxConcurrency +- internalActive +- leasedConcurrency +- reservedConcurrency + +这样网关模式和 API 提取模式可以共用同一个聚合代理池。 + +六、最好支持池隔离 + +虽然可以共用一个池,但实际运行中建议支持隔离。 + +例如: + +upstreams: + provider-a: + exposure: + gateway: true + extractApi: true + +或者: + +exposure: + modes: + - gateway + - extract + +某些代理商可能只允许内部使用: + +exposure: + modes: + - gateway + +某些代理商专门用于用户提取: + +exposure: + modes: + - extract + +还可以设置比例: + +capacity: + maxConcurrencyPerProxy: 10 + gatewayReserveRatio: 0.3 + extractReserveRatio: 0.7 + +避免 API 用户一下把所有代理容量领光,导致内部代理网关没代理可用。 + +七、增加 Distribution 配置 + +建议在顶层增加: + +distribution: + enabled: true + + listen: 0.0.0.0:8081 + + auth: + type: bearer + + lease: + duration: 60s + maxDuration: 10m + concurrencyPerLease: 1 + + limits: + maxCountPerRequest: 20 + requestsPerMinute: 60 + maxActiveLeasesPerClient: 100 + + allocation: + mode: shared + strategy: leastLoad + + response: + defaultFormat: json + +完整一点: + +distribution: + enabled: true + + endpoints: + extract: /api/v1/proxies + release: /api/v1/leases/{leaseId} + status: /api/v1/leases/{leaseId} + + auth: + type: apiKey + header: X-API-Key + + lease: + duration: 60s + maxDuration: 5m + concurrencyPerLease: 1 + + limits: + maxCountPerRequest: 10 + maxActiveLeasesPerClient: 50 + requestsPerMinute: 30 + + allocation: + mode: shared + strategy: leastLoad + minRemainingTTL: 15s + + response: + defaultFormat: json + allowTextFormat: true +八、建议提供三个 API +提取代理 +POST /api/v1/proxies/extract + +请求: + +{ + "count": 3, + "protocol": "http", + "region": "shanghai", + "leaseDuration": "60s" +} + +响应: + +{ + "proxies": [ + { + "leaseId": "lease_001", + "proxy": "http://user:pass@1.2.3.4:8000", + "expiresAt": "2026-07-28T18:30:00+08:00" + } + ] +} +主动释放 +DELETE /api/v1/leases/lease_001 + +用户提前用完时可以释放。 + +查询租约 +GET /api/v1/leases/lease_001 + +返回: + +{ + "status": "active", + "expiresAt": "2026-07-28T18:30:00+08:00" +} +九、连续无 IP 时仍然要触发供应商切换 + +API 用户请求代理时,流程和内部网关一致: + +用户请求提取 5 个代理 + ↓ +当前上游容量不足 + ↓ +调用当前代理商 API + ↓ +连续 N 次没有 IP + ↓ +切换到下一个代理商 + ↓ +继续补足用户请求 + +例如: + +routing: + - name: public-extract + purpose: extract + + upstreams: + - provider-a + - provider-b + - provider-c + + strategy: + type: sequential + switchAfterEmptyFetch: 5 + +也可以让网关和提取 API 使用不同的供应商顺序: + +routing: + - name: gateway-jd + purpose: gateway + upstreams: + - provider-a + - provider-b + + - name: public-extract + purpose: extract + upstreams: + - provider-c + - provider-b + +这个设计很实用,因为有些代理商适合内部转发,有些适合对外分发。 + +十、必须做用户级限制 + +否则一个用户调用: + +count=10000 + +就可能把整个池拿空。 + +至少需要: + +clients: + user-a: + enabled: true + apiKey: xxx + + limits: + maxCountPerRequest: 10 + maxActiveLeases: 100 + requestsPerMinute: 60 + maxConcurrentCapacity: 100 + +还可以限制允许使用的代理池: + +clients: + user-a: + allowedUpstreams: + - provider-a + - provider-b + +以及允许的地区: + +allowedRegions: + - shanghai + - beijing +十一、认证信息泄露问题 + +如果返回: + +http://username:password@ip:port + +用户就能看到上游代理商的真实账号密码。 + +更安全的做法是由聚合服务生成临时认证: + +http://临时用户名:临时密码@你的代理入口:端口 + +然后由系统再转发到真实上游。 + +即: + +用户 + ↓ +聚合代理入口 + ↓ +真实上游代理 + +这种模式叫: + +Proxy Gateway Credential + +优点: + +不暴露供应商账号 +可以随时撤销 +可以限制租约时间 +可以统计真实使用量 +可以控制用户并发 +可以防止用户长期保存代理地址 + +如果直接返回真实代理 IP 和认证信息,租约只能算“逻辑约束”,无法真正阻止用户在过期后继续使用。 + +十二、推荐两种提取模式 +Raw 模式 + +直接返回真实上游地址: + +distribution: + mode: raw + +优点: + +实现简单 +性能高 +用户直接连接供应商代理 + +缺点: + +难以控制租约 +难以统计实际使用 +上游凭证可能泄露 +用户可能绕过系统继续使用 +Gateway 模式 + +返回你自己的代理入口: + +distribution: + mode: gateway + +返回: + +http://lease_001:temporary-password@proxy.example.com:8080 + +系统内部根据 lease_001 选择真实代理。 + +优点: + +可控 +可撤销 +可计费 +可限制并发 +不暴露供应商信息 + +对于正式对外提供服务,我更推荐 gateway 模式。 + +十三、建议加入项目实施范围 + +第一版可以先做: + +1. JSON / 文本格式提取 +2. API Key 鉴权 +3. count 数量限制 +4. 共享容量分配 +5. 固定租约时间 +6. 自动过期释放 +7. 连续空 IP 自动切换供应商 +8. 用户级速率限制 +9. 提取、释放、查询三个接口 + +第二版再做: + +1. 临时代理账号 +2. Gateway 模式 +3. 地区和运营商筛选 +4. Sticky Session +5. 用户套餐与计费 +6. 租约续期 +7. 使用量统计 +8. 多实例租约一致性 + +最终架构可以变成: + + ┌────────────────────┐ +Business Request ─────▶│ Proxy Gateway │ + └─────────┬──────────┘ + │ + ▼ + Aggregate Proxy Pool + ▲ + │ + ┌─────────┴──────────┐ +Extract API Request ───▶│ Distribution API │ + └────────────────────┘ + │ + ▼ + Provider A / B / C + +核心原则是: + +网关请求和 API 提取都从同一个容量模型里分配代理,但通过租约、配额和预留比例避免互相抢光资源。 + +这样项目就既能作为透明代理服务使用,也能作为“代理 IP 提取平台”使用。 + +对,这个必须纳入,而且要区分两类认证,别混在一起: + +请求代理商提取 API 的认证 +最终代理 IP 本身的认证 + +它们不是一回事。 + +一、提取 API 的账号密码 + +例如代理商要求: + +GET https://provider.example.com/get +Authorization: Basic xxx + +或者: + +?username=abc&password=123 + +建议统一放到 api.auth: + +upstreams: + provider-a: + enabled: true + + api: + url: https://provider.example.com/get + method: GET + + auth: + type: basic + username: ${PROVIDER_A_API_USERNAME} + password: ${PROVIDER_A_API_PASSWORD} + +支持几种常见方式: + +auth: + type: none +auth: + type: basic + username: ${API_USERNAME} + password: ${API_PASSWORD} +auth: + type: bearer + token: ${API_TOKEN} +auth: + type: apiKey + location: header + name: X-API-Key + value: ${API_KEY} +auth: + type: apiKey + location: query + name: key + value: ${API_KEY} +二、直接通过请求参数认证 + +有些代理商把账号密码放进 URL 参数,例如: + +https://provider.example.com/get?username=xxx&password=xxx + +可以支持: + +api: + url: https://provider.example.com/get + method: GET + + query: + username: ${PROVIDER_A_API_USERNAME} + password: ${PROVIDER_A_API_PASSWORD} + count: 1 + +程序负责 URL 编码,避免用户手工拼接: + +url: https://provider.example.com/get?username=xxx&password=xxx + +后者容易泄露到日志里,也容易因为特殊字符出问题。 + +三、POST 表单或 JSON 认证 + +部分供应商可能要求 POST: + +api: + url: https://provider.example.com/get + method: POST + + headers: + Content-Type: application/json + + body: + type: json + value: + username: ${PROVIDER_A_API_USERNAME} + password: ${PROVIDER_A_API_PASSWORD} + count: 1 + +表单方式: + +body: + type: form + value: + username: ${PROVIDER_A_API_USERNAME} + password: ${PROVIDER_A_API_PASSWORD} + num: 1 + +这样基本能覆盖绝大多数代理商接口。 + +代理本身的账号密码 + +代理商返回的代理可能是: + +1.2.3.4:8000 + +但使用代理时还需要: + +proxyUsername +proxyPassword + +可以配置为固定认证: + +proxyAuth: + type: static + username: ${PROVIDER_A_PROXY_USERNAME} + password: ${PROVIDER_A_PROXY_PASSWORD} + +解析后内部组合为: + +http://username:password@1.2.3.4:8000 + +有些接口直接返回完整地址: + +http://user:pass@1.2.3.4:8000 + +则配置: + +proxyAuth: + type: response + +还有一种是白名单 IP 鉴权: + +proxyAuth: + type: ipWhitelist + +这种就不需要用户名密码。 + +推荐完整配置 +upstreams: + provider-a: + enabled: true + + provider: + billingMode: fetch + protocols: + - http + - https + + api: + url: https://provider.example.com/api/proxy + method: GET + + auth: + type: basic + username: ${PROVIDER_A_API_USERNAME} + password: ${PROVIDER_A_API_PASSWORD} + + query: + count: 1 + format: text + + headers: + Accept: text/plain + + template: | + {{$x := regexFindAll "\d{1,3}(\.\d{1,3}){3}:\d{2,5}" . -1}} + {{range $s := $x}}{{printf "http://%s\n" $s}}{{end}} + + proxyAuth: + type: static + username: ${PROVIDER_A_PROXY_USERNAME} + password: ${PROVIDER_A_PROXY_PASSWORD} + + pool: + maxSize: 100 + + capacity: + maxConcurrencyPerProxy: 10 + + lifecycle: + ttl: 120s + allocationSafetyMargin: 10s + + fetch: + requestInterval: 1s + timeout: 3s + retry: 5 + maxInFlight: 1 +密钥不要直接写配置文件 + +不建议: + +username: myuser +password: 123456 + +建议用环境变量: + +username: ${PROVIDER_A_API_USERNAME} +password: ${PROVIDER_A_API_PASSWORD} + +程序启动时替换。 + +也可以支持文件引用: + +passwordFile: /run/secrets/provider_a_password + +或者: + +tokenFile: /run/secrets/provider_a_token + +优先级可以定义为: + +直接配置值 +环境变量 +Secret 文件 + +但生产环境最好只允许后两种。 + +日志必须脱敏 + +涉及这些字段时: + +password +token +apiKey +Authorization +Proxy-Authorization + +日志里必须变成: + +*** + +URL 查询参数也要过滤,例如: + +https://provider.example.com/get?username=abc&password=*** + +否则账号密码很容易出现在: + +应用日志 +错误日志 +监控系统 +链路追踪 +HTTP access log +配置结构建议 + +最终 api 节点可以统一成: + +api: + url: + method: + + auth: + type: + username: + password: + token: + location: + name: + value: + + headers: {} + query: {} + + body: + type: + value: + + template: + +而代理连接认证独立: + +proxyAuth: + type: + username: + password: + +这样一眼就能看明白: + +api.auth += 如何请求供应商接口 + +proxyAuth += 如何连接获取到的代理 + +这是最关键的边界,不然后面很容易把 API 密码和代理密码搞串。 + + +都需要考虑,而且这两个认证应该分开设计。 + +实际上你的系统以后会有 3 层认证,很多代理平台就是这么做的。 + + 聚合代理池 + + ┌──────────────────────┐ + │ ① 提取 API 认证 │ + └─────────┬────────────┘ + │ + GET /api/proxies│ + ▼ + 返回代理信息 + │ + │ + ┌─────────┴────────────┐ + │ ② 代理接入认证 │ + └─────────┬────────────┘ + │ + CONNECT/HTTP Proxy + ▼ + 聚合代理池 + │ + ▼ + ┌─────────┴────────────┐ + │ ③ 上游代理认证 │ + └──────────────────────┘ +① 提取 API(必须认证) + +例如: + +GET /api/v1/proxies + +绝对不能开放。 + +建议至少支持: + +API Key +Bearer Token +Basic Auth + +例如: + +Authorization: Bearer xxxxxxxxx + +或者 + +X-API-Key: xxxxxxxxx + +认证成功后才能: + +提取 IP +查看租约 +释放租约 + +这是业务接口认证。 + +② 聚合代理池(也建议认证) + +如果你的聚合代理支持: + +http://proxy.xxx.com:8080 + +用户浏览器或者程序直接配置: + +HTTP Proxy + +那么也建议认证。 + +例如: + +proxy.xxx.com:8080 + +用户名:user001 +密码:****** + +HTTP Proxy 就是: + +Proxy-Authorization + +SOCKS5 也是: + +Username / Password + +这样好处很多: + +可以区分用户 +可以统计流量 +可以限速 +可以限制并发 +可以限制允许访问哪些 Routing +可以禁用某个用户 + +否则所有人都共用一个入口。 + +我建议支持三种模式 +IP 白名单 +gateway: + + auth: + + type: ipWhitelist + +例如: + +1.1.1.1 + +2.2.2.2 + +这些 IP 可以直接连。 + +用户密码 +gateway: + + auth: + + type: basic + +例如: + +username + +password + +最常见。 + +Token + +例如: + +Proxy-Authorization: Bearer xxxxxx + +以后方便接 OAuth。 + +③ 上游代理认证 + +这个前面已经说了。 + +聚合代理连接真正代理商的时候: + +Provider A + +需要: + +user/pass + +这是系统内部使用。 + +用户完全不知道。 + +其实以后最好抽象成 Client + +例如: + +clients: + + client-a: + + enabled: true + + apiKey: xxxx + + gatewayAuth: + + username: aaa + + password: bbb + + permissions: + + routing: + + - jd + + - taobao + + extract: true + + gateway: true + + limits: + + maxExtractCount: 20 + + maxConcurrent: 100 + + qps: 50 + +以后所有权限都挂在 Client 上。 + +例如: + +Client A + +↓ + +允许: + +提取IP + +↓ + +允许: + +走Gateway + +↓ + +允许: + +JD + +↓ + +不允许: + +淘宝 + +以后做 SaaS 就很方便。 + +还有一个很多人都会漏掉 + +如果用户: + +API 提取了 IP + +是不是还能: + +走 Gateway + +我建议权限分开。 + +例如: + +permissions: + + extract: true + + gateway: false + +或者: + +permissions: + + extract: false + + gateway: true + +有些客户只买: + +提取模式 + +有些客户只买: + +代理转发 + +不要绑在一起。 + +我建议整个项目以后就围绕四个核心对象设计 +Client(客户端) + │ + ├── Authentication(认证) + │ + ├── Permission(权限) + │ + ├── Limits(配额) + │ + └── Usage(统计) + │ + ▼ + Routing + │ + ▼ + Upstream Pool + │ + ▼ + Provider + +这样后面无论增加: + +API 提取 +HTTP Proxy +SOCKS5 +Web 管理后台 +用户套餐 +计费 + +都不用改核心架构,只是在 Client 这一层扩展认证、权限和配额即可。这也是大多数商业代理平台采用的设计思路。 + + +对,客户端认证不应该强制开启,应该做成可配置能力。内网、自用、单机部署时强制认证,反而增加配置和使用成本。 + +不过建议不要简单设计成 auth: true/false,而是区分两个入口: + +Gateway 代理入口认证 +Extract API 提取接口认证 + +它们可以独立开启或关闭。 + +推荐配置 +gateway: + enabled: true + listen: 0.0.0.0:8080 + + auth: + mode: none + +distribution: + enabled: true + listen: 0.0.0.0:8081 + + auth: + mode: apiKey + +这样可以实现: + +HTTP/SOCKS5 代理入口:无需认证 +提取 IP API:需要 API Key + +反过来也可以: + +gateway: + auth: + mode: usernamePassword + +distribution: + auth: + mode: none + +只是第二种通常不太推荐。 + +支持的认证模式 + +建议统一使用 mode: + +auth: + mode: none +auth: + mode: usernamePassword +auth: + mode: apiKey +auth: + mode: ipWhitelist + +还可以允许组合认证: + +auth: + mode: any + + methods: + - type: ipWhitelist + cidrs: + - 192.168.0.0/16 + - 10.0.0.0/8 + + - type: usernamePassword + +这里 any 表示满足任意一种即可: + +来源属于内网白名单 + 或 +提供正确账号密码 + +这个模式特别适合: + +内网调用免认证 +外网调用必须认证 +同一套服务同时面向内外网 +内网使用也别完全依赖“没有公网暴露” + +即使只在内网运行,也可能遇到: + +局域网里其他设备误用 +容器端口意外映射到宿主机 +防火墙配置错误 +VPN 用户访问 +反向代理把接口暴露出去 +SSRF 利用内部代理 +某台内网设备被入侵后滥用代理池 + +所以可以允许关闭认证,但最好提供安全保护。 + +关闭认证时限制监听地址 + +例如仅监听本机: + +gateway: + listen: 127.0.0.1:8080 + + auth: + mode: none + +或者绑定内网地址: + +gateway: + listen: 192.168.1.10:8080 + + auth: + mode: none + +不要在无认证时默认监听: + +0.0.0.0 +无认证时增加来源网段限制 +gateway: + auth: + mode: none + + access: + allowCIDRs: + - 192.168.0.0/16 + - 10.0.0.0/8 + - 127.0.0.1/32 + +注意,这不是身份认证,而是访问控制。两者最好分开: + +access: + allowCIDRs: [] + +auth: + mode: none + +这样语义更清楚。 + +提取 API 是否可以不认证 + +技术上可以,产品上应允许,默认上不建议。 + +提取 API 比代理入口更容易被滥用,因为调用一次就能批量拿走代理: + +GET /api/v1/proxies?count=100 + +无认证时系统无法知道: + +谁提取了代理 +每个用户提取了多少 +应该限制谁 +谁需要承担租约 +谁触发了上游代理消耗 + +所以无认证的提取 API 最好只用于: + +单机工具 +受控内网 +测试环境 +可信局域网服务 + +配置可以允许: + +distribution: + auth: + mode: none + + access: + allowCIDRs: + - 127.0.0.1/32 + - 192.168.0.0/16 + +同时仍然执行全局限制: + +distribution: + limits: + maxCountPerRequest: 10 + requestsPerMinute: 60 + maxActiveLeases: 100 + +认证关闭不代表限流、租约和容量控制也关闭。 + +无认证时如何识别客户端 + +认证关闭后没有 clientId,但系统仍然需要做统计和限流。 + +可以设置: + +clientIdentification: + mode: sourceIP + +系统使用来源 IP 作为匿名客户端标识: + +anonymous:192.168.1.23 + +例如: + +distribution: + auth: + mode: none + + clientIdentification: + mode: sourceIP + + limits: + maxCountPerRequest: 10 + requestsPerMinutePerClient: 30 + +但要注意反向代理场景。如果服务前面有 Nginx,直接读取连接来源,看到的可能永远是 Nginx 的 IP。 + +因此需要明确可信代理: + +network: + trustedProxies: + - 192.168.1.20/32 + +只有请求来自可信反向代理时,才接受: + +X-Forwarded-For +Forwarded + +否则攻击者可以伪造来源 IP 绕过限流。 + +建议的默认安全策略 + +可以设定以下默认值: + +gateway: + listen: 127.0.0.1:8080 + + auth: + mode: none + +distribution: + listen: 127.0.0.1:8081 + + auth: + mode: none + +这对本机部署开箱即用。 + +当监听非回环地址时,例如: + +listen: 0.0.0.0:8080 + +程序可以: + +输出醒目警告; +要求配置认证或 allowCIDRs; +严格模式下直接拒绝启动。 + +例如: + +security: + requireProtectionOnPublicListen: true + +校验规则: + +监听非回环地址 +并且 auth.mode = none +并且 allowCIDRs 为空 + ↓ +启动失败 + +这比强制所有场景认证更合理。 + +最终建议结构 +gateway: + enabled: true + listen: 192.168.1.10:8080 + + access: + allowCIDRs: + - 192.168.0.0/16 + + auth: + mode: none + + limits: + maxConcurrentConnections: 500 + +distribution: + enabled: true + listen: 192.168.1.10:8081 + + access: + allowCIDRs: + - 192.168.0.0/16 + + auth: + mode: none + + clientIdentification: + mode: sourceIP + + limits: + maxCountPerRequest: 10 + requestsPerMinutePerClient: 30 + maxActiveLeasesPerClient: 50 + +公网部署时改成: + +gateway: + listen: 0.0.0.0:8080 + + auth: + mode: usernamePassword + +distribution: + listen: 0.0.0.0:8081 + + auth: + mode: apiKey + +所以最终原则可以写进实施文档: + +客户端认证为可选能力,不作为系统运行的强制条件。系统应允许在可信内网或本机环境中关闭认证,但认证、来源访问控制、客户端识别和限流应相互独立。对非回环地址开放且未配置任何保护措施时,系统应发出警告或拒绝启动。 + +这样既不会把内网用户折腾得够呛,也不会让一个误开的 0.0.0.0 直接变成免费公共代理。 + + +还有一些,而且现在最该补的,不是再加更多功能,而是把边界、并发和故障处理收紧。你们当前方案已经覆盖了: + +多上游代理商 +聚合代理池 +连续空 IP 自动切换 +API 提取 IP +HTTP/SOCKS 代理入口 +可选客户端认证 +上游 API 认证 +健康检查、TTL、并发容量 + +剩下主要是下面这些。 + +一、必须先定清楚的核心语义 +1. “获取成功”到底怎么算 + +上游 API 返回了内容,不代表成功获取到代理。 + +建议完整流程是: + +请求上游 API 成功 +→ 响应解析成功 +→ 代理格式合法 +→ 去重后还有新代理 +→ 代理通过基础检测 +→ 才算获取成功 + +例如上游返回: + +1.2.3.4:8080 +1.2.3.4:8080 +非法内容 + +池里本来已经有 1.2.3.4:8080,那么这次实际新增数量仍然是 0。 + +这里要明确: + +switchAfterEmptyFetch + +是根据: + +上游原始返回 0 个 + +还是: + +最终成功加入池中 0 个 + +我更建议按: + +上游响应解析后,没有得到任何合法代理。 + +不要因为全是重复 IP 就直接切换供应商,否则可能误判。重复 IP 可以单独记指标。 + +2. 获取错误和空结果必须分开 + +这点非常重要: + +空结果: +API 正常返回,但没有 IP + +获取错误: +超时、500、认证失败、解析错误、DNS 错误 + +只有空结果累加: + +consecutiveEmptyFetch + +错误要走另外的重试和退避逻辑: + +fetchErrorCount + +否则上游临时网络故障,也会被当成“代理已经用完”。 + +3. 切换是针对谁生效 + +假设同一个上游被多个路由使用: + +routing: + - name: jd + upstreams: [provider-a, provider-b] + + - name: taobao + upstreams: [provider-a, provider-c] + +当 provider-a 连续空 5 次,到底是: + +所有路由都停用 provider-a + +还是: + +只让 jd 切到 provider-b + +建议定义为: + +空结果计数属于 upstream +当前选中的供应商属于 routing +当 upstream 触发不可补充时,所有引用它的 routing 都可以切换 +已经在池里的代理仍然允许继续使用 + +否则不同路由之间会出现状态打架。 + +二、容量模型还需要补完整 +4. 不要只按 IP 数量补池 + +例如: + +池中有 100 个 IP +每个最大并发 1 +当前 100 个都在忙 + +虽然数量达到 maxSize,实际上已经没有可用容量。 + +因此补池判断应基于: + +availableSlots + +而不是只看: + +proxyCount + +计算可以统一为: + +可用容量 = +所有健康代理最大并发总和 +- 网关活跃并发 +- API 提取租约预留 +- 正在分配但尚未建立的预留容量 +5. 分配过程需要临时预留 + +容易出现这种竞态: + +代理剩余容量 = 1 + +请求 A 查询:可用 +请求 B 查询:也可用 + +A 分配 +B 也分配 + +于是超出最大并发。 + +因此需要一个状态: + +reservedConcurrency + +分配流程: + +选择代理 +→ 原子预留容量 +→ 建立连接 +→ 成功后 reserved 转 active +→ 失败后释放 reserved +6. API 提取模式无法真实知道用户是否还在使用 + +如果直接把真实上游 IP 返回给用户,系统只能通过租约推测容量。 + +例如租约是 60 秒,但用户可能: + +5 秒就不用了 +使用 10 分钟 +同一个 IP 开 20 个连接 +把 IP 分享给别人 + +所以 Raw 提取模式下: + +租约容量只是估算 + +不能作为精确并发控制。 + +需要在文档明确: + +raw 模式:弱控制 +gateway 模式:强控制 + +正式商用最好以 gateway 模式为主。 + +三、代理本身的属性模型 +7. 代理不能只保存 IP、端口 + +建议至少保存: + +id +scheme +host +port +username +password +sourceUpstream +createdAt +expiresAt +lastCheckedAt +lastSuccessAt +latency +activeConcurrency +reservedConcurrency +status +tags + +可选属性: + +country +region +city +carrier +ASN +residential / datacenter +supportsHTTPS +supportsConnect +supportsUDP + +否则以后做地区筛选、质量调度时需要大改数据结构。 + +8. 去重键要设计好 + +不能只用 IP 去重。 + +下面可能是不同代理: + +1.2.3.4:8000 +1.2.3.4:9000 + +下面也可能因为账号不同而属于不同通道: + +user-a@1.2.3.4:8000 +user-b@1.2.3.4:8000 + +建议唯一键: + +scheme + host + port + username + +密码不要参与哈希日志展示,但内部身份判断可考虑凭证版本。 + +9. TTL 来源要区分 + +代理过期时间可能来自: + +上游明确返回 +配置固定 TTL +根据获取时间估算 +长效代理没有 TTL + +建议优先级: + +上游返回 expiresAt +> 上游返回 ttl +> 配置 lifecycle.ttl +> 不过期 + +还要避免服务器时间误差,最好内部统一使用 UTC。 + +四、健康检查还不够细 +10. 健康检查需要区分全局和目标站点 + +代理可能: + +能访问普通网站 +但访问京东失败 + +所以最好区分: + +globalHealth +routeHealth + +例如: + +代理本身正常 +但对 jd 路由不可用 + +这时不应把它从全局池删除,只需不再分配给 JD。 + +第一版可以先只做全局健康检查,但数据模型最好预留目标级失败记录。 + +11. 健康检查不能造成流量风暴 + +假设有 1 万个代理,每 10 秒检查一次: + +每秒约 1000 次检测 + +可能把自己和目标检测站打爆。 + +建议: + +检测任务分散执行,不要同一时刻集中触发 +添加随机抖动 +限制最大并发 +新代理优先检查 +正常代理降低检查频率 +失败代理短期加快复检 + +例如: + +check: + interval: 30s + jitter: 20% + maxInFlight: 100 +12. 失败淘汰需要分级 + +一次失败就删除太激进。 + +建议: + +第一次失败 → SUSPECT +连续 N 次失败 → UNHEALTHY +复检成功 → HEALTHY +超过一定时间仍失败 → REMOVE + +不一定要暴露复杂状态给用户,但内部最好这样处理。 + +五、请求调度策略 +13. 代理选择不要只做随机 + +后面至少会需要: + +leastConnections +leastLatency +roundRobin +random +stickySession + +推荐默认: + +leastConnections + +如果延迟差异较大,可以用: + +综合评分 = +负载权重 ++ 延迟权重 ++ 最近失败惩罚 + +第一版不用做复杂评分,但接口应该可扩展。 + +14. Sticky Session 要尽早考虑 + +某些网站登录后要求同一出口 IP。 + +例如用户传: + +sessionId = abc + +系统应该尽量把同一个 session 固定到同一个代理。 + +需要明确: + +session 绑定多久 +代理失效后是否自动重绑 +多个路由是否共享 session + +否则后面增加固定会话时,会影响整个分配接口。 + +15. 请求失败是否自动换代理重试 + +这是关键行为。 + +例如业务请求失败后: + +是否自动换另一个代理重试? + +需要避免: + +POST 支付请求 + +被重复发送。 + +建议默认: + +GET、HEAD 可以自动重试 +POST、PUT、PATCH、DELETE 默认不自动重试 +用户可配置是否允许 +每次重试必须换代理或根据错误类型决定 + +配置示例: + +gateway: + retry: + maxAttempts: 2 + retryMethods: + - GET + - HEAD +六、上游 API 调用机制 +16. 多请求合并 + +大量客户端同时缺代理时,应使用 singleflight: + +100 个请求发现池容量不足 +→ 只触发一次补池任务 +→ 其他请求等待结果 + +否则容易瞬间调用上游 API 100 次。 + +17. 需要指数退避 + +请求上游失败时不能固定高速重试: + +1s +2s +4s +8s + +同时增加随机抖动,避免多实例同时重试: + +fetch: + retry: + maxAttempts: 5 + backoff: + initial: 1s + max: 30s + jitter: 20% + +空 IP 是否遵守 requestInterval 也要明确,不能为了尽快达到 5 次而连续轰炸代理商。 + +18. API 模板功能要有限制 + +你们准备通过模板解析上游响应,这很灵活,但风险也大: + +模板死循环 +超大响应 +正则灾难性回溯 +模板访问敏感变量 +CPU 和内存消耗过高 + +建议: + +限制响应体大小 +限制模板执行时间 +限制可用函数 +正则预编译 +不允许任意文件或网络访问 +七、多实例部署 +19. 单机逻辑和多实例逻辑不一样 + +一旦部署多个节点,就会有这些问题: + +两个实例同时补池 +两个实例分别认为自己没超过 maxSize +两个实例同时切换上游 +租约被重复分配 + +因此要明确项目第一版是: + +单实例 + +还是: + +支持集群 + +如果支持集群,需要共享: + +当前活动 upstream +consecutiveEmptyFetch +租约 +客户端配额 +上游 API 限速 +代理池元数据,或明确每个节点维护独立池 + +我建议第一版明确为单实例,集群作为第二阶段,不要半支持。 + +20. 重启后的恢复策略 + +重启后需要决定: + +代理池是否恢复 +租约是否恢复 +当前切换到哪个供应商 +空结果计数是否恢复 +活跃连接如何处理 + +建议至少持久化: + +当前 routing 所选 upstream +最后切换时间 +有效租约 + +短效代理本身通常不值得持久化,重启后重新拉取即可。 + +八、安全方面 +21. 防止成为 SSRF 和内网穿透工具 + +代理入口如果允许任意目标,会有人访问: + +127.0.0.1 +169.254.169.254 +内网数据库 +Kubernetes API +路由器管理页面 + +应支持目标访问策略: + +gateway: + destinationPolicy: + denyPrivateNetworks: true + denyLoopback: true + denyLinkLocal: true + +还需要防止 DNS Rebinding: + +域名解析前检查 +连接时再次检查实际 IP + +这项对公网部署是必须的。 + +22. 访问日志不要泄露敏感信息 + +需要脱敏: + +上游 API 密码 +上游代理密码 +客户端密码 +API Key +Bearer Token +带认证信息的代理 URL +URL 查询参数中的密钥 + +最好统一做 Secret 类型,而不是靠每个日志调用者记得脱敏。 + +23. 管理 API 和业务 API 要分开 + +不要让管理接口和提取 API 共用一套低权限认证。 + +建议: + +Gateway 端口 +Extract API 端口 +Admin API 端口 +Metrics 端口 + +可以逻辑分开,是否物理端口分开视部署需求决定。 + +管理接口必须有更严格权限。 + +九、配置和运维 +24. 配置热更新要定义行为 + +修改配置时: + +删除 upstream 怎么处理现有代理 +修改 maxSize 是否立即缩容 +修改认证是否影响已有连接 +修改 routing 是否立即生效 +修改 API 地址是否重置空计数 + +建议使用: + +校验新配置 +→ 构建新配置快照 +→ 原子替换 +→ 旧任务优雅退出 + +不要边读配置边修改运行对象。 + +25. 配置版本必须存在 + +建议: + +version: 1 + +以后修改字段语义时可以做迁移,避免配置格式失控。 + +26. 优雅停机 + +程序退出时要: + +停止接受新请求 +停止发起新 fetch +等待当前代理请求结束 +保存必要状态 +超时后强制关闭 + +例如: + +shutdown: + gracePeriod: 30s +十、可观测性 +27. 状态接口不能只显示 IP 数量 + +至少要看到: + +每个 routing 当前使用哪个 upstream +每个 upstream 连续空结果次数 +代理总数 +健康代理数 +可用容量 +活跃连接 +预留容量 +API 提取租约数 +上游 fetch 成功/空/失败次数 +最近一次切换原因 +最近一次切换时间 +28. 指标标签不能无限增长 + +Prometheus 指标里不要用这些作为标签: + +proxy IP +sessionId +clientId +完整 URL + +否则高基数会把监控系统拖垮。 + +IP 级信息放日志或管理查询,不要放指标标签。 + +十一、测试必须覆盖的场景 + +建议至少做以下测试: + +连续 4 次空,第 5 次成功,不能切换 +连续 5 次空,只切换一次 +100 个并发请求,只触发有限次数 fetch +并发切换不能从 A 一次跳到 C +maxSize 在并发 fetch 下不能超限 +代理过期前 safety margin 内不能再分配 +网关和 API 提取同时使用时容量不能超卖 +配置热更新过程中请求不中断 +上游 API 超时不能计为空结果 +API 返回重复代理不能导致池重复 +所有上游不可用时按 onUnavailable 执行 + +这些比再加一个新策略更重要。 + +建议的开发优先级 +第一阶段:可稳定运行 +获取成功、空结果、错误的语义 +原子容量预留 +singleflight 补池 +原子切换上游 +maxSize 防超限 +TTL 和过期控制 +基础健康检查 +API/Gateway 限流 +配置校验 +状态接口和日志脱敏 +第二阶段:更好用 +Sticky Session +leastConnections 调度 +route 级健康状态 +配置热更新 +租约管理 +客户端权限和配额 +请求失败分类与安全重试 +第三阶段:商业化和集群 +多实例一致性 +Gateway 临时凭证 +套餐和计费 +使用量审计 +地区、运营商、ASN 筛选 +管理后台 +Secret 管理和凭证轮换 + +当前最值得优化的三个点是: + +补池请求合并、容量原子预留、供应商切换原子化。 + +这三个如果没做好,低流量时看起来一切正常,流量一高就会出现重复提取、容量超卖和供应商连续跳转。功能可以慢慢加,这三块最好第一版就打牢。 + +对,这样就简单很多,而且逻辑是成立的。 + +你的模式本质上不是“租约并发”,而是: + +API 一旦提取成功,该代理立即从可分配池中移除,归提取者独占,系统不再重复分配。 + +因此确实不用关心用户: + +有没有真正使用; +使用了多少并发; +什么时候停止使用; +是否把代理交给别人。 + +系统只需要保证: + +同一个代理只提取一次 +推荐状态流转 +FETCHED + ↓ +CHECKING + ↓ +AVAILABLE + ↓ API 提取 +EXTRACTED + ↓ +等待过期 + ↓ +EXPIRED / REMOVED + +当用户通过 API 提取时: + +AVAILABLE → EXTRACTED + +进入 EXTRACTED 后: + +不再被 API 提取; +不再分配给 Gateway; +不再参与可用代理数量统计; +不需要用户主动释放; +到期后直接删除。 + +这样连租约都可以不做。 + +提取过程必须原子化 + +虽然不用管理租约,但仍然要防止并发重复提取。 + +例如两个请求同时获取一个代理: + +请求 A 查询到 Proxy-1 可用 +请求 B 也查询到 Proxy-1 可用 + +如果只是先查询再删除,就可能同时返回给两个人。 + +正确流程应该是: + +查找 AVAILABLE 代理 + ↓ +原子修改为 EXTRACTED + ↓ +修改成功才返回 + +数据库可以类似: + +UPDATE proxies +SET status = 'EXTRACTED', + extracted_at = NOW() +WHERE id = ? + AND status = 'AVAILABLE'; + +只有影响行数为 1 的请求获得该代理。 + +纯内存实现则用锁或 CAS。 + +批量提取也要么占用成功,要么不返回 + +例如: + +GET /api/v1/proxies?count=10 + +系统应该: + +筛选可用代理 +→ 原子占用 +→ 返回实际占用成功的代理 + +可以定义两种数量语义。 + +尽量返回 + +池里只有 6 个,就返回 6 个: + +{ + "requested": 10, + "returned": 6, + "proxies": [] +} +必须足量 + +池里不足 10 个则不提取: + +{ + "code": "INSUFFICIENT_PROXIES", + "requested": 10, + "available": 6 +} + +建议支持配置: + +distribution: + extraction: + fulfillment: partial + +可选: + +partial +allOrNothing + +默认推荐 partial,用户体验更直接。 + +是否还需要保存提取记录 + +不需要“租约”,但建议保留一条简单的提取记录,用于审计和排错: + +proxyId +clientId(启用认证时) +sourceIP +extractedAt +expiresAt +upstream +requestId + +这不是为了重新释放代理,而是为了回答: + +这个 IP 有没有被提取过; +什么时间被提取; +被哪个客户端提取; +从哪个供应商获取; +为什么池里代理消耗这么快。 + +无认证模式下,可以记录: + +clientId = anonymous +sourceIP = 192.168.1.20 + +不需要维护复杂状态。 + +maxSize 要明确是否包含已提取代理 + +按照你们之前对 maxSize 的定义: + +该上游最多维护或获取多少代理。 + +这里有两种可能。 + +限制当前池内代理数 +AVAILABLE + CHECKING + BUSY + +一旦代理被提取并移出池,就释放一个位置,系统可以继续从上游获取。 + +这种适合持续供应模式。 + +限制累计提取数量 + +例如代理商套餐总共只允许提取 1000 个: + +累计从上游成功获取数量 <= maxSize + +这种情况下,代理被用户提取后也不能释放额度。 + +所以建议不要让一个 maxSize 同时承担两种含义,拆开会更清楚: + +pool: + maxSize: 100 + +fetch: + maxTotal: 1000 + +含义: + +pool.maxSize += 当前系统中最多保留多少个未提取代理 + +fetch.maxTotal += 本次运行或计费周期内最多从供应商提取多少个 + +不过根据你之前的定义,如果 maxSize 就是“允许从该代理商最多获取多少个”,那它更接近: + +fetch: + maxTotal: 1000 + +不应该再叫 pool.maxSize。 + +Gateway 和提取 API 是否共用池 + +如果提取 API 拿走以后永不再分配,那么共用池时要明确优先级。 + +例如池中有 10 个代理: + +Gateway 正在等待代理 +API 一次请求提取 10 个 + +API 可能直接清空整个池。 + +可以有三种方案。 + +完全共用 + +最简单: + +allocation: + gatewayReserve: 0 + +谁先抢到谁用。 + +适合小型内网、自用系统。 + +为 Gateway 预留数量 +allocation: + gatewayReserve: 10 + +当池里只剩 10 个时,提取 API 不再拿走,但 Gateway 还能使用。 + +分池 +pools: + gateway: + upstreams: [provider-a] + + extract: + upstreams: [provider-a, provider-b] + +这是隔离最彻底的方式,但配置和资源管理更复杂。 + +第一版可以直接共用,再加一个简单的预留值: + +distribution: + extraction: + reserveForGateway: 5 + +提取 API 可领取数量: + +max(0, availableProxyCount - reserveForGateway) +提取后的健康和过期问题 + +代理提取出去后,系统不再负责分配,但返回时应告诉用户剩余有效时间: + +{ + "proxy": "http://1.2.3.4:8080", + "expiresAt": "2026-07-28T17:30:00+08:00", + "remainingTtlSeconds": 83 +} + +并设置最低剩余寿命: + +distribution: + extraction: + minRemainingTTL: 30s + +避免把只剩 2 秒的代理提取给用户。 + +提取前可以选择: + +只使用最近健康检查通过的代理 + +例如: + +distribution: + extraction: + maxHealthCheckAge: 15s + +否则代理虽然状态是 AVAILABLE,但可能已经很久没检测过。 + +配置可以简化成这样 +distribution: + enabled: true + listen: 192.168.1.10:8081 + + auth: + mode: none + + extraction: + mode: exclusive + fulfillment: partial + + maxCountPerRequest: 20 + minRemainingTTL: 30s + reserveForGateway: 5 + + limits: + requestsPerMinute: 60 + +其中: + +mode: exclusive + +代表: + +提取成功后立即从聚合池中永久移出,不再分配给任何请求。 + +甚至 mode 都可以省掉。要是你们的产品定义里 API 提取永远都是独占提取,那就直接把它写成固定行为,不用为不存在的共享需求提前增加配置。 + +最终流程 +客户端请求提取 N 个代理 + ↓ +身份认证 / 来源检查 + ↓ +数量和频率限制 + ↓ +筛选: +AVAILABLE +剩余 TTL 足够 +健康检查未过时 + ↓ +原子标记 EXTRACTED + ↓ +返回代理 + ↓ +系统不再管理其使用情况 + ↓ +过期后清理记录 + +所以你的判断没问题:**API 提取的代理只要保证“一次性独占发放”,就不需要租约、释放接口和实际使用量追踪。**保留提取记录和原子占用就够了。