代理IP池搭建与维护:用 Redis + Golang 把"一批 IP"变成"可管理的池"

2 阅读10分钟

大多数人搭代理池踩的第一个坑,不是代码问题

很多人搭代理池的起点是这样的:从某个渠道拿到一批代理 IP,写个脚本存进 Redis List,用 LPOP 取、用完 RPUSH 回去,循环往复。

跑着跑着就会发现:成功率越来越低,延迟越来越飘,偶尔还有一批请求集中失败——但你不知道是哪些 IP 出了问题,因为池子里根本没有"谁是好的、谁已经废了"这层信息。

说白了,这不是代理池,这是代理列表。池和列表的区别在于:池有生命周期管理——IP 进来要验证、跑着要检测、不行要淘汰、好的要优先用。

这篇要搭的就是这样一个池。技术栈选 Redis + Golang,原因很直接:Redis 的 Sorted Set 天然适合做"带评分的 IP 调度",Go 的 goroutine 天然适合做"并发健康检测"。

先想清楚池的四个模块,再写第一行代码

动手之前,先把代理池拆成四个独立模块,每个模块的职责边界要清楚:

模块一:IP 获取层(Fetcher)。 负责从上游拿 IP。上游可能是 API 接口、文件、数据库,甚至手动导入。这层只做一件事:把拿到的 IP 格式化成统一结构,丢给下一层。

模块二:入池验证层(Validator)。 IP 拿到不等于能用。入池之前先用目标站(或通用测试地址)跑一次连通性检测,能通的才写入 Redis,不能通的直接丢弃。这步省掉了"把废 IP 塞进池子、等业务失败了才发现"的成本。

模块三:调度层(Scheduler)。 业务侧请求代理时,从池子里取 IP。但不是随机取——按评分取。评分怎么算,后面单独讲。

模块四:健康检测与淘汰层(Health Checker)。 定期扫描池子里的 IP,更新评分,把评分跌破阈值的 IP 移除。这层是整个池能长期跑下去的关键。

四个模块之间用 Redis 作为状态中枢连接。下面逐个讲实现。

Redis 数据结构设计:一个 Sorted Set 搞定调度,几个 Hash 搞定状态

代理池的核心数据结构推荐这样设计:

一个 Sorted Set 做调度池。 Key 是 proxy:pool:{task_name},Member 是 ip:port,Score 是该 IP 的综合评分(0-100)。业务取 IP 时用 ZREVRANGEBYSCORE 拿评分最高的,检测失败时用 ZREM 移除,检测通过时用 ZINCRBYZADD 更新评分。

为什么用 Sorted Set 而不是 List?因为 List 只能做 FIFO/LIFO,没有"优先级"概念。Sorted Set 的 Score 就是天然的优先级字段,而且 ZRANGEBYSCORE 的时间复杂度是 O(log(N)+M),在万级 IP 规模下完全够用。

一个 Hash 存 IP 元数据。 Key 是 proxy:meta:{ip:port},字段包括:

 last_check_time    // 上次检测时间,Unix 时间戳
 last_check_latency // 上次检测延迟,毫秒
 success_count      // 累计成功次数
 fail_count         // 累计失败次数
 consecutive_fails  // 连续失败次数
 region             // 地域标签(如果上游提供)
 protocol           // HTTP / HTTPS / SOCKS5

一个 Set 做黑名单。 Key 是 proxy:blacklist,存放被淘汰的 IP。入池验证时先查黑名单,避免刚淘汰的 IP 又被加回来。黑名单可以设 TTL(比如 24 小时后自动释放,给 IP 一次"复活"机会)。

这三个数据结构加起来,就是整个池的状态层。接下来看 Go 代码怎么跟它们交互。

Golang 实现:从入池验证到健康检测的核心代码

先定义 IP 的基础结构体:

 type Proxy struct {
     Addr       string  // "ip:port"
     Protocol   string  // "http" / "https" / "socks5"
     Region     string
     Score      float64
     LastCheck  int64
     SuccCount  int64
     FailCount  int64
     ConsFails  int64
 }

入池验证的核心逻辑:拿到一批 IP 后,并发检测,通过的写入 Redis。

 func validateAndAdd(ctx context.Context, proxies []Proxy, rdb *redis.Client, taskName string) {
     var wg sync.WaitGroup
     sem := make(chan struct{}, 50) // 控制并发数,别一次打满
 ​
     for _, p := range proxies {
         wg.Add(1)
         sem <- struct{}{}
         go func(proxy Proxy) {
             defer wg.Done()
             defer func() { <-sem }()
 ​
             latency, err := checkProxy(ctx, proxy, "https://httpbin.org/ip")
             if err != nil {
                 return // 验证不通过,不入池
             }
             proxy.Score = calcInitScore(latency)
             proxy.LastCheck = time.Now().Unix()
             writeToRedis(rdb, taskName, proxy)
         }(p)
     }
     wg.Wait()
 }

这里有几个工程细节值得注意:

第一,并发数要限制。sem 这个 channel 就是信号量,50 是个经验起点——具体要根据你本机的出口带宽和上游的并发限制调。如果你的验证目标是自己的测试服务,可以开高一点;如果是第三方地址,开太高容易被限制。

第二,验证目标用 httpbin.org/ip 这种通用地址只能验连通性。如果你的业务目标站有特殊的访问频率控制机制,连通性通过不代表业务能用。条件允许的话,入池验证最好直接用业务目标站的一个低频页面。

第三,初始评分 calcInitScore 不要给满分。我一般给 60-70 分(满分 100),让新 IP 从中等位置开始,靠后续检测结果涨分或降分。

健康检测是一个定时任务,每隔 N 分钟扫描池子里所有 IP:

 func healthCheck(ctx context.Context, rdb *redis.Client, taskName string) {
     members, _ := rdb.ZRangeWithScores(ctx, "proxy:pool:"+taskName, 0, -1).Result()
 ​
     var wg sync.WaitGroup
     sem := make(chan struct{}, 30)
 ​
     for _, m := range members {
         wg.Add(1)
         sem <- struct{}{}
         go func(addr string, oldScore float64) {
             defer wg.Done()
             defer func() { <-sem }()
 ​
             latency, err := checkProxy(ctx, Proxy{Addr: addr}, "https://httpbin.org/ip")
             if err != nil {
                 newScore := oldScore * 0.7 // 失败一次,评分打七折
                 if newScore < 10 {
                     // 评分太低,移出池子,加入黑名单
                     rdb.ZRem(ctx, "proxy:pool:"+taskName, addr)
                     rdb.SAdd(ctx, "proxy:blacklist", addr)
                     rdb.Expire(ctx, "proxy:blacklist", 24*time.Hour)
                 } else {
                     rdb.ZAdd(ctx, "proxy:pool:"+taskName, &redis.Z{Score: newScore, Member: addr})
                 }
                 incrConsFails(rdb, addr)
                 return
             }
             // 检测通过,评分上调
             bonus := calcBonus(latency)
             newScore := math.Min(oldScore+bonus, 100)
             rdb.ZAdd(ctx, "proxy:pool:"+taskName, &redis.Z{Score: newScore, Member: addr})
             resetConsFails(rdb, addr)
             updateMeta(rdb, addr, latency)
         }(m.Member.(string), m.Score)
     }
     wg.Wait()
 }

评分策略的核心思路是:失败惩罚要比成功奖励更重。一次失败打七折(*0.7),一次成功只加 3-5 分。这样一个 IP 连续失败两三次就会跌破阈值被淘汰,但一个稳定的 IP 需要持续表现好才能维持高分。

这个不对称设计是有原因的:在生产环境里,一个坏 IP 造成的损失(请求失败、重试成本、业务延迟)远大于一个好 IP 多等一轮带来的收益。

调度取 IP:不是随机取,而是带权取

业务侧从池子里取 IP 时,最简单的做法是 ZREVRANGE 取评分最高的。但这有个问题:如果池子里有 1000 个 IP,评分最高的那 10 个会被反复使用,其他的闲置——等检测周期一到,闲置的因为没被检测而评分不变,高分的因为高频使用而更容易触发目标站的访问频率控制。

更合理的做法是带权随机

 func getProxy(ctx context.Context, rdb *redis.Client, taskName string) (string, error) {
     // 取评分前 30% 的 IP 作为候选
     members, err := rdb.ZRevRangeWithScores(ctx, "proxy:pool:"+taskName, 0, -1).Result()
     if err != nil || len(members) == 0 {
         return "", fmt.Errorf("pool empty")
     }
 ​
     topN := int(math.Ceil(float64(len(members)) * 0.3))
     if topN < 1 {
         topN = 1
     }
     candidates := members[:topN]
 ​
     // 按评分做加权随机
     var totalScore float64
     for _, c := range candidates {
         totalScore += c.Score
     }
     r := rand.Float64() * totalScore
     var cumulative float64
     for _, c := range candidates {
         cumulative += c.Score
         if r <= cumulative {
             return c.Member.(string), nil
         }
     }
     return candidates[0].Member.(string), nil
 }

这样做的效果是:评分高的 IP 被选中的概率更大,但不是独占。评分 90 的 IP 被选中概率大约是评分 60 的 1.5 倍,而不是 100% vs 0%。

如果你的业务有多个不同的目标站,建议按目标站拆分池子(不同的 taskName),而不是所有任务共用一个池。原因是:同一个 IP 对目标站 A 成功率 95%,对目标站 B 可能只有 60%——混在一起评分就失去了意义。这就是"业务隔离"在池子层面的体现。

维护阶段最容易忽略的三件事

代理池搭起来只是开始,跑起来之后有三件事容易被忽略:

第一,池子的水位监控。 如果淘汰速度大于补充速度,池子会越来越小,最后业务取不到 IP。你需要一个 goroutine 定期检查池子大小,低于阈值时自动触发 Fetcher 补充。一个简单的做法:

 func monitorPoolSize(ctx context.Context, rdb *redis.Client, taskName string, minSize int64) {
     ticker := time.NewTicker(1 * time.Minute)
     for range ticker.C {
         size, _ := rdb.ZCard(ctx, "proxy:pool:"+taskName).Result()
         if size < minSize {
             log.Printf("pool %s below threshold: %d < %d, triggering fetch",
                 taskName, size, minSize)
             triggerFetch(taskName)
         }
     }
 }

第二,检测频率和业务频率的平衡。 检测太频繁,检测请求本身就消耗大量带宽和上游资源;检测太稀疏,坏 IP 在池子里待太久,拉低成功率。我的经验是:对于日常采集任务,5-10 分钟一轮全量检测是比较平衡的起点。如果池子规模上万,可以改成分批检测——每分钟检测 1/5 的 IP,5 分钟一个完整周期。

第三,成功率统计要区分"代理失败"和"业务失败"。 代理连接超时、代理返回 502/503 是代理层面的失败,应该计入 IP 评分。但目标站返回 403、429 这类状态码,可能是访问频率控制,不一定是代理的问题。如果把业务层面的 403 也算成代理失败,会导致好 IP 被误杀。建议在 checkProxy 里只把连接失败和代理层错误算作失败,业务层状态码单独统计。

怎么验证你搭的池子是不是真的在工作

池子跑起来之后,你需要一组指标来判断它的健康度:

池内可用 IP 数。 ZCARD proxy:pool:{task_name},如果持续下降说明淘汰太快或补充不足。

平均评分。ZRANGEBYSCORE 取全量评分算平均值。如果平均分持续低于 50,说明池子质量在恶化。

请求成功率。 这个要在业务侧统计,不是在池子里。定义清楚:成功率 = 返回有效内容的请求数 / 总请求数。注意"有效内容"的定义——返回 200 但内容是空页面或验证页面,不算成功。

P95 延迟。 平均延迟容易被少数极慢的请求拉高,P95 更能反映真实体验。Go 里可以用 github.com/rcrowley/go-metrics 或自己维护一个滑动窗口。

如果你发现成功率掉到 80% 以下、P95 延迟超过 3 秒,先检查池子里高分 IP 的比例——如果评分 80 以上的 IP 不到 20%,大概率是上游 IP 质量在下降,池子本身的调度逻辑没问题。

FAQ

Q:Redis 挂了怎么办,池子状态全丢了?

Redis 本身支持 RDB 和 AOF 持久化,生产环境建议至少开 AOF。但更重要的是:代理池的状态天然是可重建的——Redis 挂了重启后,触发一次全量 Fetch + 验证就能恢复。所以不用过度依赖 Redis 的持久化保障,把 Fetcher 的触发条件设好(比如检测到池子为空时自动触发)更实际。

Q:池子规模多大合适?

取决于你的并发量和目标站的访问频率控制强度。一个粗略的估算方式:如果你每分钟发 1000 个请求,目标站对同一 IP 的限制是每分钟 10 次,那你至少需要 100 个可用 IP。再加上健康检测的淘汰损耗,建议池子水位保持在理论最低值的 2-3 倍。

Q:Go 的 goroutine 开太多会不会有问题?

goroutine 本身很轻量(初始栈只有几 KB),开几千个没问题。但要注意的是出口带宽和目标站的承受能力。并发检测 1000 个 IP 不是开 1000 个 goroutine 的问题,而是"你的出口带宽能不能同时跑 1000 个 HTTP 请求"的问题。用信号量(上面代码里的 sem)控制并发数,根据实际带宽调整,比盲目开满要稳。


这篇讲的是搭建方法,不涉及具体代理供应商。你拿到的 IP 不管来自哪个渠道——自建、采购、第三方接口——池子的管理逻辑都是通用的。搭完之后,先用小规模(100-200 个 IP)跑一周,观察成功率、P95 延迟和淘汰率的趋势,再决定是否放大规模。