☰
Java网络爬虫实战:HttpClient+Jsoup构建高效采集系统
2026/10/14 15:04:04 网站建设 项目流程

最近接到一个需求,要用Java写一套网络爬虫,去采集几个行业网站的数据。本来想图省事用Python,但项目组现有的技术栈是Java,数据要直接落到MySQL和ES里,后续还要接公司内部的消息队列,用Java反而能少走弯路。折腾了几天,把HttpClient、Jsoup、线程池、代理IP这些组件串起来,总算是跑通了。

这篇文章就记录一下我在这个Java网络爬虫项目里的完整思路和踩坑过程。不会讲太多虚的,全是实际能落地的代码和方案,适合刚接触爬虫、或者准备在Java技术栈里做数据采集的同学参考。如果你已经在用Python写爬虫,也可以看看Java这边的工程化思路,两种语言在解决同一类问题时,侧重点真的不太一样。

1. 为什么要用Java写爬虫,而不是Python

网上聊爬虫,十篇有八篇是Python的,requests加BeautifulSoup一套组合拳下来,几十行代码就能跑。但真到了企业级的数据采集场景,Java的优势才会体现出来。

先说语言本身。Java的强类型特性在写复杂解析逻辑时其实是个保护,字段定义清晰,IDE提示到位,不像Python写久了容易忘了某个字段到底是字符串还是字典。另外Java的异常处理机制也更适合爬虫这种高IO、高失败率的场景,你可以准确地捕获连接超时、解析失败、反爬拦截等不同类型的异常,分别做降级处理。

再聊生态和集成。做爬虫从来不只是“把网页抓下来”这么简单,抓完的数据要清洗、去重、入库,要定时调度,要监控运行状态。这些在Java生态里都有非常成熟的方案:Spring Boot负责定时任务和依赖管理,MyBatis或者JPA负责数据持久化,XXL-Job或Quartz负责分布式调度,监控报警直接接Prometheus或者公司自带的监控系统。你用Python写爬虫抓完数据,最后还是要写一堆胶水代码去把数据转发给Java服务,多一层网络开销不说,出了问题排查起来还麻烦。

最后是性能和资源控制。Java的线程池机制配合HttpClient的异步特性,可以在单机上轻松维持几百个并发连接,而且每个连接的内存占用是可控的。Python因为GIL的存在,多线程爬虫在CPU密集型的HTML解析场景下会被锁住,只能用多进程或者协程来弥补,部署和运维成本反而更高。

这不是说Python不好,Python的requests库写起来确实爽,Scrapy框架的插件生态也丰富。但如果你的项目本身就是Java技术栈,数据要直接进ES、MQ、各种Java中间件,那用Java写爬虫反而是一条更顺的路。我这次就是把采集、解析、清洗、入库全部在一个Spring Boot服务里搞定,没有额外的数据传输环节,整体链路短,出问题也好定位。

2. 整体架构设计思路

写爬虫和写CRUD接口不一样,不能用“一次性请求”的思路来写。数据采集是一个持续的、动态的过程,你面对的网站随时可能改版、升级反爬策略、调整接口参数,所以架构设计上要留出足够的缓冲和容错空间。

我这次采用的架构分为五层:调度层、采集层、解析层、清洗层、存储层。

调度层负责控制“什么时候抓、抓哪些URL”,我用Spring Schedule做定时触发,再用一个自定义的URL队列管理器维护待抓取的任务。采集层是核心,用HttpClient发送HTTP请求,带着配置好的请求头、Cookie、代理IP池等参数去获取响应结果。解析层负责把拿到的HTML或者JSON转换为结构化的Java对象,根据页面类型的不同,有时候用Jsoup解析HTML,有时候用Jackson解析JSON接口。清洗层做的工作就是去重、过滤无效数据、处理缺失字段、统一数据格式。存储层就是把最终干净的数据落库,我这边是写了双写逻辑,一份进MySQL用于业务查询,一份进ES用于全文检索。

关键的设计决策是“采集”和“解析”解耦。页面请求逻辑和页面解析逻辑写在一起,初期看起来省事,但网站一旦改版,你就得在一堆代码里找哪里是抓取的、哪里是解析的,非常痛苦。我用了生产者-消费者模式,采集线程只负责把响应结果放进一个阻塞队列,解析线程从队列里取数据做解析。队列有界,防止内存被打爆;采集速度和解析速度不一致时,队列天然起到了削峰填谷的作用。

URL管理也是容易被忽视的一环。爬虫最容易犯的错就是重复抓取同一个页面,既浪费带宽又给目标网站增加压力。我在内存里维护了一个去重集合,同时把已抓取的URL指纹写进Redis,用Set类型做去重,这么设计的好处是即使服务重启了,运行过程中已抓取的URL还能被Redis记住,不会从头再抓一遍。

关于爬取频率的控制,我是用了最简单的令牌桶算法。一个定时任务每秒钟往桶里放一定数量的令牌,采集线程每次抓取之前先申请一个令牌,拿到令牌才允许发HTTP请求,拿不到就阻塞等待。这个方法虽然简单,但能把抓取速率控制得死死的,比直接在代码里sleep更灵活。比如目标网站白天访问量大,我就可以动态调低令牌桶的速率,晚上再放开,完全不用重启服务。

3. 用Jsoup解析HTML的核心细节

Jsoup是Java领域解析HTML的标配工具,它的哲学和jQuery很像,用选择器语法就能定位页面元素。我一直觉得,如果你会用jQuery,那上手Jsoup基本没有成本。

用Jsoup解析页面的第一步是把HTML字符串加载成一个Document对象。这里有几种方式,最常见的是Jsoup.parse(html),传入一个字符串就可以。需要注意的是,网页的字符编码问题很容易踩坑,如果HTTP响应头里没有明确的charset,而HTML的meta标签里声明的是UTF-8,Jsoup默认的解析行为可能会导致中文乱码。我遇到这种情况后,在解析之前先做了一次编码探测,用了一个叫做CharsetDetector的工具类,先根据Content-Type头部的charset参数判断,没有的话就去HTML的head标签里找meta声明,实在找不到才用UTF-8兜底。

定位元素是解析的核心操作。doc.select("div.product-list")、doc.select(".price")、doc.select("#main > ul > li"),这些CSS选择器的用法和前端一模一样。还有一个很常用的方法是doc.getElementsByClass("item"),它等价于.item这个类选择器,返回一个Elements集合,可以遍历每个元素再做二次定位。

我在解析中经常用到的一个模式是先定位“列表项的外层容器”,再在容器内部查找目标字段。比如一个商品列表页,每个商品在一个div.goods-item里,里面的span.goods-name是商品名,span.goods-price是价格。我先Elements items = doc.select("div.goods-item")选出所有商品,再遍历items,分别调用item.select("span.goods-name").text()和item.select("span.goods-price").text()取出字段值。这种方式比直接用一大串复杂的选择器一次定位更稳健,页面结构稍微调整时,只需要改内层选择器就行。

Jsoup处理相对路径也是一把好手。页面里的链接经常是/product/123.html这种相对地址,直接用会出问题。用absUrl方法可以自动补全成绝对地址,比如element.absUrl("href"),前提是Document对象加载时设置了baseUri参数。HttpClient请求时我已经知道了最终的URL,所以Jsoup.parse(html, url)这种方式就能把页面链接全部转为绝对路径,非常省事。

如果页面里有些数据不在HTML里,而是通过AJAX异步加载的,那用Jsoup直接解析是拿不到的。这种情况下必须用浏览器开发者工具找到那个异步接口,直接用HttpClient请求接口拿JSON数据。这个思路很重要,不能死磕HTML解析,学会分析网络请求是爬虫进阶的必修课。

4. HttpClient请求库的实战用法

项目里我用的是Apache HttpClient 4.5版本,虽然现在有更新的HttpClient 5.x,但4.5因为稳定性和兼容性,在企业项目里还是主流。我封装了一个基于连接池的HttpClient工具类,把连接池配置、超时设置、请求头管理、重试机制都封装好,外部调用只需传一个URL就能拿到响应状态码和响应内容。

连接池是HttpClient的核心配置。爬虫通常需要同时请求几十个甚至上百个页面,如果每个请求都新建一个HTTP连接,TCP握手的时间开销会非常可观。我的做法是设置一个最大连接数200、每个路由的最大连接数100的连接池,并开启Keep-Alive策略,让TCP连接可以复用。实际压测下来,开启了连接池后,吞吐量提升了将近三倍,而且目标服务器的连接压力也变小了。

超时设置是三段式配置,联系超时、连接超时、Socket读取超时。最初的版本是把超时时间全设在10秒,结果经常因为某个页面响应慢,队列里堆积了大量超时异常。后来我把Socket读取超时调到了15秒,连接超时保持5秒,配合HttpClient的连接管理策略,问题就缓解了很多。

请求头也是需要精心构造的。很多网站的防护策略首先看User-Agent,如果发现是爬虫常用的UA,直接拒绝服务或者返回验证码页面。我用了一个UA池,里面放了Chrome、Firefox、Safari、Edge几种主流浏览器的UA字符串,每次请求随机取一个。更完整一点的方案还要带上Referer、Accept、Accept-Language等字段,模拟真实浏览器的请求特征。

处理重定向也是HttpClient的重要功能。HttpClient默认会跟随302重定向,但问题是重定向后你可能丢失了自定义的请求头。我遇到了一个网站,登录后的Cookie是加在初始请求上的,结果重定向后HttpClient默认策略没有把Cookie带过去,导致跳转后的页面显示未登录。解决办法是关闭自动重定向,手动处理Location头再发一次请求,这样Cookie和header都能自己控制。

Cookie的存储管理同样关键。我实现了一个基于持久化存储的CookieStore,把Cookie保存到本地文件里,这样程序重启后还能沿用之前的登录态,不用每次启动都重新登录。这个方案在需要登录的网站上非常实用,省去了一堆登录验证的流程。

5. 代理IP池与反爬应对策略

反爬是每个爬虫工程师都躲不开的话题。正规的数据采集,一定要遵守网站的robots协议,合理控制频率,不恶意攻击站点。但即便如此,很多网站还是会验证User-Agent、检测访问频率、校验Cookie,来拦截看起来像“非人类”的请求。

应对反爬,最有效的办法就是代理IP。我用了一个管理代理IP的组件,从代理服务商那边获取IP列表,定期验证可用性,然后供采集线程轮换使用。每个IP有自身的权重和失败次数,失败次数超过阈值就会被暂时移出池子,定期重新探测。

代理分为透明代理、匿名代理和高匿代理,采集敏感数据时一定要用高匿代理。高匿代理不会在HTTP头里暴露你的真实IP,目标网站看到的就是代理IP本身。如果用了透明代理,服务器可以从X-Forwarded-For等字段看出你的真实地址,那代理就形同虚设了。

除了IP层面的应对,请求频率控制也是关键。我给每个代理IP设置了每秒最大请求数,超过阈值就换下一个IP。配合前面说的令牌桶算法,整个爬虫的请求压力被均匀地分散到各个IP上,单IP的负载很小,自然不容易触发反爬规则。

验证码一直是爬虫的难点,能绕开就绕开。我发现很多网站虽然会弹验证码,但如果你的请求头、Cookie和访问行为足够像真人,触发验证码的概率并不高。比如我会在两次请求之间随机暂停2到5秒,并且让请求的次序不完全按照URL列表的顺序,而是随机打乱,这样看起来更像是用户在手动浏览。

如果实在绕不开验证码,那就只能上打码服务了。市面上有很多在线的验证码识别服务,通过API提交图片返回结果,集成起来不算复杂。不过这也意味着成本上升和延迟增加,我的选择是优先调整行为模式,尽量减少验证码的出现频率,只有极少数情况下才调用打码服务。

6. 多线程采集的并发控制实践

单线程爬虫的效率实在是太低了,特别是面对几千个URL的任务队列时,用单线程可能要跑几个小时。我在采集层引入了线程池,并发度控制在合理范围内,既能显著提升速度,又不会对目标站点造成过大压力。

线程池我用的是ThreadPoolExecutor,核心线程数设为10,最大线程数设为20,队列容量为500。选这个参数不是什么玄学,是综合考虑了本机CPU核数、内存大小以及目标网站的承受能力。队列满的时候会执行CallerRunsPolicy拒绝策略,让提交任务的线程自己执行任务,这样就不会出现任务丢失的情况。相比直接使用Executors.newFixedThreadPool(),手动创建线程池的好处是参数完全可控,出现异常时也更容易定位问题。

线程安全是并发采集的重中之重。我们在多个线程里共享URL队列、代理IP池、统计数据等资源,如果不对这些共享资源做同步控制,会出现重复抓取、IP选择冲突、数据统计不准确等各种问题。

我选的方案是ConcurrentLinkedQueue来管理URL队列,它基于CAS实现,并发性能好,不会锁住整个队列。代理IP池用了ConcurrentHashMap加AtomicInteger来管理每个IP的权重和状态。统计数据则是用LongAdder,在高并发更新计数时比AtomicLong的性能更优秀。

还有一个小细节是线程上下文的传递。我在设计时给每个爬虫任务加了一个自定义的上下文对象,里面存了任务ID、来源URL、重试次数等信息。解析线程处理数据时,可以通过这个上下文追溯到数据来源,后续排查问题时能省很多力气。

多线程采集虽然爽,但问题也容易出现在并发上。最常见的问题就是某个共享数据结构没有做好同步,导致数据错乱或者抛出ConcurrentModificationException。我的建议是,在代码审查时重点检查所有在多线程环境下会被修改的集合对象,看是否使用了线程安全的数据结构,或者是否有加锁保护。这个环节偷懒,后面排查bug会痛苦百倍。

7. 数据清洗与持久化的完整流程

采集和解析只是前半场,数据清洗和持久化才是爬虫价值的体现。抓下来的原始数据质量参差不齐,有HTML标签残留、有空字段、有编码混乱、有重复数据,直接入库会严重污染下游的业务分析。

在清洗层,我先做的是字段级校验。商品价格必须大于0,发布时间不能晚于当前时间,标题长度不能超过数据库字段长度限制,这些校验规则在一个ValidationUtil里统一维护。校验不通过的数据不会直接丢弃,而是写入一个日志表中备案,方便后续追溯和分析。

接着是格式统一。日期字段统一转成yyyy-MM-dd HH:mm:ss格式,数字字段统一去除千分位逗号,金额字段统一保留两位小数。这里务必记得处理两个常见的坑:一是网页里经常出现 这种HTML实体字符,需要转成空格;二是数字字段里偶尔会有全角数字或中文数字,需要做转换。清洗这一步看起来不起眼,但真的是数据质量的生命线。

持久化我用的是MyBatis-Plus,主要是图它简便的CRUD和分页插件。写入策略上,MySQL用INSERT ... ON DUPLICATE KEY UPDATE做幂等写入,靠唯一索引去重。ES的话是先查出文档ID是否存在,有则更新,无则新增。

批量写入的性能优化也是值得注意的。刚开始我是一条一条地往MySQL里insert,一万条数据要跑快一分钟。后来改成了每攒满500条执行一次批量插入,瞬间提速到原来的五倍左右。批量操作时要注意控制每条SQL的参数数量,单次批量太大反而会因为SQL语句过长或者事务过大导致性能下降,甚至触发MySQL的max_allowed_packet限制。

8. 常见问题排查与避坑指南

爬虫项目实施过程中,我记录了十几个典型问题和对应的排查思路,这里挑几个有代表性的分享出来,希望你能少踩点坑。

第一个是响应内容乱码。分析思路是:先确认目标站点返回的字符编码,再看程序里是否正确处理了这个编码。我用了一套三级探测逻辑,先看HTTP响应头里的Content-Type,再看HTML的meta标签,最后用第三方库做内容编码猜测。绝大多数乱码问题都是编码探测不健全导致的,这个解决思路基本可以覆盖99%的网站在编码方面的坑。

第二个是SSL证书校验失败。用HttpClient请求HTTPS站点时,经常会遇到证书链不完整或者证书过期的情况。解决办法是构造一个信任所有证书的SSLContext,绕过证书验证,但要注意这会产生中间人攻击风险,建议只在可控环境下使用。更稳妥的方案是把目标站点的证书下载下来,导入到本地信任库中。

第三个是请求超时和重试。网络环境不稳定时,某个请求偶尔会超时,如果直接放弃会丢失数据,如果无限重试又可能卡死线程。我用的方案是支持重试机制,最多重试3次,每次重试的等待时间按指数退避递增。重试超过了3次就丢弃该URL并写入失败日志,由定时任务统一进行补偿抓取。

第四个是内存溢出问题。爬虫程序跑的时间长了,内存情况总是需要特别关注。如果响应内容很大,且解析后对象没有及时释放,加上队列积压过多,很容易触发OutOfMemoryError。建议用可观测性工具实时关注堆内存使用情况,配合有界队列来控制生产速度。另外,我建议把大页面请求的结果先落盘到临时文件,再异步解析,避免同时有太多大对象驻留在堆内存。

第五个是动态数据加载问题。很多时候目标页面的数据是通过AJAX异步加载的,直接用HttpClient请求HTML页面拿到的是空壳。这种情况我建议使用浏览器开发者工具,找到数据对应的XHR请求,直接请求这个接口。接口返回的JSON数据不仅结构稳定,解析起来也更方便。当然,如果是纯JavaScript渲染的页面,就只能上Selenium或者Playwright这类浏览器自动化工具了,但那是另一个复杂度级别的事情,通常作为最后的备选方案。

9. 分布式爬虫的扩展思路

当你的采集量级从日级十万涨到千万级别时,单机爬虫就跑不动了。这时候需要考虑横向扩展,也就是分布式爬虫。

分布式爬虫的核心思想是将任务队列从内存中搬出来,放到外部的消息中间件中。我用Kafka作为任务分发中心,每台采集节点从Kafka中拉取URL任务,处理完成后把新的URL再生产回Kafka,形成一个闭环。数据解析和入库的结果统一发到另一个Topic,由专用的写入组消费入库。

这个架构比单机的线程池方案复杂不少,但好处也很明显:可以对任意节点单独扩缩容而无须停止整个服务。某个节点被目标网站封了IP,直接下线该节点,换一批新的代理IP,再把新节点加回集群就行。

调度一致性是分布式场景下面临的最大挑战。比如所有节点都在跑定时任务,到整点就同时发起采集,会产生巨大的流量峰值,并且极容易触发目标网站的反爬策略。我引入了集中式的调度服务,由调度中心统一分配任务片,每个采集节点只处理分配给自己的那一份任务,互不竞争。

分布式采集还有一个数据一致性的问题。同一个URL有可能被两个节点同时抢到并抓取,所以我的去重逻辑从Redis单机版本改成了Redis集群的SETNEX操作,确保同一个URL在全集群只会被一个节点成功消费。这个改造是很必要的,不然后台的脏数据会大量增加。

不过说实话,如果你只是做中小规模的数据采集,单机方案完全够用。引入分布式组件会显著提升系统的复杂度,如果不能驾驭Kafka、Redis集群这些基础设施,反而会把自己搞得很累。我的态度是:按需演进,先用单机方案跑通业务,明确瓶颈之后再上分布式架构。

10. 聊聊学习路径和踩坑心得

写完这套Java网络爬虫,我对“爬虫工程师”这个角色的理解又深了一层。爬虫不是简单地写代码调接口,它要求你有网络协议基础、有HTML/CSS/JavaScript功底、有并发编程经验、有数据分析思维,还得懂一些系统运维的知识。

对于想入门Java爬虫的新手,我建议从最基础的内容学起,不要一上来就想搞分布式。先用HttpClient+Jsoup写一个能跑的单机爬虫,采集几个静态页面,做数据清洗和入库,把整个链路跑通。然后再考虑加入多线程、代理池、布隆过滤器去重、异常处理框架这些进阶方案。每一层都不难,但叠加起来,就构成一个完整的爬虫系统。

踩过的坑多了以后,我的体会是,爬虫系统出问题,十次有八次不是代码逻辑错,而是对目标站点的行为理解不到位。写爬虫之前一定要花时间分析目标网站的技术栈和页面结构,先手动用浏览器打开页面,按下F12看网络请求,能请求接口就直接请求接口,实在不行再解析HTML,这会省去大量无效编码时间。

最终实际运行下来,这套Java爬虫系统每天能稳定采集约50万条数据,抓取成功率达到99.2%,这还是在目标网站时不时调整前端代码的情况下。我不敢说所有场景都适合Java,但在Java技术栈里做数据采集,这套方案确实是最稳妥的一条路。以后要是聊到反爬对抗、数据调度,按照这个思路继续往下做,路会很顺。

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

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

立即咨询