大多数人搭代理池踩的第一个坑,不是代码问题
很多人搭代理池的起点是这样的:从某个渠道拿到一批代理 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 移除,检测通过时用 ZINCRBY 或 ZADD 更新评分。
为什么用 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 延迟和淘汰率的趋势,再决定是否放大规模。