Java爬虫开发实战:从基础HTTP请求到高并发系统构建

Java爬虫开发实战:从基础HTTP请求到高并发系统构建 1. 项目概述为什么是Java爬虫在数据驱动的时代获取和分析网络公开信息的能力变得至关重要。提到网络爬虫很多人第一反应是Python毕竟它有着Scrapy、BeautifulSoup这样成熟的生态。但作为一名有十多年经验的Java开发者我经常被问到“Java能做爬虫吗是不是比Python麻烦很多” 答案是肯定的Java不仅能做而且在企业级、高并发、长周期运行的爬虫项目中Java往往展现出更强的稳定性和工程化优势。这个教程就是为那些熟悉Java生态希望将爬虫能力整合到现有Java项目中的开发者或者希望构建更健壮、可维护爬虫系统的朋友准备的。我们将从最基础的HTTP请求开始一步步深入到连接池管理、反爬策略应对、分布式调度等高级话题。你会发现用Java写爬虫绝非简单的“麻烦”而是一套完整的工程解决方案。它解决的不仅仅是“把数据抓下来”更是如何“稳定、高效、合规地持续抓取”。2. 核心需求解析Java爬虫的应用场景与挑战在动手之前我们必须明确Java爬虫的典型应用场景和它需要应对的核心挑战。这决定了我们技术选型和架构设计的出发点。2.1 典型应用场景Java爬虫并非为了替代Python在快速原型、数据分析脚本上的轻便性而是在以下场景中更具优势企业级数据集成与ETL许多企业的后台系统是基于Java如Spring Boot构建的。当需要从外部网站如行业门户、竞品网站、公开数据平台定时抓取数据并直接入库MySQL, Elasticsearch或进入数据仓库时用Java编写爬虫模块可以无缝集成共享同一套配置管理、日志框架和监控告警体系。高并发与分布式爬取对于需要同时抓取成千上万个页面的大型项目例如全网商品价格监控、新闻聚合Java凭借其强大的多线程、NIO以及成熟的分布式框架如Apache Storm, Apache Flink, 或自研基于消息队列的调度系统可以更优雅地管理资源、分配任务和处理故障转移。长周期稳定运行一些爬虫任务需要7x24小时不间断运行对内存管理、连接泄漏、异常恢复有极高要求。Java的JVM在长期运行和内存回收方面经过充分验证配合完善的监控如JMX, Micrometer更容易保证服务的稳定性。复杂业务逻辑处理当抓取的数据需要立即进行复杂的清洗、校验、关联业务规则时Java强大的面向对象能力和丰富的工具库如Apache Commons, Guava能让代码结构更清晰易于维护。2.2 面临的核心挑战明确了场景我们也要正视挑战这些是教程中会重点攻克的难点反爬虫机制这是爬虫开发者永恒的课题。现代网站普遍采用IP频率限制、请求头校验、验证码、JavaScript动态渲染、数据加密接口等手段。Java爬虫需要一套完整的策略来应对包括但不限于代理IP池、请求头模拟、验证码识别集成、无头浏览器调用等。解析复杂度网页结构千变万化从规整的HTML到混乱的标签嵌套再到JavaScript动态生成的内容。我们需要灵活组合不同的解析工具如Jsoup, HtmlUnit, Selenium。效率与资源平衡盲目提高并发数可能导致本地端口耗尽或目标服务器崩溃。如何设计合理的并发模型、连接池、请求间隔Polite Crawling是保证效率和遵守Robots协议的关键。数据存储与去重海量数据的高效存储和去重避免重复抓取是另一个工程问题。涉及到布隆过滤器、分布式缓存等的应用。3. 环境准备与基础工具选型工欲善其事必先利其器。Java爬虫的生态虽然不像Python那样“全家桶”但核心库非常强大且稳定。这里我推荐一套经过生产环境验证的工具组合。3.1 开发环境与依赖管理首先确保你有一个Java开发环境。我推荐使用JDK 11或17LTS版本它们提供了良好的性能和稳定性。构建工具选择Maven或Gradle以下以Maven为例展示核心依赖。dependencies !-- 核心HTTP客户端Apache HttpClient -- dependency groupIdorg.apache.httpcomponents/groupId artifactIdhttpclient/artifactId version4.5.14/version /dependency !-- HTML解析神器Jsoup -- dependency groupIdorg.jsoup/groupId artifactIdjsoup/artifactId version1.17.2/version /dependency !-- 处理JSONJackson或Gson -- dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId version2.16.1/version /dependency !-- 日志框架SLF4J Logback -- dependency groupIdorg.slf4j/groupId artifactIdslf4j-api/artifactId version2.0.12/version /dependency dependency groupIdch.qos.logback/groupId artifactIdlogback-classic/artifactId version1.4.14/version /dependency !-- 辅助工具库Guava -- dependency groupIdcom.google.guava/groupId artifactIdguava/artifactId version32.1.3-jre/version /dependency /dependencies注意版本号请根据实际情况更新。Apache HttpClient 4.x是阻塞IO模型对于极高并发场景可以考虑异步客户端如HttpAsyncClient或基于NIO的OkHttp。但对于绝大多数应用HttpClient 4.x的线程池管理已经足够优秀。3.2 核心工具详解与选型理由Apache HttpClient为什么不是JDK自带的HttpURLConnectionHttpClient提供了连接池管理、自动重试、cookie管理、灵活的请求配置等高级功能这些都是生产级爬虫所必需的。它让HTTP交互从“能用”变成了“好用且可靠”。Jsoup一个极其轻量级但功能强大的HTML解析器。它的API设计非常人性化支持CSS选择器对于静态HTML页面的解析效率极高。虽然它不能执行JavaScript但80%的爬虫场景中数据都在初始HTML中Jsoup是首选。HtmlUnit 与 Selenium当目标网站严重依赖JavaScript渲染时如Vue.js, React.js构建的单页应用我们就需要能执行JS的“无头浏览器”。HtmlUnit一个纯Java的无GUI浏览器。速度快内存占用相对较小适合在服务器端运行。但它对现代JS的支持偶尔会有小问题需要调试。Selenium通过驱动真实的浏览器如Chrome, Firefox来工作。兼容性最好能模拟几乎所有人类操作但资源消耗大速度慢。通常用于调试复杂场景或当HtmlUnit搞不定时。选型建议优先尝试HtmlUnit如果遇到问题再考虑Selenium。对于大规模爬取可以混合使用用HttpClientJsoup处理静态页用HtmlUnit处理动态页。4. 从零开始第一个Java爬虫实战让我们从一个最简单的例子开始抓取某个博客网站的文章标题列表。这个过程会涵盖HTTP请求、响应处理、HTML解析和数据提取的全流程。4.1 发起HTTP GET请求我们使用HttpClient来发送请求。第一步是创建HttpClient实例并配置连接池。强烈建议将HttpClient实例设为单例或通过池管理因为反复创建和销毁客户端的开销很大。import org.apache.http.client.config.RequestConfig; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClients; import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; public class HttpClientSingleton { private static CloseableHttpClient httpClient; static { // 1. 创建连接池管理器 PoolingHttpClientConnectionManager connManager new PoolingHttpClientConnectionManager(); connManager.setMaxTotal(200); // 最大总连接数 connManager.setDefaultMaxPerRoute(20); // 每个路由目标主机最大连接数 // 2. 配置请求参数 RequestConfig requestConfig RequestConfig.custom() .setConnectTimeout(5000) // 连接超时5秒 .setSocketTimeout(10000) // 读取超时10秒 .setConnectionRequestTimeout(2000) // 从连接池获取连接的超时时间 .build(); // 3. 构建HttpClient实例 httpClient HttpClients.custom() .setConnectionManager(connManager) .setDefaultRequestConfig(requestConfig) .build(); } public static CloseableHttpClient getInstance() { return httpClient; } }实操心得setDefaultMaxPerRoute这个参数至关重要。如果不设置默认是2意味着你对同一个主机同时只能有2个连接这会成为性能瓶颈。根据目标网站的承受能力合理设置通常10-20是个安全的起点。4.2 解析HTML与提取数据有了HttpClient我们就可以发起请求并获取HTML内容然后用Jsoup进行解析。import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpGet; import org.apache.http.util.EntityUtils; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; import org.jsoup.select.Elements; import java.io.IOException; public class SimpleCrawler { public static void main(String[] args) { String url https://example-blog.com/articles; CloseableHttpClient httpClient HttpClientSingleton.getInstance(); HttpGet httpGet new HttpGet(url); // 设置请求头模拟浏览器访问这是绕过基础反爬的第一步 httpGet.setHeader(User-Agent, Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36); httpGet.setHeader(Accept, text/html,application/xhtmlxml,application/xml;q0.9,image/webp,*/*;q0.8); try (CloseableHttpResponse response httpClient.execute(httpGet)) { int statusCode response.getStatusLine().getStatusCode(); if (statusCode 200) { String html EntityUtils.toString(response.getEntity(), UTF-8); // 使用Jsoup解析HTML Document doc Jsoup.parse(html); // 假设文章标题在 h2 class\article-title\ 标签内 Elements titleElements doc.select(h2.article-title); for (Element titleElement : titleElements) { String title titleElement.text(); System.out.println(文章标题: title); // 进一步可以提取链接 String link titleElement.selectFirst(a).attr(abs:href); // 获取绝对URL System.out.println(文章链接: link); } } else { System.err.println(请求失败状态码: statusCode); } } catch (IOException e) { e.printStackTrace(); } } }代码解析与技巧User-Agent这是最基本的反爬措施。务必设置一个常见的浏览器UA字符串。EntityUtils.toString(response.getEntity(), UTF-8)将响应实体转换为字符串并指定编码。务必指定编码否则可能乱码。有些网站会在HTML的meta标签里声明编码更严谨的做法是先读取字节流再根据响应头或HTML元信息判断编码。doc.select(h2.article-title)这是Jsoup的核心使用CSS选择器语法。你可以通过浏览器开发者工具F12的“检查元素”功能快速定位目标元素的CSS路径。.attr(abs:href)abs:前缀可以帮我们将相对路径如/article/123自动补全为绝对路径https://example-blog.com/article/123非常方便。5. 应对反爬虫策略从伪装到突破如果你的爬虫只运行一次可能相安无事。但一旦开始高频、持续访问很快就会遇到各种限制。下面我们来系统性地构建防御体系。5.1 请求头精细化伪装仅仅设置User-Agent是不够的。一个真实的浏览器请求会携带大量头部信息。我们可以构建一个“标准”的请求头集合。import org.apache.http.client.methods.HttpRequestBase; import java.util.HashMap; import java.util.Map; public class HeaderManager { private static final MapString, String COMMON_HEADERS new HashMap(); static { COMMON_HEADERS.put(User-Agent, Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36); COMMON_HEADERS.put(Accept, text/html,application/xhtmlxml,application/xml;q0.9,image/webp,image/apng,*/*;q0.8); COMMON_HEADERS.put(Accept-Language, zh-CN,zh;q0.9,en;q0.8); COMMON_HEADERS.put(Accept-Encoding, gzip, deflate, br); // 注意HttpClient默认会自动处理gzip这里声明即可 COMMON_HEADERS.put(Connection, keep-alive); COMMON_HEADERS.put(Upgrade-Insecure-Requests, 1); COMMON_HEADERS.put(Sec-Fetch-Dest, document); COMMON_HEADERS.put(Sec-Fetch-Mode, navigate); COMMON_HEADERS.put(Sec-Fetch-Site, none); COMMON_HEADERS.put(Cache-Control, max-age0); } public static void setDefaultHeaders(HttpRequestBase request) { for (Map.EntryString, String entry : COMMON_HEADERS.entrySet()) { request.setHeader(entry.getKey(), entry.getValue()); } } // 针对特定网站可以添加或覆盖头部 public static void setHeadersForSite(HttpRequestBase request, String referer) { setDefaultHeaders(request); request.setHeader(Referer, referer); // 设置来源页对于链式抓取很重要 } }5.2 代理IP池的搭建与轮询当IP被封锁时代理IP是解决方案。你可以购买付费代理服务也可以自建代理池难度较大。这里我们演示如何集成代理。import org.apache.http.HttpHost; import org.apache.http.client.config.RequestConfig; import org.apache.http.client.methods.HttpGet; import org.apache.http.impl.client.CloseableHttpClient; import java.util.ArrayList; import java.util.List; import java.util.Random; public class ProxyManager { private ListHttpHost proxyList new ArrayList(); private Random random new Random(); public ProxyManager() { // 这里模拟从数据库或API加载代理列表 proxyList.add(new HttpHost(123.45.67.89, 8080)); proxyList.add(new HttpHost(98.76.54.32, 8888)); // ... 更多代理 } public HttpHost getRandomProxy() { if (proxyList.isEmpty()) { return null; } return proxyList.get(random.nextInt(proxyList.size())); } public void markProxyFailed(HttpHost proxy) { // 可以将失效的代理移出列表或降低其优先级 proxyList.remove(proxy); System.out.println(代理 proxy 失效已移除。); } public static void main(String[] args) { ProxyManager proxyManager new ProxyManager(); CloseableHttpClient httpClient HttpClientSingleton.getInstance(); String url https://httpbin.org/ip; // 一个用于测试IP的网站 HttpGet httpGet new HttpGet(url); HeaderManager.setDefaultHeaders(httpGet); HttpHost proxy proxyManager.getRandomProxy(); if (proxy ! null) { RequestConfig config RequestConfig.custom() .setProxy(proxy) .setConnectTimeout(10000) // 使用代理时超时时间可适当延长 .build(); httpGet.setConfig(config); System.out.println(使用代理: proxy); } try (CloseableHttpResponse response httpClient.execute(httpGet)) { if (response.getStatusLine().getStatusCode() 200) { String body EntityUtils.toString(response.getEntity()); System.out.println(响应内容显示当前IP: body); // 解析body确认IP已变 } else { // 可能代理失效 if (proxy ! null) { proxyManager.markProxyFailed(proxy); } } } catch (Exception e) { // 网络异常代理可能失效 if (proxy ! null) { proxyManager.markProxyFailed(proxy); } e.printStackTrace(); } } }注意事项免费代理的稳定性极差延迟高可用率低仅适合学习测试。生产环境务必使用可靠的付费代理服务并实现代理健康检查、自动剔除失效代理、按延迟和成功率调度等逻辑。5.3 处理Cookie与Session许多网站使用Cookie来跟踪会话。HttpClient会自动管理Cookie我们只需启用并配置一个Cookie存储。import org.apache.http.client.CookieStore; import org.apache.http.impl.client.BasicCookieStore; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClients; public class CookieExample { public static void main(String[] args) { // 创建Cookie存储 CookieStore cookieStore new BasicCookieStore(); CloseableHttpClient httpClient HttpClients.custom() .setDefaultCookieStore(cookieStore) // 设置默认Cookie存储 .build(); // 第一次访问可能设置Cookie如登录 HttpGet loginPageGet new HttpGet(https://example.com/login); // ... 执行请求服务器可能会设置Session Cookie // 后续请求HttpClient会自动携带之前收到的Cookie HttpGet dashboardGet new HttpGet(https://example.com/dashboard); // ... 执行请求此时请求头中已包含Cookie // 你也可以手动添加Cookie // BasicClientCookie cookie new BasicClientCookie(key, value); // cookie.setDomain(example.com); // cookie.setPath(/); // cookieStore.addCookie(cookie); } }5.4 模拟登录与表单提交对于需要登录才能访问的页面我们需要模拟登录过程。这通常是一个POST请求提交用户名和密码。import org.apache.http.NameValuePair; import org.apache.http.client.entity.UrlEncodedFormEntity; import org.apache.http.client.methods.HttpPost; import org.apache.http.message.BasicNameValuePair; import java.util.ArrayList; import java.util.List; public class LoginSimulator { public static void main(String[] args) throws Exception { CloseableHttpClient httpClient HttpClientSingleton.getInstance(); String loginUrl https://example.com/login; HttpPost httpPost new HttpPost(loginUrl); HeaderManager.setDefaultHeaders(httpPost); // 构建表单参数需要分析目标网站的登录表单 ListNameValuePair params new ArrayList(); params.add(new BasicNameValuePair(username, your_username)); params.add(new BasicNameValuePair(password, your_password)); params.add(new BasicNameValuePair(csrf_token, 提取到的token)); // 很多网站有CSRF保护 httpPost.setEntity(new UrlEncodedFormEntity(params, UTF-8)); try (CloseableHttpResponse response httpClient.execute(httpPost)) { // 登录成功后后续请求的Cookie会自动由HttpClient管理 // 检查响应状态码或跳转URL确认登录成功 if (response.getStatusLine().getStatusCode() 302) { String redirectUrl response.getFirstHeader(Location).getValue(); System.out.println(登录成功重定向至: redirectUrl); } } } }关键点CSRF Token跨站请求伪造令牌是现代网站的常见安全措施。你通常需要先发起一个GET请求到登录页用Jsoup从HTML中解析出隐藏的csrf_token字段值然后再将其填入POST表单中。这是一个典型的“先GET后POST”的流程。6. 解析动态内容当Jsoup无能为力时越来越多的网站采用前端框架React, Vue, Angular构建数据通过AJAX异步加载初始HTML只是一个空壳。这时我们需要能执行JavaScript的引擎。6.1 使用HtmlUnit处理JavaScriptHtmlUnit是一个模拟浏览器的Java库可以执行JS并获取渲染后的DOM。import com.gargoylesoftware.htmlunit.WebClient; import com.gargoylesoftware.htmlunit.html.HtmlPage; import java.io.IOException; public class HtmlUnitCrawler { public static void main(String[] args) { // 创建WebClient并配置关闭JS报错日志否则很吵 try (WebClient webClient new WebClient()) { webClient.getOptions().setJavaScriptEnabled(true); // 启用JS webClient.getOptions().setCssEnabled(false); // 通常不需要CSS webClient.getOptions().setThrowExceptionOnScriptError(false); // 忽略JS错误 webClient.getOptions().setThrowExceptionOnFailingStatusCode(false); webClient.getOptions().setTimeout(10000); // 超时设置 // 获取页面HtmlUnit会等待JS执行 String url https://example-spa.com/data-loaded-by-js; HtmlPage page webClient.getPage(url); webClient.waitForBackgroundJavaScript(5000); // 等待额外JS执行单位毫秒 // 获取渲染后的HTML String pageAsXml page.asXml(); // 或者你可以继续使用HtmlUnit的DOM API进行解析 // 例如获取某个元素 // DomElement element page.getElementById(data-container); // String content element.asText(); // 也可以将HtmlPage转为Jsoup的Document进行解析更熟悉 Document doc Jsoup.parse(pageAsXml, url); // 之后就可以用Jsoup的CSS选择器了 Elements items doc.select(.dynamic-item); for (Element item : items) { System.out.println(item.text()); } } catch (IOException e) { e.printStackTrace(); } } }注意事项HtmlUnit的JS引擎不如真实浏览器完善对极其复杂的现代JS框架支持可能不完美。waitForBackgroundJavaScript的时间需要根据页面实际情况调整太短可能数据没加载完太长影响效率。HtmlUnit消耗的内存比纯HttpClientJsoup大得多需监控JVM内存使用情况。6.2 集成Selenium作为备选方案当HtmlUnit无法正确渲染页面时Selenium是最后的武器。它需要下载对应的浏览器驱动如ChromeDriver。import org.openqa.selenium.By; import org.openqa.selenium.WebDriver; import org.openqa.selenium.WebElement; import org.openqa.selenium.chrome.ChromeDriver; import org.openqa.selenium.chrome.ChromeOptions; import java.util.List; public class SeleniumCrawler { public static void main(String[] args) { // 设置ChromeDriver路径需提前下载 System.setProperty(webdriver.chrome.driver, /path/to/chromedriver); // 配置Chrome选项无头模式不显示GUI ChromeOptions options new ChromeOptions(); options.addArguments(--headless); // 无头模式 options.addArguments(--disable-gpu); options.addArguments(--no-sandbox); // Linux环境可能需要 options.addArguments(user-agentMozilla/5.0 ...); // 同样可以设置UA WebDriver driver new ChromeDriver(options); try { driver.get(https://example-spa.com/complex-page); // 显式等待直到某个元素出现 // WebDriverWait wait new WebDriverWait(driver, Duration.ofSeconds(10)); // wait.until(ExpectedConditions.presenceOfElementLocated(By.id(loaded-element))); // 简单的线程休眠等待JS加载不推荐仅示例 Thread.sleep(3000); // 获取页面源码 String pageSource driver.getPageSource(); Document doc Jsoup.parse(pageSource); // ... 使用Jsoup解析 // 或者直接使用Selenium的API查找元素 ListWebElement items driver.findElements(By.cssSelector(.dynamic-item)); for (WebElement item : items) { System.out.println(item.getText()); } } catch (Exception e) { e.printStackTrace(); } finally { driver.quit(); // 务必退出释放资源 } } }重要提醒Selenium是重量级方案会启动完整的浏览器进程资源消耗巨大内存、CPU速度慢。绝对不要在大规模并发爬虫中使用Selenium。它的定位是1) 调试复杂爬取逻辑2) 处理HtmlUnit无法解决的极少部分页面3) 需要执行复杂交互如拖动滑块验证码的场景。7. 工程化进阶构建健壮的爬虫系统单个爬虫脚本很容易写但要构建一个能持续稳定运行、易于管理和扩展的爬虫系统就需要考虑更多架构问题。7.1 任务调度与队列管理爬虫任务需要被有序地调度。我们可以使用内存队列如LinkedBlockingQueue对于分布式系统则需要消息队列如RabbitMQ, Kafka, Redis List。import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; public class UrlQueueManager { private BlockingQueueString urlQueue new LinkedBlockingQueue(); private SetString visitedUrls ConcurrentHashMap.newKeySet(); // 用于去重 public void addSeedUrl(String url) { if (!visitedUrls.contains(url)) { urlQueue.offer(url); } } public String takeUrl() throws InterruptedException { return urlQueue.take(); // 阻塞直到有元素 } public void markAsVisited(String url) { visitedUrls.add(url); } // 从已抓取页面中解析出新链接并加入队列 public void extractAndAddNewUrls(Document doc, String baseUrl) { Elements linkElements doc.select(a[href]); for (Element link : linkElements) { String newUrl link.attr(abs:href); // 获取绝对URL // 过滤只抓取特定域名下的链接避免爬出去 if (newUrl.startsWith(baseUrl) !visitedUrls.contains(newUrl)) { urlQueue.offer(newUrl); } } } }7.2 数据存储与去重对于抓取到的数据需要持久化存储。根据数据量和结构可以选择文件JSON, CSV格式适合小规模或临时存储。数据库MySQL, PostgreSQL用于存储结构化数据MongoDB用于存储半结构化或文档数据。搜索引擎Elasticsearch用于全文检索和分析。数据仓库Hive, ClickHouse用于海量数据分析。去重是另一个关键点。在内存中可以用HashSet但数据量大时不行。常用方案布隆过滤器 (Bloom Filter)一种空间效率极高的概率数据结构用于判断一个元素是否在集合中。它可能会产生误判判断存在时可能不存在但判断不存在时一定不存在适合URL去重这种可以接受极低误判率的场景。Guava库提供了实现。Redis Set将URL的MD5或SHA256哈希值存入Redis的Set中利用Redis的高性能和分布式特性。import com.google.common.hash.BloomFilter; import com.google.common.hash.Funnels; import java.nio.charset.StandardCharsets; public class BloomFilterDeduplicator { private BloomFilterCharSequence bloomFilter; public BloomFilterDeduplicator(long expectedInsertions, double fpp) { // expectedInsertions: 预期插入数量 // fpp: 误判率 (false positive probability) this.bloomFilter BloomFilter.create( Funnels.stringFunnel(StandardCharsets.UTF_8), expectedInsertions, fpp ); } public boolean isDuplicate(String url) { // 如果布隆过滤器认为可能存在则很可能是重复的有极小误判可能 // 如果认为一定不存在则肯定不是重复的 if (bloomFilter.mightContain(url)) { // 为了100%准确可以在这里加一层数据库查询确认 return true; // 大概率是重复 } else { bloomFilter.put(url); return false; // 肯定不是重复 } } }7.3 异常处理与重试机制网络请求充满不确定性必须要有健壮的异常处理和重试逻辑。import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpUriRequest; import org.apache.http.impl.client.CloseableHttpClient; public class RetryableHttpExecutor { private CloseableHttpClient httpClient; private int maxRetries; private long retryInterval; // 重试间隔毫秒 public RetryableHttpExecutor(CloseableHttpClient httpClient, int maxRetries, long retryInterval) { this.httpClient httpClient; this.maxRetries maxRetries; this.retryInterval retryInterval; } public CloseableHttpResponse executeWithRetry(HttpUriRequest request) throws Exception { int retryCount 0; while (retryCount maxRetries) { try { CloseableHttpResponse response httpClient.execute(request); int statusCode response.getStatusLine().getStatusCode(); // 可以根据状态码决定是否重试例如5xx服务器错误重试4xx客户端错误不重试 if (statusCode 200 statusCode 300) { return response; // 成功返回响应 } else if (statusCode 500) { // 服务器错误考虑重试 EntityUtils.consumeQuietly(response.getEntity()); // 消耗实体释放连接 retryCount; if (retryCount maxRetries) { Thread.sleep(retryInterval * retryCount); // 退避策略 continue; } } else { // 客户端错误如404403通常重试无意义 return response; } } catch (IOException e) { // 网络IO异常重试 retryCount; if (retryCount maxRetries) { System.err.println(请求失败进行第 retryCount 次重试。错误: e.getMessage()); Thread.sleep(retryInterval * retryCount); } else { throw e; // 重试次数用完抛出异常 } } } throw new IOException(达到最大重试次数 maxRetries); } }8. 常见问题排查与性能优化在实际开发中你会遇到各种各样的问题。这里记录一些典型问题的排查思路和优化技巧。8.1 连接池管理与资源泄漏这是Java爬虫中最常见也最致命的问题之一。症状是运行一段时间后程序变慢或抛出java.net.SocketException: Too many open files错误。排查与解决确保正确关闭响应使用try-with-resources语句确保CloseableHttpResponse被关闭从而释放底层连接。// 正确做法 try (CloseableHttpResponse response httpClient.execute(request)) { // 处理响应 }监控连接池状态可以注册PoolingHttpClientConnectionManager的ConnectionPoolStats来获取活跃连接、空闲连接等指标。设置合理的连接存活时间通过setValidateAfterInactivity设置连接在从池中取出时进行有效性检查的间隔。定期清理过期连接可以起一个定时任务调用connManager.closeExpiredConnections()和connManager.closeIdleConnections(maxIdleTime, TimeUnit.MILLISECONDS)。8.2 内存溢出 (OutOfMemoryError)长时间运行或处理大量数据时可能发生OOM。优化方向及时清理大对象处理完一个页面的HTML字符串或Document对象后及时将其引用置为null特别是循环中。限制队列大小如果使用内存队列存储待抓取URL务必设置容量上限防止内存被撑爆。调整JVM参数增加堆内存-Xmx并设置合理的垃圾回收器参数。对于爬虫这种会产生大量短生命周期对象的应用G1GC是不错的选择。流式处理数据如果抓取的数据很大避免将整个响应内容全部读入内存字符串。对于JSON或XML可以考虑使用流式API如Jackson的JsonParser边读边处理。8.3 被目标网站封禁除了使用代理IP还需要注意遵守Robots协议在爬取前检查/robots.txt尊重网站的爬取规则。可以使用robots4j这样的库来解析。设置合理的抓取延迟在请求之间加入随机间隔如1-3秒模拟人类浏览行为。Thread.sleep((long)(Math.random() * 2000 1000));降低并发度对于单个网站不要开启过多线程同时抓取。识别并处理验证码如果遇到验证码需要集成打码平台如超级鹰、图鉴的API进行识别或者考虑使用Selenium手动处理仅限低频场景。8.4 编码问题中文字符乱码是常见问题。解决方案优先使用响应头中的编码Content-Type头中的charset信息最准确。其次使用HTML元信息用Jsoup解析meta charset...或meta http-equivContent-Type content... charset...标签。最后尝试自动检测可以使用juniversalchardet等库进行编码猜测但不完全可靠。统一内部编码在内存中处理字符串时统一使用UTF-8。public static String getHtmlWithCorrectCharset(CloseableHttpResponse response) throws IOException { HttpEntity entity response.getEntity(); String charset UTF-8; // 默认 // 1. 从Content-Type头获取 ContentType contentType ContentType.getOrDefault(entity); if (contentType.getCharset() ! null) { charset contentType.getCharset().name(); } else { // 2. 如果头里没有从HTML中解析 // 需要先按默认编码读取一部分字节来探测 byte[] bytes EntityUtils.toByteArray(entity); // 注意这会消耗整个实体 String htmlGuess new String(bytes, StandardCharsets.ISO_8859_1); // 先用单字节编码读避免破坏字节 Document docGuess Jsoup.parse(htmlGuess); Element metaCharset docGuess.select(meta[charset]).first(); if (metaCharset ! null) { charset metaCharset.attr(charset); } else { Element metaContentType docGuess.select(meta[http-equivContent-Type]).first(); if (metaContentType ! null) { String content metaContentType.attr(content); // 从 contenttext/html; charsetGBK 中提取charset // 正则匹配略... } } // 用正确的编码重新构建字符串 return new String(bytes, charset); } return EntityUtils.toString(entity, charset); }构建一个稳定、高效、可维护的Java爬虫系统是一个将网络编程、多线程、资源管理、异常处理、数据结构等知识综合应用的过程。它没有银弹需要根据具体的业务场景、目标网站的特点以及资源约束不断地调整和优化。从最简单的HTTP请求开始逐步加入代理、动态渲染、队列调度、分布式存储等组件最终形成一个能够持续提供数据价值的自动化系统这个过程本身就是对Java工程师综合能力的一次极佳锻炼。