☰
Java爬虫工程化实战:从HttpClient到反爬与调度系统设计
2026/10/11 16:42:50 网站建设 项目流程

做数据采集项目时,很多人第一反应是用 Python 写爬虫,但我们在实际落地一个多城市公共交通数据采集系统时,最终选型却定为 Java。Java 爬虫在并发控制、工程化整合和长期稳定性上确实有它不可替代的位置。这篇东西不是教程式的废话堆砌,我尽量把选型逻辑、核心实现、反爬应对和真正会踩的坑讲透,适合正在纠结语言选型、或者已经用 Java 写爬虫但还停留在单线程阶段的团队参考。

1. 为什么用 Java 写爬虫:先想清楚适不适合

1.1 与 Python 爬虫的正面对比

必须要承认,Python 在爬虫领域有先发优势,Scrapy 框架、requests 语法糖、BeautifulSoup 的易用性,让大部分小中型任务在三五分钟内就能跑起来。但如果我们把衡量维度从“能不能跑”换成“能不能稳定地高压跑、持续地融入现有系统”,情况就完全不一样了。

对比维度Java 爬虫Python 爬虫
启动速度较慢,需编译与 JVM 启动快,脚本即写即跑
多线程并发成熟线程池 + 内存模型完善GIL 限制多线程吞吐
类型安全静态类型,字段错误编译期暴露动态类型,运行期才暴露
生态整合天然融入 Spring 全家桶、大数据组件需额外桥接
部署运维打包成 Jar,进程管理便利,可用 JVM 调优多依赖 Python 环境和虚拟环境
性能天花板配合 NIO 与连接池可压出极高吞吐受解释器性能限制

很多人忽略一个关键点:爬虫的最大成本不是请求,而是后续的数据清洗、去重、落库、监控报警和增量调度。这些工程化需求恰恰是 Java 体系最擅长的。同理,如果你要对接的消息队列是 Kafka,要存储的是 MySQL 加 ES,分布式调度用的是 XXL-Job,那爬虫用 Java 写有一个最直接的收益——全链路语言统一,排查问题不用跨语言调 API。

1.2 Java 爬虫最适合的三类场景

第一类是高并发采集。比如要采集一万个页面的商品信息,还要求每个请求都必须带上独立的会话参数。Python 写多线程受 GIL 限制,真要压吞吐还得上多进程,调度复杂;Java 用线程池加连接池,几千个并发挂在同一个 JVM 里,内存和线程管理都在可控范围内。

第二类是企业级采集管道。爬虫只是数据管道的最前端,后面连着数据质量校验、业务规则引擎、报表系统。用 Java 写爬虫可以直接把解析后的对象交给下游接口,让整个数据流都是强类型引用,减少中间层的结构对接成本。

第三类是需要长期稳定运行的采集任务。这里说的稳定不是不报错,而是报错之后有完善的恢复能力,比如线程池拒绝策略、失败任务重试、断点续爬等。JVM 提供了非常丰富的监控手段(JMX、Arthas、GC 日志),这些在排查一个跑了三个月突然不产数据的爬虫时能救命。

1.3 什么时候别硬上 Java

如果你的目标只是抓几十页数据跑一次分析,或者你是一个人在做个人兴趣项目,那我劝你老老实实回去用 Python。Java 爬虫的初期搭建成本确实高一些,你需要处理 Maven 依赖、连接池配置、实体类定义,这套流程对简单任务来说太重了。技术选型不应该有偏见,只应该讲性价比。

2. 环境搭建与依赖选型:HttpClient 和 Jsoup 怎么配合

2.1 最基础的依赖清单

以 Maven 项目为例,我常用的核心依赖配置如下:

<dependencies> <!-- HTTP 客户端 --> <dependency> <groupId>org.apache.httpcomponents</groupId> <artifactId>httpclient</artifactId> <version>4.5.14</version> </dependency> <!-- HTML 解析 --> <dependency> <groupId>org.jsoup</groupId> <artifactId>jsoup</artifactId> <version>1.16.2</version> </dependency> <!-- JSON 处理 --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.15.2</version> </dependency> <!-- 日志 --> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-api</artifactId> <version>2.0.9</version> </dependency> <dependency> <groupId>ch.qos.logback</groupId> <artifactId>logback-classic</artifactId> <version>1.4.14</version> </dependency> </dependencies>

一个容易忽略的点是:HttpClient 4.x 和 5.x 的 API 风格差别很大,网上很多老文章用的是 4.x,但新项目我推荐直接用 5.x。当然你如果团队里已有大量 4.x 封装代码,也不必强行迁,重点是团队维护成本。我下面示例统一用 4.5.x 版本,因为大部分生产环境存量代码还是这套体系。

2.2 HttpClient 的核心配置项

很多人只是 new 一个 CloseableHttpClient 就发请求,这样在爬虫场景下很快会出问题。HTTP 连接是需要复用的,每次 new 连接的开销非常大。正确的姿势是把连接池配置好:

PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager(); // 路由最大连接数,这里按目标站点收敛 connectionManager.setMaxTotal(200); connectionManager.setDefaultMaxPerRoute(50); RequestConfig config = RequestConfig.custom() .setConnectTimeout(5000) .setSocketTimeout(10000) .setConnectionRequestTimeout(5000) .setRedirectsEnabled(true) .build(); CloseableHttpClient httpClient = HttpClients.custom() .setConnectionManager(connectionManager) .setDefaultRequestConfig(config) .setRetryHandler(new DefaultHttpRequestRetryHandler(3, true)) .build();

超时时间这个参数,我建议根据目标站点的响应速度来调。像一些老系统站点响应经常超过五秒,ConnectTimeout 给 3000 就太低;反过来一些接口极快的站点,SocketTimeout 给 10000 又会让失败请求占用线程太久。比较稳妥的做法是设置成可配置项,上线后根据日志里出现的超时异常比例动态调整。

2.3 Jsoup 的定位

Jsoup 是整个链路里的解析担当。它之于 Java 就像 BeautifulSoup 之于 Python,但有一个差别需要注意:Jsoup 的解析器对真实世界不规范的 HTML 有很强的容错处理,能自动修正缺失的标签闭合,div嵌套错乱时不会直接崩溃。这一点在抓取一些老站时特别重要,因为它们的 HTML 根本经不起严格的 XML 解析器检查。

Jsoup 的选择器语法模仿了 jQuery,上手成本几乎为零。举个例子,要提取一个商品列表页里所有商品的标题和价格:

Connection conn = Jsoup.connect(url) .userAgent("Mozilla/5.0 ...") .timeout(10000); Document doc = conn.get(); Elements items = doc.select("div.item-list > div.item"); for (Element item : items) { String title = item.selectFirst("h3.title").text(); String price = item.selectFirst("span.price").text(); System.out.println(title + " -> " + price); }

slect方法返回所有匹配元素,selectFirst抓第一个,text()自动去掉内部标签只留文字。这里有个细节:如果你用了html()拿到的是带标签的原始内容,通常还得自己清洗;而text()在绝大多数文本采集场景下更省心。

3. 一个完整的基础爬虫:从 URL 到结构化数据的四个步骤

3.1 构造请求:别小看 Headers

很多人写爬虫只设置了 UA,这是最低配。很多站点会校验请求的完整度,比如 Accept 头、Accept-Language 头、Referer 头。用 HttpClient 时建议把公共头统一塞进 HttpGet 里:

HttpGet get = new HttpGet("https://example-site.com/public/data?page=1"); get.setHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); get.setHeader("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"); get.setHeader("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8"); get.setHeader("Referer", "https://example-site.com/"); get.setHeader("Connection", "keep-alive");

Referer 看起来不起眼,但在防热链站点或需要请求来源合法的系统里,缺了它直接返回 403。建议首次采集前先手动用浏览器打开目标页面,按 F12 看真实的请求头长什么样,然后原样复制到代码里。不要凭经验猜测,因为每个站点的校验维度差别很大。

3.2 发起请求与状态码管理

状态码的处理应该是显式的,而不是拿到什么就解析什么。我在代码里习惯先判断响应码,再做分支处理:

CloseableHttpResponse response = httpClient.execute(get); try { int statusCode = response.getStatusLine().getStatusCode(); if (statusCode == 200) { String content = EntityUtils.toString(response.getEntity(), Charset.forName("UTF-8")); // 交给解析层处理 } else if (statusCode == 403) { // 反爬拦截,需要换身份或降速 logger.warn("请求被拦截: {},响应码 403", url); } else if (statusCode == 429) { // 访问过于频繁,触发限流 logger.warn("触发限流: {},等待后重试", url); Thread.sleep(5000); } else if (statusCode >= 500) { // 服务端错误,记录失败任务等待重试 logger.error("服务端错误: {},响应码 {}", url, statusCode); } else { logger.warn("未处理的状态码: {},URL: {}", statusCode, url); } } finally { response.close(); }

注意EntityUtils.toString()之后实体流会被关闭,如果要复用响应体,必须先把字符串拿出来存着。这是我早期写过的最容易踩的坑——把 response 传给下一个方法再取 entity,结果抛了 ConnectionClosedException。

3.3 解析与字段抽取

解析层的核心任务是抽字段、补默认值、处理缺项。我曾经维护过一个爬虫项目,目标站点的某个字段时有时无,XPath 在某条记录上取不到值,直接导致 NPE 和数据中断。后来我的所有解析代码统一采用**“先判空、再取值、再兜底”**的三段式写法:

public static ProductInfo parseProduct(Element item) { ProductInfo p = new ProductInfo(); Element titleEl = item.selectFirst("h3.title"); if (titleEl != null) { p.setTitle(titleEl.text().trim()); } else { p.setTitle(""); // 兜底,避免 NPE logger.warn("商品标题缺失,已设置空字符串"); } Element priceEl = item.selectFirst("span.price"); if (priceEl != null) { String priceText = priceEl.text().replaceAll("[^0-9.]", "").trim(); p.setPriceText(priceText); } return p; }

这个阶段还有两个高频操作:一是清洗 HTML 实体字符,像&amp;、&nbsp;要转回来;二是日期字段统一格式,把2024-08-01 12:30:00和2024-08-01T12:30:00都规整成标准时间字符串。清洗逻辑建议集中放一个工具类里,不要散落在各个解析代码中,否则后期维护会非常痛苦。

3.4 清洗与落库

数据库表结构设计极度影响爬虫项目的稳定性。我建议至少有三张表:

  • crawl_task:爬取任务表,记录每个批次要抓哪些页面、状态是什么(待执行/执行中/成功/失败);
  • crawl_data:原始数据表,存解析结果和原始 HTML,原始 HTML 用来回溯解析问题;
  • crawl_log:采集日志表,记录每个任务的耗时、异常信息。

数据落库时两个坑:

第一,主键冲突。同一页面被重试两次,解析出相同唯一标识的数据,不能用业务主键做唯一索引,一定要给每条记录分配自增主键,业务唯一键单独加判断逻辑。

第二,批量插入要控制批次大小。我用 JDBC 批量插入时,一批评 500 条左右性能最优,太多会影响事务回滚的成本,太少又浪费连接往返。如果是用 MyBatis Plus 的saveBatch,也可以设置自定义批量大小,默认值通常偏小。

4. 高频反爬应对:从 Cookie 到限速的常规操作

4.1 先说合规边界

写爬虫之前必须先确认目标站点的 robots 协议和用户条款,只采集允许公开访问的数据,不采集涉及个人隐私、账号内数据、版权保护数据,不利用漏洞或绕过访问控制获取非公开信息。控制抓取频率,不给目标站点造成压力,这也是爬虫能长期存在的生存底线。

以下应对手段仅限于处理合理频率下的通用访问控制,不适用于任何破解验证码、绕过登录鉴权等涉嫌违规的做法。

4.2 请求头与 Cookie 维度

最简单也最容易被忽略的反爬手段是完整模拟浏览器请求头。很多站点的反爬系统首先看的就是请求头里的指纹信息是否一致,你的 UA 是 Chrome 的,但 Sec-Fetch-Mode 缺失了,这本身就暴露了“你是个爬虫”。

Cookie 的处理要区分两种场景:

  • 会话会话型 Cookie:首次请求时服务器下发,后续请求带上即可保持会话。用 HttpClient 时可以直接设置一个 CookieStore:
BasicCookieStore cookieStore = new BasicCookieStore(); HttpClientContext context = HttpClientContext.create(); context.setCookieStore(cookieStore);
  • 签名型 Cookie:目标站点每次请求前会在 JS 里生成一个签名参数,这种模拟起来很重。如果只是采集公开数据,可以先用浏览器手动访问一次拿到有效 Cookie,然后在代码里配置合理的过期替换机制。当然,这要求你的采集周期远小于 Cookie 的过期时间,否则就得定期手工更新,工程上不算优雅,但胜在简单可靠。

4.3 限速与退避

高频率请求是触发反爬的第一大杀手。很多爬虫挂了不是你代码写得差,而是你用线程池一次性发出去 200 个请求,目标站点的限流系统直接把你整段 IP 给封了。我的做法是:采集请求之间设置随机间隔,不设置固定值。

private void randomSleep(int baseMs, int rangeMs) { int delay = baseMs + ThreadLocalRandom.current().nextInt(rangeMs); try { Thread.sleep(delay); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }

比如基础间隔 800ms,随机范围 400ms,每天跑下来的平均请求频率稳定,但又不会出现像节拍器一样精准的间隔特征。这个特征值非常关键——固定间隔会被识别为程序行为,随机化能在一定程度上规避这种统计识别。

失败重试时用指数退避很有必要。第一次重试等 2 秒,第二次等 4 秒,第三次等 8 秒,偶尔来一次 30 秒的较长等待,效果比固定时间重试好得多。

4.4 代理池的落地思路

假如目标站点对一个 IP 的并发和频率都做了严格控制,你就需要代理 IP 池。我见过的方案通常有三个层次:

  1. 自建少量代理(比如家里带宽拨号改变公网 IP),配合限速使用;
  2. 购买私密代理服务,通过 API 拉取可用 IP;
  3. 维护一个代理池模块,自动测试可用性、剔除失效 IP。

代理池模块核心代码并不复杂,就是定时验证代理是否可用并打分:

public class ProxyValidator { private static final String TEST_URL = "http://example-site.com/health"; public boolean validate(String ip, int port) { HttpHost proxy = new HttpHost(ip, port); RequestConfig config = RequestConfig.custom() .setProxy(proxy) .setConnectTimeout(3000) .setSocketTimeout(5000) .build(); try (CloseableHttpClient client = HttpClients.custom() .setDefaultRequestConfig(config).build()) { HttpGet get = new HttpGet(TEST_URL); try (CloseableHttpResponse response = client.execute(get)) { return response.getStatusLine().getStatusCode() == 200; } } catch (IOException e) { return false; } } }

必须明确一点:使用代理不能突破站点的合法访问控制,代理只是分散 IP 压力。如果你本来就没资格访问某个资源,换再多的 IP 也改变不了这一点,而且可能触发更严厉的法律风险。这个边界需要每个做爬虫的同学清醒地守住。

5. 爬虫工程化:并发、去重、任务调度与失败重试

5.1 用线程池控制并发采集

爬虫工程化的第一步是抛弃“用一个 for 循环发请求”这种写法。并发控制必须显式地交给线程池,而且要设置合理的拒绝策略。我常用下面这套配置:

ThreadPoolExecutor executor = new ThreadPoolExecutor( 8, // 核心线程数 16, // 最大线程数 60L, TimeUnit.SECONDS, // 空闲回收时间 new LinkedBlockingQueue<>(2000), // 队列容量 new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略:调用线程自己执行 );

使用CallerRunsPolicy的用意是,队列满了以后不丢弃任务,也不抛异常,而是让提交任务的线程去执行。这个策略在爬虫场景下很实用,因为它天然实现了背压——下游处理不过来了,上游就会变慢,而不是任务大量堆积在主内存里。

但要注意,核心线程数不是越大越好。并发太高而目标站点响应慢时,就会有一大堆线程阻塞在等待响应的 InputStream 上,白白耗尽内存。我通常以“单个请求的平均耗时 × 目标 QPS”来估算线程数。比如单个请求耗时 0.5 秒,要跑 10 QPS,至少需要 5 个并发线程,再留一倍余量就是 10~12 个。

5.2 用布隆过滤器做 URL 去重

爬虫跑久了会面临一个非常实际的问题:同一个 URL 会以不同形式反复出现在列表页里,比如带尾随的参数、换不同的排序方式,不去重的话数据会大量重复,不仅浪费存储,还会让后续分析失真。

用HashSet存 URL 在小数量级时是可行的,但爬到几百万条时内存就会告急。布隆过滤器的核心思路是:用多个哈希函数和一个位数组判一个元素“可能存在还是绝对不存在”,它允许一定的误判率,换来极低的内存占用。

public class UrlBloomFilter { private BitSet bits = new BitSet(1 << 24); // 约1600万位 private int[] seeds = {7, 11, 13, 31, 37, 61}; private int hash(String str, int seed) { int result = 0; for (char c : str.toCharArray()) { result = result * seed + c; } return Integer.MAX_VALUE & result; } public void add(String url) { for (int seed : seeds) bits.set(hash(url, seed) % bits.size()); } public boolean contains(String url) { for (int seed : seeds) { if (!bits.get(hash(url, seed) % bits.size())) return false; } return true; // 这里返回 true 只能说“很可能已存在” } }

布隆过滤器的“误判为已存在”会导致极小部分 URL 被跳过不抓。在大多数场景下这是可接受的,因为被布隆误判拒绝的 URL 大概率是重复内容。用 Guava 的BloomFilter也可以,它允许你指定误判率,内部实现更优化,生产环境我推荐直接用 Guava 而不是自己写位运算。

5.3 重试策略与死信队列

任何爬虫都会遇到失败,不设计重试方案就是给自己埋雷。我建议区分三类失败:

  • 瞬时失败:连接超时、Socket 异常。适合快速重试,一般 2~3 次即可。
  • 持久失败:404、页面结构变化导致解析异常。重试多少次都不会成功,不要浪费资源。
  • 限流失败:429、503。适合退避重试,等待时间逐步拉长。

基于这个分类,我的重试逻辑不写在 logger 层面,而是写成一个独立的任务模型,让每个待抓 URL 都带上“重试次数”和“失败原因”字段。多次失败的任务丢进死信队列表,每日单独跑一个补偿任务去处理。

这里有一个小经验:解析异常和网络异常必须分开处理。网络异常是请求没发出去,重试还有意义;解析异常说明页面结构变了,很可能需要人工干预调整解析规则。把两种异常丢进同一个重试逻辑里,就是一种自欺欺人式的“看起来流程完备”。

5.4 任务调度:定时增量采集

真正的生产级爬虫绝不是一次性跑完就结束,而是需要按小时、按天增量采集。Java 生态最省心的方案是直接用现成的分布式调度框架,比如 xxl-job 或 Quartz,把采集任务拆成三种:

任务类型频率说明
全量采集每周把核心列表页全部扫一遍,发现新增/变更
增量采集每小时只抓时间窗口内更新的页面
异常补偿每半小时处理死信队列和失败重试任务

增量采集的核心是确定一个增量时间锚点,比如根据最后发布时间字段过滤 URL 列表。不要简单地把上次任务结束时间作为锚点,因为任务的重试延迟会导致错过中间产生的数据。更稳妥的方式是记录每个来源站点的“最近成功采集时间”,并从这个时间往前预留一定的重叠窗口,比如预留 30 分钟,防止数据漏采。

6. 踩坑实录:我在爬虫项目里遇到的三类典型问题

6.1 编码乱码问题的完整排查链路

在一次采集新闻类站点的项目中,页面上的中文全部变成了???或者乱码。很多人第一反应是“转编码”,但我的排查链路是:

第一步,先看 HTTP 响应头里的Content-Type是否声明了 charset。Chrome 的开发者工具里能看到Content-Type: text/html; charset=utf-8或者charset=gbk。

第二步,看 HTML 文档里的<meta charset="...">标签。有些站点 HTTP 头没声明编码,但 HTML 头部有。

第三步,如果两者都没有,就直接看前几个字节判断。一般来说,UTF-8 的中文会有清晰的字节规律,GBK 也是。实在不对就用工具库去猜字符集,比如juniversalchardet。

我那次遇到的是 HTTP 响应头写的charset=utf-8,但页面实际是 GBK 编码,HTTP 头把解析器带偏了。解决办法是强制忽略 HTTP 头里的 charset,以实际内容检测为准:

String rawHtml = EntityUtils.toString(response.getEntity(), Charset.forName("ISO-8859-1")); byte[] bytes = rawHtml.getBytes(Charset.forName("ISO-8859-1")); String realContent = new String(bytes, Charset.forName("GBK"));

这里的关键是先用 ISO-8859-1 把字节保留下来,再按真实编码转换。如果你直接用EntityUtils.toString(response.getEntity(), Charset.forName("UTF-8")),编码转换发生在字符串创建的那个瞬间,字节已经丢失,再转换就晚了。这个技巧我用了很多次,几乎每天都要用。

6.2 动态页面拿不到数据:接口直连优先

采集某数据平台时,页面列表的前十条数据能抓到,后面的数据全是空的。打开浏览器调试才发现,页面是 Vue 动态渲染的,服务端返回的 HTML 骨架里根本没有数据,数据是页面加载后由 JavaScript 异步请求某个 JSON 接口再去填充。

最常见的解法是抓包找接口直连。在开发者工具的 Network 面板筛选XHR或Fetch,找到返回 JSON 的那个请求,直接请求这个接口,解析 JSON 而不是解析 HTML。这样做的好处是数据更干净、结构稳定,而且接口通常比页面轻量得多。

如果接口加上了签名验证参数,事情就变复杂了。这时候有人会想到用 Selenium 或 Playwright 跑真实浏览器渲染。非到万不得已我不建议这一条路,因为真实浏览器的资源消耗极大,靠它做大规模采集,机器成本会涨好几个数量级。用到它只意味着一个结论:纯静态解析方案已经走不通了,需要评估是否还有合法的替代数据源。

6.3 连接池耗尽问题

项目压测时发现,吞吐量到某个阈值后突然暴跌,同时日志大量出现Connection pool exhausted异常。排查过程是这样的:

先看代码里是否有response.close()被遗忘。在连接池模式下,响应如果没关闭,连接就一直被占用,最终连接池被拖垮。我抽查代码发现,有些分支只做了response.getEntity(),忘了在 finally 里关闭响应。

再看连接池参数。MaxTotal和DefaultMaxPerRoute的默认值在低并发下够用,一上量就严重制约。在 HttpClient 4.5 里,路由粒度是按协议加主机和端口来区分的,如果你访问了多个域名,MaxPerRoute的配额会相互隔离,某个热点域名的连接不够用,其他域名再多也帮不上忙。

最终解决方案是双重整改:一是统一封装一个工具方法,用 try-with-resources 保证响应一定被关闭;二是把好参数调大,并加上监控,每五分钟打一次连接池的使用率日志:

logger.info("连接池状态:available={}, leased={}, pending={}", poolStats.getAvailable(), poolStats.getLeased(), poolStats.getPending());

连接池是一个典型的“平时感觉不到,出问题就全局崩”的组件。我建议任何爬虫项目上线前都做一次简单的并发压测,就起几百个线程同时刷同一个页面的 URL,观察连接池指标是否正常,能提前暴露绝大多数代码级资源泄漏问题。

在做完上面这些工作后,我又围绕这套方案做了不少微调,比如把公用的请求头定义成常量类、把限速参数放到配置文件里、用模板方法模式把同类型站点的解析逻辑抽成基类,这样再接入新站点时的开发工作量能下降不少。最后给正在打算用 Java 写爬虫的人一个建议:先不要急着写请求和解析,先把采集模型设计好,把任务表结构定义好,想清楚失败怎么办、重复怎么办、监控怎么做,再开始写第一行代码。爬虫死得最快的不是不会写,而是写了一堆没有工程质量意识的流水账代码。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询