圆海博客-探寻心灵的宁静

您现在的位置是:首页 > 博客 > 正文

博客

Java 爬虫代理搭建方案:IP去重、质量评分与自动淘汰机制

2026-09-12 15:42:27博客
为什么 Java 爬虫的代理池总”翻车”?
做过 Java 爬虫的同学大概率遇到过这样的场景:凌晨三点,监控告警响了——代理池里 30% 的 IP 连续超时,爬虫任务大面积卡死,目标站点的反……

为什么 Java 爬虫的代理池总”翻车”?

做过 Java 爬虫的同学大概率遇到过这样的场景:凌晨三点,监控告警响了——代理池里 30% 的 IP 连续超时,爬虫任务大面积卡死,目标站点的反爬策略又升级了。你打开代理池一看,同一批 IP 被反复塞进去,质量参差不齐,有的能用有的秒挂,整个池子就像一锅粥。

代理 IP 不是”买回来往池子里一扔”就完事的。一个真正能扛住生产环境的 Java 爬虫代理系统,至少需要解决三个核心问题:

第一,IP 去重——批量导入的代理列表里重复率可能高达 20%-40%,不去重就会浪费配额、拉低整体命中率。
第二,质量评分——不是所有”能连通”的 IP 都值得用,延迟、成功率、稳定性需要量化打分。
第三,自动淘汰——坏 IP 必须被快速踢出,否则一个慢节点就能拖垮整条请求链路。

这篇文章会从零开始,用 Java 代码把这三件事讲透,并给出可直接落地的架构方案。

一、IP 去重:别让”垃圾 IP”占着茅坑

代理 IP 的来源通常有几种:批量采购的代理列表、动态住宅 IP 接口、自建代理节点。无论哪种来源,重复 IP 都是常态。不去重的后果很直接:你以为池子里有 5000 个 IP,实际去重后可能只剩 3200 个,调度时还会把同一个 IP 分配给多个并发任务,触发目标站点的频率限制。

1.1 基础去重:HashSet + 归一化

最朴素的做法是把 IP 地址做归一化后扔进 HashSet。注意,”归一化”不只是去空格,还要处理端口、协议前缀等差异:

public class ProxyDeduplicator {

    private final Set seen = ConcurrentHashMap.newKeySet();

    /**
     * 归一化代理地址:统一小写、去协议头、补默认端口
     */
    public String normalize(String raw) {
        String s = raw.trim().toLowerCase();
        // 去掉 http:// https:// 前缀
        s = s.replaceFirst("^https?://", "");
        // 补默认端口
        if (!s.contains(":")) {
            s += ":8080";
        }
        return s;
    }

    /**
     * 判断是否为新增 IP
     */
    public boolean addIfNew(String raw) {
        String key = normalize(raw);
        return seen.add(key);
    }

    public int size() {
        return seen.size();
    }
}

这个方案在 IP 数量小于 10 万时完全够用,内存占用也很小。但问题在于:它只解决了”当前批次”的去重,跨批次、跨时间窗口的重复它管不了。比如你今天导入一批 IP,明天又导入一批,两批之间可能有大量重叠。

1.2 进阶去重:布隆过滤器 + 时间窗口

当 IP 池规模到了 百万级甚至千万级,HashSet 的内存开销就不友好了。这时候上 布隆过滤器(Bloom Filter)是标准操作。它用极少的内存(千万级 IP 大约 10-20MB)就能做到毫秒级的”是否存在”判定,代价是存在极低的误判率(通常设为 0.1%)。

import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;

public class ProxyBloomFilter {

    private final BloomFilter filter;
    private final long windowMillis; // 时间窗口

    public ProxyBloomFilter(long expectedInsertions, double fpp, long windowMillis) {
        this.filter = BloomFilter.create(Funnels.stringFunnel(java.nio.charset.StandardCharsets.UTF_8),
                expectedInsertions, fpp);
        this.windowMillis = windowMillis;
    }

    public boolean mightContain(String normalizedIp) {
        return filter.mightContain(normalizedIp);
    }

    public void put(String normalizedIp) {
        filter.put(normalizedIp);
    }
}

实际生产中,我推荐组合策略

策略 适用规模 内存占用 去重精度 推荐场景
HashSet 归一化 < 10 万 ~50MB 100% 小型爬虫、单节点
布隆过滤器 100 万 – 1000 万 ~20-200MB 99.9% 中型代理池
Redis SET + TTL 百万级 共享内存 100% 分布式多节点
布隆过滤器 + Redis 兜底 千万级 ~200MB + Redis ≈100% 大型生产环境

如果是分布式部署(多个 Java 进程共享同一个代理池),Redis 的 SADD + EXPIRE 是最省心的方案,天然支持多节点去重和 TTL 过期。

二、质量评分:给每个 IP 打一个”体检报告”

去重只解决了”别用重复的”,但没解决”这个 IP 到底好不好用”。一个代理 IP 的质量,至少要从三个维度来衡量:

2.1 评分维度设计

① 响应速度(权重 40%)
记录每次请求的响应时间,取最近 N 次(比如 20 次)的 P95 值。P95 比平均值更能反映”最差体验”。阈值建议:P95 < 500ms 满分,> 3000ms 零分,中间线性插值。

② 成功率(权重 35%)
最近 N 次请求中,成功返回(HTTP 200/301/302)的比例。连续失败 3 次以上直接触发降级。注意:目标站点返回 403、429 不算代理的”成功”,要区分”代理层失败”和”业务层拒绝”。

③ 稳定性(权重 25%)
响应时间的标准差 / 均值(变异系数 CV)。CV 越小越稳定。一个平均 800ms 但波动在 ±50ms 的 IP,远比平均 600ms 但波动在 ±2000ms 的 IP 更适合生产环境。

2.2 评分代码实现

public class ProxyScore {

    private final String proxyAddr;
    private final Deque latencies = new ArrayDeque<>();  // 最近N次延迟
    private final Deque successes = new ArrayDeque<>(); // 最近N次是否成功
    private static final int WINDOW = 20;

    public ProxyScore(String proxyAddr) {
        this.proxyAddr = proxyAddr;
    }

    public void record(long latencyMs, boolean success) {
        latencies.addLast(latencyMs);
        successes.addLast(success);
        if (latencies.size() > WINDOW) latencies.pollFirst();
        if (successes.size() > WINDOW) successes.pollFirst();
    }

    /**
     * 综合评分 0-100
     */
    public int score() {
        if (latencies.size() < 3)="" return="" 50;="" 样本不足,给中位分="" 1.="" 速度分="" (0-100)="" long="" p95="percentile(latencies," 95);="" int="" speedscore="clamp(100" -="" (int)((p95="" -="" 500)="" *="" 100="" 2500),="" 0,="" 100);="" 2.="" 成功率分="" (0-100)="" long="" successcount="successes.stream().filter(Boolean::booleanValue).count();" int="" successscore="(int)(successCount" *="" 100.0="" successes.size());="" 3.="" 稳定性分="" (0-100)="" double="" mean="latencies.stream().mapToLong(Long::longValue).average().orElse(0);" double="" variance="latencies.stream()" .maptolong(l="" -=""> (l - mean) * (l - mean))
                .average().orElse(0);
        double cv = mean > 0 ? Math.sqrt(variance) / mean : 1.0;
        int stabilityScore = clamp(100 - (int)(cv * 100), 0, 100);

        // 加权
        return (int)(speedScore * 0.4 + successScore * 0.35 + stabilityScore * 0.25);
    }

    private long percentile(Deque data, int p) {
        List sorted = new ArrayList<>(data);
        sorted.sort(Long::compareTo);
        int idx = (int)(p / 100.0 * (sorted.size() - 1));
        return sorted.get(Math.min(idx, sorted.size() - 1));
    }

    private int clamp(int v, int min, int max) {
        return Math.max(min, Math.min(max, v));
    }
}

2.3 评分的更新频率

不建议每次请求都重新算全量评分,太耗 CPU。推荐做法:

  • 实时记录:每次请求完成后,把延迟和成功/失败追加到滑动窗口(O(1))。
  • 定时重算:每 30 秒或每 100 次请求触发一次评分重算,结果写入 Redis 或本地缓存。
  • 冷启动保护:新 IP 前 3 次请求不参与淘汰判定,只记录不评分,避免”一次抖动就出局”。

三、自动淘汰机制:坏 IP 活不过一个窗口

评分是”体检”,淘汰是”执行”。一个设计良好的淘汰机制,能让代理池的整体可用率始终维持在 90% 以上,而不需要你手动去清理。

3.1 淘汰规则设计

我推荐的淘汰策略是多级触发,而不是单一阈值一刀切:

public class ProxyEliminator {

    // 阈值配置(可按业务调整)
    private static final int HARD_FAIL_THRESHOLD = 3;   // 连续失败3次 → 立即淘汰
    private static final int SOFT_SCORE_THRESHOLD = 30; // 综合评分低于30 → 标记观察
    private static final int OBSERVE_FAIL_LIMIT = 5;    // 观察期内再失败5次 → 淘汰
    private static final long IDLE_EVICT_MS = 30 * 60 * 1000; // 30分钟无请求 → 回收

    /**
     * 每次请求完成后调用
     * @return true 表示该 IP 应被淘汰
     */
    public boolean shouldEliminate(ProxyScore score, int consecutiveFails, long lastUsedMs) {
        long now = System.currentTimeMillis();

        // 规则1:连续失败 → 立即淘汰(最严格)
        if (consecutiveFails >= HARD_FAIL_THRESHOLD) {
            return true;
        }

        // 规则2:长期空闲 → 回收(释放资源)
        if (now - lastUsedMs > IDLE_EVICT_MS) {
            return true;
        }

        // 规则3:低分 + 观察期再失败 → 淘汰
        if (score.score() < soft_score_threshold)="" {="" 这里可以结合一个"观察期计数器"="" 简化版:低分且最近5次有3次以上失败="" long="" recentfails="score.countRecentFails(5);" if="" (recentfails="">= 3) {
                return true;
            }
        }

        return false;
    }
}

3.2 淘汰后的处理

淘汰不等于”永久拉黑”。推荐的做法:

  • 软淘汰(降权):评分低于阈值但不触发硬淘汰的 IP,不删除,而是降低调度优先级。调度器优先选高分 IP,低分 IP 作为”备胎”。
  • 硬淘汰(移除):连续失败或空闲超时的 IP,从活跃池中移除,放入”冷却队列”。
  • 冷却复活:冷却队列中的 IP 每隔一定时间(比如 10 分钟)被重新探测一次。如果探测成功,重新入池;如果仍然失败,冷却时间翻倍(指数退避),最多重试 3 轮后彻底丢弃。
// 调度器核心逻辑(简化版)
public String pickProxy() {
    // 1. 从活跃池按评分降序取
    List candidates = activePool.stream()
            .sorted(Comparator.comparingInt(ProxyScore::score).reversed())
            .limit(5) // 取Top5候选
            .collect(Collectors.toList());

    // 2. 轮询 + 随机,避免热点
    if (candidates.isEmpty()) return null;
    int idx = ThreadLocalRandom.current().nextInt(candidates.size());
    return candidates.get(idx).getProxyAddr();
}

3.3 架构总览

把上面三个模块串起来,一个完整的 Java 爬虫代理调度系统大致是这样的分层:

┌─────────────────────────────────────────────────┐
│                  爬虫业务层                       │
│         (HttpClient / OkHttp / Jsoup)           │
├─────────────────────────────────────────────────┤
│              代理调度器 (ProxyScheduler)          │
│  ┌───────────┐  ┌───────────┐  ┌────────────┐  │
│  │ 评分引擎   │  │ 淘汰引擎   │  │ 调度策略    │  │
│  │ ProxyScore│  │ProxyElim. │  │ 轮询/加权   │  │
│  └───────────┘  └───────────┘  └────────────┘  │
├─────────────────────────────────────────────────┤
│              代理池 (ProxyPool)                   │
│  ┌──────────────────────────────────────────┐   │
│  │  活跃池  │  观察池  │  冷却队列  │  黑名单  │   │
│  └──────────────────────────────────────────┘   │
├─────────────────────────────────────────────────┤
│         去重层 (BloomFilter / Redis SET)         │
├─────────────────────────────────────────────────┤
│         代理源 (API / 文件 / 自建节点)           │
└─────────────────────────────────────────────────┘

这套架构的关键在于各层解耦:去重层只管”新不新”,评分引擎只管”好不好”,淘汰引擎只管”留不留”,调度策略只管”选哪个”。每一层都可以独立替换和测试。

四、代理源选择:好方案离不开好 IP

再精巧的调度算法,也架不住源头的 IP 质量差。如果你用的是那种”9.9 元 1000 个”的廉价代理,上面所有去重、评分、淘汰逻辑都在做无用功——因为 IP 本身就不稳定,评分永远在波动,淘汰队列永远在满。

选择代理源时,重点关注以下几点:

  • IP 池规模与更新频率:池子越大、更新越频繁,你越不容易”撞车”。静态 IP 池如果几个月不更新,重复率和失效率都会飙升。
  • 延迟与稳定性:尤其是做海外站点爬虫时,代理节点与目标站点之间的物理距离直接决定延迟。选代理源时最好确认节点分布,优先选离目标站点近的线路。
  • 并发支持:你的 Java 爬虫如果是高并发(比如 200 线程同时跑),代理源必须支持足够的并发连接数,否则再好的调度策略也会被瓶颈卡住。
  • API 可用性:动态代理最好提供 API 接口,方便你的 Java 程序自动拉取、自动刷新,而不是手动下载 CSV 再导入。

在实际项目中,我们团队使用光络云的代理 IP 服务作为主数据源。它提供国内和海外多个区域的代理节点,IP 池规模大、更新频率高,配合我们前面讲的评分和淘汰机制,代理池的整体可用率长期维持在 95% 以上。光络云的 API 接口设计也比较贴合 Java 开发者的习惯,拉取、鉴权、轮换都比较顺畅,接入成本不高。如果你的爬虫需要访问海外站点,光络云的海外节点延迟表现也不错,值得纳入你的代理源候选。

五、几个容易踩的坑

坑 1:把”连通”当成”可用”。很多代理检测工具只测 TCP 连接是否通,但实际爬取时,TCP 通了不代表能拿到正确响应。建议检测时发一个真实的 HTTP GET 请求(带目标站点的 User-Agent),看返回码和响应体是否合理。

坑 2:评分窗口太短。如果窗口只设 3 次请求,一次网络抖动就能把一个好 IP 打到低分。建议窗口至少 10-20 次,或者用指数加权移动平均(EWMA)来平滑。

坑 3:淘汰后不补。淘汰机制跑得越”积极”,代理池缩水越快。必须有一个自动补充机制:当活跃池数量低于阈值(比如低于总量的 70%)时,自动从代理源拉取新 IP 补充进来。这个补充动作本身也要经过去重层。

坑 4:忽略 IP 与目标站点的”亲和性”。有些目标站点对特定 IP 段有黑名单。你的评分系统应该能识别”这个 IP 对站点 A 一直返回 403,但对站点 B 正常”的情况,做站点级别的隔离,而不是全局拉黑。

常见问题

Q: 我的 Java 爬虫并发只有 20 个线程,需要这么复杂的代理调度吗?
并发低的话,可以简化:去重用 HashSet 就够,评分只保留成功率一个维度,淘汰用”连续失败 3 次移除”一条规则。但去重和基础检测不能省,哪怕 20 个线程,重复 IP 导致的 429 限流也会让你很头疼。

Q: 评分权重 40/35/25 是怎么定的?
这是经验值,不是圣经。具体权重取决于你的业务:如果你做实时性要求高的场景(比如监控价格变动),速度权重可以提到 50%;如果你做批量采集、不在乎快慢只在乎成功率,成功率权重可以提到 50%。建议上线后根据实际数据调参。

Q: 代理 IP 需要海外网络环境才能用吗?
光络云的代理 IP 服务中,除 TikTok 专线外,需要客户自身具备海外网络环境才能使用。如果你的部署环境在国内且需要访问海外站点,建议提前确认网络出口条件。TikTok 专线则不受此限制,可以直接从国内节点使用。

Q: 布隆过滤器的误判怎么处理?
0.1% 的误判率意味着每 1000 个新 IP 可能有 1 个被误判为”已存在”而跳过。在实际业务中这个影响微乎其微。如果你确实介意,可以在布隆过滤器判定”已存在”后,再查一次 Redis 做二次确认,代价是多一次 Redis 查询,但只在误判时才会触发。

Q: 冷却队列的指数退避怎么设?
建议初始冷却 10 分钟,第一次探测失败后翻倍到 20 分钟,第二次 40 分钟,第三次 80 分钟。三轮都失败后彻底丢弃。如果某个 IP 在丢弃后又被代理源重新下发,重新走完整的入池流程(去重 → 冷启动 → 评分 → 正式入池)。