Java爬虫实战:多线程任务队列与反爬策略全解析 写这篇爬虫笔记的时候我刚把一个Java采集脚本从单线程改成带任务队列的多线程版本。起因很简单一个跨平台数据整理项目需要某公开图书网站的图书信息和评论摘要数据量大概十几万条还要求每天增量更新。一开始我想直接用现成的开源爬虫框架后来对比了一下发现这种轻量级场景根本用不上那么重的调度系统于是用Java原生HTTP组件加几个解析库自己搭了一条完整的采集链路。这篇笔记不是教科书式的源码讲解而是把这套东西从设计到落地过程中的关键取舍、踩坑记录、排查思路整理出来。适合两类人看一是刚接触爬虫、想用Java做数据采集的开发者二是已经写过简单爬虫、但遇到反爬、并发、增量更新等问题时需要参考方案的从业者。我会尽量把每个“为什么这么选”“为什么踩坑”都写清楚。1. 项目整体设计与思路拆解1.1 为什么用Java写爬虫而不是直接用Python很多人一提到爬虫就想到Python这个思路没错Python的生态确实最全写起来也最短。但Java在有几个场景下有不可替代的优势我这里选Java不是因为Python不行而是因为整个数据处理链路的后端本来就是Java写的。当时的项目情况是抓回来的数据要直接进入已有的Java后端服务进行清洗、去重、落库然后通过接口提供给前端查询。如果抓取端用Python整个链路里就多了一个跨语言组件数据要通过文件或者消息队列中转运维的时候要维护两套运行时。用Java写爬虫抓取、解析、清洗、存储全部在同一个工程里完成部署就是一个jar包逻辑复用也方便。另外Java爬虫在可控性上更符合我的习惯。Python的爬虫框架有很多封装好的中间件很多细节被隐藏了出了问题反而不好排查Java这边从HTTP连接到解析器都是显式控制的清楚每一步在干什么。对于这种规模不大、但对稳定性和可维护性有要求的项目Java反而更顺手。1.2 爬取目标的边界定义动手写代码之前我花了半天时间把目标网站的结构摸了一遍。这一步非常关键很多人上来就写请求结果爬到一半发现漏了字段或者多了脏数据再回头看网站结构已经改版了。目标网站是一个图书展示类站点结构很典型首页有分类入口每个分类下列表分页列表页每页显示20本书包含封面、书名、作者、价格点进详情页有完整的简介、出版信息、目录和读者评论我把采集范围分成三层第一层分类入口页拿到所有分类的URL第二层每个分类的列表页拿分页总数和每本书的详情页URL第三层详情页提取完整字段这个层级划分帮了大忙。后面每次运行出问题先看跑到哪一层就能快速定位是列表页规则失效还是详情页的区块选择器没匹配上。边界也提前定好只抓公开可见的文本数据不做登录态绕过不做验证码识别不抓任何涉及个人隐私的字段。这些边界不光是合规需要也直接影响了设计复杂度——不碰登录态就不用维护Cookie会话不碰验证码就不用引入图像识别模型。1.3 技术栈选型与依赖清单整个项目用到的组件不多但每个都是有针对性的组件用途选型理由Java 11 Maven基础环境公司后端统一版本避免环境割裂Apache HttpClient 4.5.xHTTP请求支持连接池、重试、超时控制比Java原生HttpURLConnection好用太多Jsoup 1.14.x页面解析CSS Selector语法简洁容错性好专为HTML解析设计Jackson 2.13.xJSON处理部分接口返回JSON格式数据Jackson性能好且兼容Java 11HikariCP MySQL数据存储连接池轻量可靠存储端直接复用现有表结构没有引入重量级框架比如布隆过滤器、分布式任务调度、代理池管理等因为数据量没到那个量级加了反而增加运维负担。合理评估规模再选型这是我这几年做项目最大的体会。技术选型的时候还有一个小考量解析部分要不要用正则表达式最终没有把它作为主力解析方式因为HTML结构经常微调正则写死了很难维护。Jsoup的CSS选择器对结构变化的容忍度高得多正则只在处理特定字符串格式时才用比如从描述里提取ISBN号。2. 核心细节解析与实操要点2.1 请求构造的细节与反爬对策请求构造是整个爬虫里最容易出问题的地方也是反爬检测的重点关注区域。第一次写完代码直接跑很快就被对方服务器识别了返回403。排查下来问题出在几个细节上。User-Agent必须伪装成真实浏览器。HttpClient默认的User-Agent是一串Java的标识网站一看就知道不是普通用户。我一开始只改了这个还是有拦截后来才发现光改UA不够还需要把Header补全。一个真实的浏览器请求头通常包含这些字段User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/avif,image/webp,*/*;q0.8 Accept-Language: zh-CN,zh;q0.9,en;q0.8 Accept-Encoding: gzip, deflate, br Connection: keep-alive Upgrade-Insecure-Requests: 1在HttpClient里设置这些Header并不复杂但有一个容易忽略的点Accept-Encoding如果带了gzip那么响应体默认是压缩的需要手动处理解压。HttpClient的BasicResponseHandler不会自动解压要么去掉gzip要么用DecompressingHttpClient或者自己判断Content-Encoding。我选择保留gzip因为压缩传输更快更接近真实浏览器行为然后在代码里加了一层解压逻辑。请求间隔控制比Header伪装更重要。有些反爬策略不是看你头对不对而是看你的访问频率。如果每秒钟几十个请求不管Header伪装得多好都可能被限制。我给自己定的默认策略是同一个域名下的请求间隔不低于800毫秒每个页面随机再加200到400毫秒的抖动。这样做的原因是固定间隔本身就带着机器特征。人浏览网页的时候每两次点击之间的间隔肯定不是精确相等的所以加随机延迟不仅是为了礼貌更是为了模仿人的行为。实际跑下来这个策略配合正确的Header基本没有触发过频率限制。Cookie和Referer的处理方式。对于不需要登录的网站Cookie并不是必须的但如果不带任何Cookie服务端可能认为你是全新用户有些网站会触发风控让请求重定向到验证页。我的做法是先手动用浏览器访问一次目标站点把浏览器自动生成的Cookie复制到代码里作为初始Cookie。这个方式不适合长期运行因为Cookie会过期但对于短周期的数据采集任务来说足够了。Referer的作用在于某些网站会校验来源页。比如详情页要求Referer必须是指向它的列表页URL如果不带上可能返回404或者空页面。我在构造详情页请求时把当前详情页的来源列表页URL动态设为Referer而不是写死这样更贴近真实浏览路径。2.2 页面解析策略与选择器编写技巧页面解析是另一个坑最多的环节。Jsoup的选择器语法本身并不难难在如何写出对页面结构变化有容忍度的规则。我第一次写选择器的时候直接按照浏览器里“检查元素”给出的路径来写doc.select(#book-list div.item:nth-child(2) div.info h3 a)这样写第一次跑完全没问题但跑了一周后网站改了一次样式书名链接从h3移到了h4我的规则立刻失效。后来我总结了经验优先使用稳定的属性锚点而不是位置锚点。改成了这样doc.select(div.book-item a[href*\\/book\\/])意思是找到所有class为book-item的区块再在其中找链接里包含/book/的a标签。这种写法对样式表的结构变动不敏感只要URL格式不变就能工作。对于页面里的关键字段我整理了一个提取清单字段选择器说明书名div.book-info h2.title优先用语义化class作者div.book-info span.author直接文本提取ISBNdiv.meta span.isbn带连字符需要清洗价格div.price span.amount提取数字部分简介div#desc p多段落拼接评论数div.comment-count b数字正则提取写选择器时的注意事项不要直接用tbody选择器浏览器会自动补全table结构而Jsoup不一定会容易造成选择不到内容文本提取的时候要用text()不要用html()不然会把嵌套标签一并带出来后期清洗很麻烦对可能不存在的字段要判空处理后走默认值避免空指针打断采集流程2.3 数据去重与任务队列设计爬虫跑起来之后最大的问题不是抓取慢而是重复。列表页有翻页边界重复、分类间有交叉重复、增量更新时旧数据和新数据混在一起如果不做去重落库的时候就会主键冲突或者产生大量冗余。去重我分了两层来做第一层是URL去重。维护一个SetString存放已处理过的详情页URL。单机场景下这个方案简单直接十几万URL的内存开销也就几十MB完全可控。如果数据量到百万级别再换成基于本地文件的去重或者布隆过滤器。第二层是业务键去重。光靠URL去重不够因为同一个ISBN的书可能出现在多个URL下。我在数据库表结构里给isbn字段加了唯一索引插入时用INSERT IGNORE这样就算URL不同、内容重复数据库这一层也能兜住。这个设计后来帮了大忙因为确实遇到同一个书在不同分类下以不同URL出现的情况URL去重完全拦不住。任务队列的设计也遵循了“够用就好”的原则。没有用消息队列中间件直接用的LinkedBlockingQueue队列A待抓取的详情页URL队列B待解析的HTML原始数据线程池从队列A取URL抓到HTML后放进队列B解析线程从队列B取数据这种生产者-消费者模式的好处是抓取速度和解析速度可以解耦。如果对方网站响应慢抓取线程自然阻塞解析线程不会被拖累如果解析逻辑复杂解析线程慢抓取线程也无需等待。两个队列之间的容量比我设计成3:1因为HTML原始数据比URL占内存大得多队列B不能太长。3. 实操过程与核心环节实现3.1 基础爬虫框架搭建工程结构上我保持了简洁没有做多模块拆分就是单模块、按功能分包src/main/java └── com.example.crawler ├── fetch │ ├── HttpClientFactory.java │ └── PageFetcher.java ├── parse │ ├── ListPageParser.java │ └── DetailPageParser.java ├── queue │ └── TaskQueue.java ├── store │ └── BookDao.java └── CrawlerApplication.javaHttpClient的配置是整个框架的地基。连接池配置得合理与否直接决定后期并发能力能到多少。public class HttpClientFactory { private static final HttpClient INSTANCE create(); public static HttpClient getInstance() { return INSTANCE; } private static HttpClient create() { // 连接管理器配置最大连接数200单路由最大连接数100 PoolingHttpClientConnectionManager cm new PoolingHttpClientConnectionManager(); cm.setMaxTotal(200); cm.setDefaultMaxPerRoute(100); // 请求配置连接超时5秒Socket读取超时10秒 RequestConfig config RequestConfig.custom() .setConnectTimeout(5000) .setSocketTimeout(10000) .setConnectionRequestTimeout(3000) .setRedirectsEnabled(true) .build(); return HttpClients.custom() .setConnectionManager(cm) .setDefaultRequestConfig(config) .setRetryHandler(new DefaultHttpRequestRetryHandler(2, true)) .build(); } }这里的几个参数我解释一下因为最开始我是照着网上配置抄的没搞懂含义后来踩了坑才回头研究。setSocketTimeout是读取数据的超时时间。如果对方服务器响应很慢但连接已经建立了超过这个时间还拿不到数据就断开。我设了10秒实际情况是偶尔会碰到单个详情页加载超过10秒的情况这时候重试机制就起作用了。DefaultHttpRequestRetryHandler的第二个参数requestSentRetryEnabled设为true表示如果请求已经发送但响应超时也会重试。这里有个隐患对于非幂等的POST请求重试可能导致重复提交。我的场景全是GET请求所以安全。setRedirectsEnabled(true)是允许自动重定向。有些列表页的URL末尾不带斜杠服务器会302到一个规范URL如果不自动跟随解析到的内容可能是空页面。3.2 列表页到详情页的抓取链路实现整个采集流程的代码结构是线性的但执行上是并发流水线。核心逻辑在CrawlerApplication里串联public void start() { // 1. 从入口页解析所有分类URL ListString categoryUrls fetchCategoryUrls(); // 2. 遍历分类解析列表页往详情页队列里投递URL for (String categoryUrl : categoryUrls) { String listHtml pageFetcher.get(categoryUrl); ListPageParser parser new ListPageParser(listHtml); ListString detailUrls parser.getDetailUrls(); for (String url : detailUrls) { if (!visitedUrlSet.contains(url)) { detailUrlQueue.offer(url); visitedUrlSet.add(url); } } } }列表页解析的重点在于分页信息和详情页URL的提取。分页信息通常在页面底部的分页导航里我解析下一页按钮的href属性然后循环抓取直到没有下一页public ListString getDetailUrls() { ListString urls new ArrayList(); Elements items doc.select(div.book-item a[href*\\/book\\/]); for (Element item : items) { String href item.attr(abs:href); if (StringUtils.isNotBlank(href)) { urls.add(href); } } return urls; } public String getNextPageUrl() { // 优先找下一页按钮链接 Element next doc.select(a.next:contains(下一页)).first(); if (next null) { // 部分列表页是数字页码取当前页号1 Element currentPage doc.select(span.current).first(); // ... 省略页码解析逻辑 } return next null ? null : next.attr(abs:href); }这里我踩了一个坑attr(href)拿到的是相对路径比如/book/123456.html如果直接用它构造请求就会丢域名。需要用attr(abs:href)它会基于文档的base URI自动拼接成绝对地址。Jsoup的Document对象如果设置了baseUri这个特性才会生效所以解析HTML的时候一定要传入页面的实际URLDocument doc Jsoup.parse(html, pageUrl);3.3 多线程并发采集与限速并发采集是提升效率的关键但也是容易被反爬的源头。我的方案是用固定大小的线程池控制并发数同时通过信号量控制全局请求速率。public class PageFetcher { private static final int THREAD_COUNT 8; private final ExecutorService executor Executors.newFixedThreadPool(THREAD_COUNT); private final Semaphore rateLimiter new Semaphore(1); private final long minIntervalMillis 800; public void fetchUrls(ListString urls) { for (String url : urls) { executor.submit(() - { rateLimiter.acquire(); String html get(url); rateLimiter.release(); handleHtml(html, url); }); } } }用Semaphore限速的方式同一时刻只有一个请求在跑然后配合强制的最小间隔就实现了全局每秒最多1.25个请求的速率。这个速率看起来慢但胜在稳定。我有试过把并发调到16、间隔降到300毫秒跑半小时后就开始出现偶发的请求超时再后来就直接被限流了。后来把并发数降下来才恢复正常。并发数不是越大越好要根据目标网站的响应能力和反爬严格程度来动态调整。保守起步、逐步试探上限控制在“能稳定跑3个小时不出错”的水平这是最实用的经验。线程池的拒绝策略也要处理。当详情页URL暴增时LinkedBlockingQueue可能会堆积大量任务如果线程池的队列满了会触发拒绝策略ThreadPoolExecutor.AbortPolicy默认会直接抛异常。我改成了CallerRunsPolicy让提交任务的线程自己执行被拒绝的任务这样既不会丢任务也可以反向给生产端一个背压信号让生产端放慢投递节奏。3.4 数据落库与增量更新策略数据落库这一层看似简单但设计好增量更新逻辑才能让爬虫长期跑下去。我一开始的设计是每次全量抓取后先清空旧表再插入但跑了一天就被否了书的价格、评论数量这类数据是变化的全量重刷代价大而且清理期间接口查不到数据影响线上服务。于是改成了按天增量更新的模式public void upsertBook(BookInfo book) { String sql INSERT INTO book_info (isbn, title, author, price, detail_url, raw_data, update_time) VALUES (?, ?, ?, ?, ?, ?, NOW()) ON DUPLICATE KEY UPDATE title VALUES(title), author VALUES(author), price VALUES(price), detail_url VALUES(detail_url), raw_data VALUES(raw_data), update_time NOW(); // ... 省略占位符赋值 }INSERT ... ON DUPLICATE KEY UPDATE配合isbn的唯一索引实现“有则更新无则插入”的幂等写入。这个模式对增量更新非常友好已经存在的书只看价格和评论数有没有变化有变化就更新新上架的书自动插入。增量更新的触发方式很简单粗暴但有效——每天凌晨执行一次定时任务走全量列表扫描但因为有数据库层去重实际插入的只有新增数据更新的只有字段变化的数据。全量扫描虽然URL多但写入量很小。还有一个细节原始HTML要不要存我选择在raw_data字段里存了一份完整详情页的HTML文本。这个字段看起来占空间但排查问题的时候特别好用。当某一天解析的字段突然为空我可以直接去库里翻原始HTML看看是选择器失效还是字段本身就没值不用重新请求一次。如果网站页面结构变过多次还可以用这个字段做历史回放测试。4. 常见问题与排查技巧实录4.1 403/429状态码的处理思路跑爬虫最常碰到的就是403和429。这两个状态码含义不同处理方式也不同。403是“禁止访问”通常是请求被识别为机器人或者IP被限制。我之前遇到403第一反应是上代理池后来发现过度设计了。多数情况下403是Header不完整加上访问频率过高造成的调整好Header和限速就能解决。如果调整后仍然403才会考虑IP层面是否被限制再考虑代理方案。429是“请求过多”这是更明确的信号说明访问频率超过了服务端阈值。处理429的策略很直接收到429后等待一段时间再重试退避时间从几秒到几分钟不等。我最终实现了一个简单的指数退避第一次429等待5秒第二次10秒第三次20秒最多退避到300秒。这个设计配合并发控制能在不太激进的前提下自动适应服务端的限流策略。private String getWithRetry(String url) { int retryCount 0; int maxDelay 300_000; // 最长退避5分钟 while (retryCount 6) { HttpResponse response execute(url); if (response.getStatusLine().getStatusCode() 429) { int waitMillis Math.min(5000 * (int) Math.pow(2, retryCount), maxDelay); Thread.sleep(waitMillis random.nextInt(1000)); retryCount; continue; } // ... 解析响应体 } throw new MaxRetryException(超过最大重试次数URL: url); }4.2 页面结构变动后的快速适配页面结构变动是所有爬虫开发者都躲不掉的坑。这个网站大概每两周会有一次小改动不是改样式就是改DOM结构每次都会影响到部分选择器。我的应对方法是在解析模块里加了一个“字段完整性校验器”。每次解析完一个详情页统计关键字段的非空率如果某个字段的非空率低于阈值比如书名低于95%就在日志里输出告警并把这个页面的URL和原始HTML单独存到一个parse_error表里。这套机制的价值在于不用人工盯着运行日志第二天早上看一眼告警邮件就知道昨天哪个字段出了问题。然后打开parse_error表里的原始HTML对照新结构更新选择器就可以了。还有一个更省事的思路解析时优先选稳定的数据属性。有些网站的HTML里会埋>// 错误写法entity消费了但连接没有确保释放 public String get(String url) throws IOException { CloseableHttpResponse response httpClient.execute(request); String body EntityUtils.toString(response.getEntity(), UTF-8); return body; }正确写法是要在finally块里关闭响应或者使用工具方法确保每个response都会被closepublic String get(String url) throws IOException { try (CloseableHttpResponse response httpClient.execute(request)) { return EntityUtils.toString(response.getEntity(), UTF-8); } }Java 7的try-with-resources写法会自动关闭响应连接会归还池中。这个细节不排查出来后面并发一开就崩。4.4 乱码问题与字符集处理页面乱码这个问题看着小实际处理起来很烦。目标网站的首页和列表页是UTF-8编码但详情页的部分字段是从旧系统导入的用了GBK编码导致Jsoup解析出来一段乱码。解决思路是这样的先根据HTTP响应头里的Content-Type判断字符集如果没有明确字符集就用Jsoup的ParseSettings和CharsetDetector做检测。Jsoup解析HTML时会自动阅读meta charset标签所以大多数页面直接传字节流给Jsoup就能正确处理。但对于那种meta里写UTF-8、实际内容混着GBK的页面就要在项目里写一层编码修正逻辑。我的做法是先按UTF-8解码如果发现替换字符的比例超过阈值就改用GBK重新解码public String decodeHtml(byte[] bytes) { String utf8String new String(bytes, StandardCharsets.UTF_8); int replacementCount countReplacementChar(utf8String); if (replacementCount bytes.length / 100) { return new String(bytes, Charset.forName(GBK)); } return utf8String; }这个方法不算完美但足够处理大多数场景。4.5 常见问题速查表整理一张速查表方便以后快速定位现象可能原因排查方向返回403Header不完整或频率过高检查UA、Accept、Referer降低并发返回429访问频率超过阈值指数退避重试增大请求间隔返回200但内容为空页面结构变化打开原始HTML检查选择器解析字段大量为null选择器失效或编码错误看parse_error表对照新结构获取连接超时连接未释放或池太小检查try-with-resources增大连接池内存持续增长队列积压优化解析速度收紧队列容量中文乱码字符集声明与实际不符编码修正逻辑按替换字符比例降级同一数据重复入库业务去重缺失检查唯一索引和INSERT IGNORE4.6 几个值得保留的调试技巧最后分享几个调试中常用的技巧这些技巧在官方文档里通常不会写。用单页调试跑通再上并发。我每次调整完选择器都先写一个测试方法只抓一个详情页把解析结果逐字段打印出来比对。确认无误后再放开并发。很多人图省事直接全量跑结果一个字段解析错了等发现时数据库里已经存了上万条脏数据清洗起来非常痛苦。把抓取和解析拆开调试。在抓取阶段报错时把HTTP状态码、响应长度一起打印在解析阶段报错时把选择器和目标HTML片段打印出来。这样能一眼看出问题出在网络上还是解析逻辑上不用反复猜测。维护一个精简的URL测试集。在项目里放一个test-urls.txt包含每个分类至少一个典型页面URL改完代码直接跑这个列表就能快速回归。完整回归全量页面太慢有这个测试集就够了。日志里记录每个阶段的耗时。抓取耗时、解析耗时、写入耗时分别统计。有时候看数据量判断不出来瓶颈在哪但看耗时分布一眼就明白了。比如写入耗时占比过高就应该考虑批量插入把每次逐条插入改成100条一批。写在最后这套Java爬虫从开发到稳定运行前后大概花了三周时间。回头复盘最初写第一版代码只用了两天剩下大部分时间都花在应对页面结构调整和优化稳定性上。我反而觉得这是正常的爬虫的核心从来不是把页面拉下来而是保证各种意外情况下数据依然准确、任务依然能推进。如果这个笔记对你有帮助可以顺着HTTP连接管理、任务队列、幂等写入这几个方向继续往深了研究它们不只是爬虫的底子也是很多后端系统的通用能力。