作为一个常年跟 Java 打交道的后端开发,我一直觉得“网络爬虫”这词听起来挺唬人,好像得是什么黑客级的技术。但真做起来你会发现,它本质就是三件事:把网页拿下来、把有用的信息抠出来、把结果存好。这篇文章我想用一套完整的 Java 实现,把从环境准备、技术选型到核心代码、面试考点再到线上踩坑的整个过程,一次性讲清楚。没有炫技,全是实际项目里能直接用的东西。
这套代码适合谁看?准备系统学习 Java 集合、多线程、HTTP 编程的初学者,准备 Java 面试但缺一个能当项目经验讲的实战案例的同学,以及工作中突然接到“帮我把这个网站的数据整理一份”这种需求的后端开发。我会尽量把每一步的“为什么这么写”也解释出来,因为面试官最爱的就是问这个。
1. 为什么我最终选了 Java 做网络爬虫:思路与选型
1.1 项目到底要解决什么问题
先说需求背景。我当时接到的是一个商品信息采集任务:需要定期从一个开放的商品列表页抓取标题、价格、类目和详情链接,再落到数据库里做数据分析。数据量不算大,日常维护也就几万条量级,但要求稳定、能定时跑、出了问题能快速定位。这就是非常典型的中小型爬虫场景,它不是搜索引擎那种全网爬虫,而是“定点采集”。
为什么这个场景用 Java 很合适?第一,团队技术栈就是 Java,后续要接 Kafka、ES、MySQL 这些基础设施,几乎不用写胶水代码;第二,Java 的 HTTP 生态和 JSON 序列化生态非常成熟,HttpClient、Jsoup、Jackson 组合起来,学习成本很低;第三,Java 的多线程模型在做这种 I/O 密集的抓取任务时,配合线程池能轻松把吞吐量提上去。
我知道很多人会说 Python 才是爬虫首选,Scrapy 框架也确实很强大。但如果你所在的项目组是清一色的 Spring Boot 微服务,你单独为了爬虫去引一套 Python 技术栈,后续的维护成本其实挺高的。很多大厂的采集服务反而是 Java 写的,原因不是 Java 比 Python 适合爬虫,而是它适合“融入现有工程体系”。
1.2 技术选型:HTTP 客户端、解析器、存储的取舍
网络爬虫的技术选型,核心就是三条链路:怎么发请求、怎么解析响应、怎么存结果。我逐一说说我的取舍逻辑。
HTTP 客户端。JDK 自带的HttpURLConnection虽然零依赖,但用起来真的很痛苦,尤其是设置连接超时、读取超时、自定义 Header 的时候,代码又长又容易漏。我最早入门时用过它,后来就彻底换掉了。Java 11 之后官方推出了java.net.http.HttpClient,支持 HTTP/2 和异步调用,如果不引入第三方依赖,用它也是不错的选择。但考虑到大部分教程和团队老代码都基于 Apache HttpClient 4.x,我决定用 Apache HttpClient,理由很简单:资料多、踩坑记录多、遇到问题好搜。
HTML 解析器。市面主流是 Jsoup 和 HtmlUnit。HtmlUnit 更重,它内置了完整的浏览器行为模拟,能执行 JavaScript,适合抓取动态渲染页面。但我们这次的目标页面是服务端渲染的,HTML 结构里直接就有数据,用 HtmlUnit 属于杀鸡用牛刀,还会带来巨大的性能开销。Jsoup 就刚刚好,它支持 jQuery 风格的 CSS 选择器,解析容错能力强,响应结果是 HTML 片段也能处理,这也是大多数 Java 爬虫教程选它的原因。
JSON 解析。现在很多网站的数据不是藏在 HTML 里,而是通过接口返回 JSON。项目里我统一用 Jackson,因为 Spring Boot 默认集成它,后续如果你想把采集结果直接对接项目,Jackson 的ObjectMapper就是你最熟悉的那套 API。
存储层面,这个项目用的是 MySQL,通过 JDBC 写入。如果你只是临时跑一次,直接写 CSV 文件更快;如果数据量特别大,或者要支撑搜索需求,那就得考虑 ES。但“用什么存储”应该是跟着业务走的,不要为了炫技而引入重组件。
2. 环境准备与工程搭建:JDK、Maven 和依赖
2.1 环境变量和 JDK 版本那些坑
先把环境准备好。JDK 版本我建议直接上 8 或者 11 以上的长期支持版本。为什么强调版本?因为爬虫项目经常会遇到“代码在本机能跑,一到服务器就报错”的诡异问题,八成是 JDK 版本不一致。
举个例子,如果你在pom.xml里用<properties>指定了maven.compiler.source和maven.compiler.target为 11,但本机根本没装 JDK 11,只有 JDK 17,那么编译期通常不会立刻报错,因为 javac 可以向下兼容编译到低版本字节码。可一旦用到了低版本没有的 API,运行期就会抛UnsupportedClassVersionError。所以我的建议是:确认java -version,然后在 IDEA 的 Project Structure 里把 Project SDK 和 Module SDK 都指向同一个 JDK,再用 Maven 的maven-compiler-plugin锁死目标版本。
环境变量这块也是新手重灾区。很多人配置JAVA_HOME时把路径写到了C:\Program Files\Java\jdk-17,但 PATH 里同时保留了一个旧的jre路径,导致终端里java -version显示的是旧版本。这时候最笨也最有效的排查办法,就是在命令行执行where java,它会把所有被 PATH 命中的 java 可执行文件路径列出来,逐个看是哪个版本。我在帮同事排查环境问题的时候,十次有八次都是这种多版本共存导致的混乱。
2.2 Maven 工程结构:一个爬虫模块该怎么分层
工程不需要太复杂,但包结构一定要干净。我习惯这样分:
src/main/java ├── com.example.crawler │ ├── CrawlerApplication.java // 主入口 │ ├── downloader │ │ └── HttpDownloader.java // 发送请求,拿 HTML │ ├── parser │ │ ├── PageParser.java // 解析 HTML,提取数据 │ │ └── JsonParser.java // 解析接口返回的 JSON │ ├── pipeline │ │ └── DataPipeline.java // 存储逻辑 │ ├── model │ │ └── Product.java // 数据模型 │ └── util │ └── CrawlUtils.java // 通用工具这样的分层对应了爬虫的三个核心阶段:下载、解析、存储。每层只管自己的事,后面要加代理、加深夜限速、换存储引擎,都只需要改对应模块,不用推倒重来。这其实也是面试时能讲的“面向对象设计”雏形——职责单一。
2.3 Maven 依赖清单
pom.xml里我加了这些依赖:
<dependencies> <dependency> <groupId>org.apache.httpcomponents</groupId> <artifactId>httpclient</artifactId> <version>4.5.14</version> </dependency> <dependency> <groupId>org.jsoup</groupId> <artifactId>jsoup</artifactId> <version>1.15.4</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.15.2</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.28</version> <scope>provided</scope> </dependency> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-api</artifactId> <version>2.0.7</version> </dependency> </dependencies>注意,Lombok 的scope如果没设置成provided,打出来的 fat jar 里会包含完整 Lombok 注解处理器,偶尔会引发一些诡异的运行期问题。而且 Lombok 对 JDK 版本比较敏感,新版 JDK 必须搭配新版本 Lombok,不然启动时会直接报“Lombok will not work”之类的大红字——这个我后面在踩坑章节里详细讲。
3. 核心功能拆分与逐模块实现
3.1 请求模块:带 Header、超时和重试
请求模块是整个爬虫的地基。如果连页面都拿不到,后面的解析无从谈起。我先说一个最基本的 HttpClient 封装。
public class HttpDownloader { private static final CloseableHttpClient HTTP_CLIENT; static { // 连接池管理 PoolingHttpClientConnectionManager connManager = new PoolingHttpClientConnectionManager(); connManager.setMaxTotal(100); connManager.setDefaultMaxPerRoute(50); RequestConfig config = RequestConfig.custom() .setConnectTimeout(3000) .setSocketTimeout(5000) .setConnectionRequestTimeout(3000) .build(); HTTP_CLIENT = HttpClients.custom() .setConnectionManager(connManager) .setDefaultRequestConfig(config) .setRetryHandler(new DefaultHttpRequestRetryHandler(2, true)) .build(); } public String getHtml(String url) throws IOException { HttpGet httpGet = new HttpGet(url); httpGet.setHeader("User-Agent", "Mozilla/5.0 ... Chrome/120.0"); httpGet.setHeader("Accept-Language", "zh-CN,zh;q=0.9"); try (CloseableHttpResponse response = HTTP_CLIENT.execute(httpGet)) { int status = response.getStatusLine().getStatusCode(); if (status != 200) { throw new IOException("HTTP status code: " + status); } return EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); } } }这里最容易被新手忽略的是静态块里的连接池管理。如果你每次都new CloseableHttpClient,爬几十个页面就会把系统文件句柄耗尽,症状就是运行一段时间后突然SocketException: Too many open files。用连接池复用 TCP 连接,既省去了反复三次握手的时间,又能稳定控制连接数。
超时设置也值得展开。connectTimeout是建立 TCP 连接的超时时间,socketTimeout是等待对方返回数据的超时时间。有的网站接口不稳定,偶尔 10 秒都不回数据,如果你 socketTimeout 设太短,会误杀正常请求;设太长,整个任务会被拖死。我一般先用 5 秒跑一轮,观察 p99 响应时间再调整。
重试机制要谨慎。DefaultHttpRequestRetryHandler默认对IOException重试,但它不区分“连接被拒绝”和“请求已发出但响应丢失”。如果是后者,重试可能导致业务侧重复数据。我的处理办法是:在下一层做幂等去重,而不是完全禁止重试。
3.2 解析模块:Jsoup 选择器与数据抽取
拿到 HTML 源码之后,就要用 Jsoup 把它解析成可操作的元素树了。Jsoup 最舒服的一点是它支持 CSS 选择器,写过前端的人几乎零成本上手。
假设我们要抓的页面是这样一个结构:
<div class="product-item"> <a href="/detail/1001" class="title">无线鼠标</a> <span class="price">39.90</span> </div>那么解析代码就是:
public List<Product> parseHtml(String html) { List<Product> products = new ArrayList<>(); Document document = Jsoup.parse(html); Elements items = document.select("div.product-item"); for (Element item : items) { Product product = new Product(); String title = item.selectFirst("a.title").text(); String priceText = item.selectFirst("span.price").text(); String detailUrl = item.selectFirst("a.title").attr("href"); product.setTitle(title); product.setPrice(new BigDecimal(priceText)); product.setDetailUrl(detailUrl); products.add(product); } return products; }这段代码看起来简单,但里面有不少实战经验。
第一,尽量用selectFirst而不是select(...).first(),因为selectFirst在找不到目标时会返回null,而select返回的是空列表,后者不容易暴露空指针问题。拿到null之后你要主动判断,否则调用.text()就直接 NPE。
第二,解析价格时不要直接把字符串set进 BigDecimal 字段。页面上的价格可能是¥39.90,也可能是39.90 起,必须先做清洗,把多余字符去掉。写一个CrawlUtils.cleanPrice(String)方法,用正则提取数字部分,这类坑会少很多。
第三,Jsoup 解析本身是容错的,但如果你用Jsoup.parse解析的是不完整的响应体(比如网络截断的半截页面),DOM 树会缺节点。这时候最直接的验证办法是document.title()和document.select(...).size(),快速判断是否拿到了预期元素。
3.3 存储模块:脱敏入库和去重
爬虫的存储层往往是被低估的。很多人觉得拿到数据就万事大吉,随便 insert 一下完事。但真实场景里,数据质量才是决定一个采集系统能不能上线运行的关键。
我在这个项目里的存储层做了三件事:
第一,字段脱敏与标准化。比如价格统一保留两位小数、空字符串转null、URL 统一补齐协议头。这样后续做数据分析时不需要反复清洗。
第二,幂等写入。用业务唯一键(比如详情页 URL)在表里建唯一索引,写入时先select判断是否存在,存在就update,不存在才insert。防止重试机制带来重复数据。
第三,分批批量插入。不要一条一条 insert,几千条数据逐条提交,时间和数据库连接开销都很高。用 JDBC 的addBatch和executeBatch,或者直接拼多值 SQL,插入效率能提升一个数量级。
public void saveProducts(List<Product> products) { if (products == null || products.isEmpty()) { return; } String sql = "INSERT INTO product (title, price, detail_url, created_at) VALUES (?, ?, ?, NOW()) " + "ON DUPLICATE KEY UPDATE title = VALUES(title), price = VALUES(price)"; // 通过 Connection.prepareStatement(sql) 循环执行 }我特别想提醒一句:任何来自网页的数据都不可信。爬虫拿到的字符串里可能藏着 SQL 注入片段、隐藏字符、HTML 标签残留,所以存库前一定要转义处理,至少要用PreparedStatement参数化查询,禁止直接拼接 SQL。
3.4 多线程采集:线程池与流量控制
单线程抓取一个几百页的列表,耗时太长,用户体验很差。Java 里做并发采集最合理的方式就是线程池。但多线程不是简单地Executors.newFixedThreadPool(10)就完事了,你得控制队列大小和拒绝策略,还要考虑对目标网站的压力。
我的做法是:
ThreadPoolExecutor executor = new ThreadPoolExecutor( 8, // corePoolSize 16, // maximumPoolSize 60, TimeUnit.SECONDS, // 空闲线程存活时间 new LinkedBlockingQueue<>(100), // 等待队列 new ThreadPoolExecutor.AbortPolicy() // 拒绝策略 );核心线程数为什么设 8?因为这是一台 4 核 8 线程的服务器,I/O 密集型任务的核心线程数可以设到 CPU 核心数的 2 倍甚至更高。但也不能无脑高,爬虫的瓶颈更多在目标网站,你需要估算对方能承受的 QPS。
这里有个容易被忽略的点:线程池里跑的任务不能只是“抓取”这一件事,否则响应解析和入库会抢占抓取线程的时间。更合理的做法是抓取线程把 HTML 丢到一个内存队列,由独立的消费者线程负责解析入库。这就是经典的“生产者-消费者模式”,也是面试里最喜欢追问的场景。用 Java 的LinkedBlockingQueue或者Disruptor都可以,小项目用前者就够。
采集过程中建议加个AtomicInteger计数器,每完成一个任务就自增,配合日志打印进度。看到任务跑完,别急着关进程,先确认消费队列已经清空。线程池shutdown()之后要用awaitTermination等一会儿,否则数据会丢。
4. 从爬虫看 Java 基础:面试题与八股文的实际落地
4.1 面向对象与策略模式在爬虫里的应用
说到 Java 面试题,第一个绕不开的就是面向对象。很多人在学校背了一堆“封装、继承、多态”的概念,但问“你项目里哪里用了多态”就哑火了。爬虫项目恰好多态最好的练兵场。
我在设计请求模块时,定义了一个顶层接口:
public interface Crawler { List<Product> crawl(String url) throws Exception; }然后为不同网站提供不同实现类:BookStoreCrawler、ElectronicsCrawler、DefaultCrawler。每个实现类负责自己的解析逻辑,主控程序只要根据站点类型,从 Map 里取对应的实现去执行。这就是策略模式,也是一种开闭原则的实践:以后要加新站点,只需要新增实现类,不用改已有代码。
再比如HttpDownloader里可以定义抽象方法buildHeaders(),子类返回各自的 Header 集合。有的站点需要自定义 Referer,有的需要带Accept: application/json,这些差异通过继承关系隔离开,主流程完全不用关心。
如果面试官问你“面向对象的三大特性在项目里怎么体现”,你就可以拿这个例子讲:封装体现在HttpDownloader把连接池、超时配置、重试逻辑都隐藏起来;继承体现在不同站点的 Crawler 继承同一个抽象基类;多态体现在主程序面向接口编程,运行时再决定用哪个实现。
4.2 lambda、流和 Comparator 在数据处理中的应用
爬虫的解析阶段会产生大量中间集合,用 Java 8 的 Stream 处理会非常舒服。比如我需要过滤出价格大于 50 的商品,再按价格排序取前 10 个:
List<Product> result = products.stream() .filter(p -> p.getPrice() != null && p.getPrice().compareTo(new BigDecimal("50")) > 0) .sorted(Comparator.comparing(Product::getPrice).reversed()) .limit(10) .collect(Collectors.toList());这段代码一写出来,就同时用到了 lambda 表达式、方法引用、Stream 流、比较器这些高频考点。
关于Comparator还有一个非常实用的技巧——把某个值排到最前面。比如你想把“有库存”的商品排前面:
products.sort( Comparator.comparing(Product::getStock, Comparator.reverseOrder()) .thenComparing(Product::getPrice) );或者更直白一点,想把状态为“推荐”的商品提到第一位,可以这样写:
products.sort(Comparator.comparing((Product p) -> "推荐".equals(p.getTag()) ? 0 : 1) .thenComparing(Product::getPrice));这种写法的思路是:把业务条件映射成排序权重,再组合第二排序字段。面试官看到你在实际项目里能灵活用Comparator.comparing,比背十道八股文都管用。
4.3 异常处理与数组越界
爬虫项目是异常重灾区,因为网络请求和外部数据有太多不确定性。异常处理的核心原则是:不要吞异常,也不要裸抛异常。
try-catch的正确姿势是捕获后分类处理。比如解析过程中遇到IndexOutOfBoundsException,说明页面结构可能变了;遇到NullPointerException,说明某个元素没匹配到。我在代码里会把异常包装成自定义的CrawlException,带上 URL 和阶段信息,这样排查问题时能一眼看出是哪个环节挂了。
try { List<Product> products = parser.parseHtml(html); } catch (NullPointerException e) { throw new CrawlException("解析页面出错,URL: " + url + ", 原因: 缺少关键元素", e); }数组越界这个话题,在 Java 面试题里的出现频率极高。面试官通常会问“ArrayIndexOutOfBoundsException 是什么,什么时候会发生”。我在爬虫里的真实场景是:解析某个详情页时,用split(":")切分字符串,结果某一行数据格式变了,切出来只有一段,访问下标 1 就直接越界。所以现在的习惯是,split之后先判断length,再决定是否取值。
4.4 枚举和常用类的小场景
Java 枚举类型在爬虫项目里也有用武之地。比如我要区分采集状态,定义枚举:
public enum CrawlStatus { PENDING("待处理"), SUCCESS("成功"), FAILED("失败"), RETRY("待重试"); private final String desc; CrawlStatus(String desc) { this.desc = desc; } }用它管理状态机,比用一坨魔法数字清晰得多,也方便后续在switch里做分支处理。
常用类这块,String绝对是最值得熟练掌握的。爬虫里的字符串操作非常多:URL 拼接、去空白、替换非法字符、判断前缀后缀。比如京东商品链接的skuId提取,我一般用String.substring配合indexOf加上正则兜底,而不是笨拙地去 split 整个 URL。
5. 踩坑实录:编译错误、内存问题与线上事故
5.1 源发行版 17 需要目标发行版 17
这是我在搜索引擎里看到频率最高的一条 Java 报错,也是新手最常遇见的问题。它长这样:
java: 警告: 源发行版 17 需要目标发行版 17报错的本质是:JDK 17 编译时,source和target版本设置不一致或没设置。比如maven-compiler-plugin默认的source是 1.8,但 IDE 的 Project SDK 是 17,编译时就容易出现“source release 8 requires target release 8”这类提示。
解决思路很简单。在pom.xml的<properties>里明确统一:
<properties> <maven.compiler.source>11</maven.compiler.source> <maven.compiler.target>11</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties>然后确保 IDEA 的 Project SDK 和此处的 11 一致。如果你真的想用 JDK 17 的新特性,比如 switch 表达式模式匹配,那就把上面两个值改成 17,同时确认部署环境的 JDK 也必须是 17。
5.2 Lombok 编译报错:Lombok will not work
Lombok 是很多 Java 项目的标配注解库,但它有个特点:必须和当前 JDK 的编译版本兼容。如果你的项目从 JDK 8 升级到 JDK 17,而 Lombok 还停留在 1.16.x,编译时会直接提示 Lombok 无法工作,甚至 IDE 里大量getter/setter直接标红。
遇到这问题不要慌,升级 Lombok 版本到对应新版本即可。JDK 17 我试过 1.18.24 以上版本,都没问题。我自己的项目现在用的是1.18.30,跑在 JDK 17 上很稳。
还有一个相关的小坑:如果你用了@Data注解,但字段里包含BigDecimal这类不可变类型,Lombok 生成的setter不会做特殊处理,后续在业务代码里修改字段时要注意不可变性的一致性。这类问题面试不会问,但实际开发里会烦你一下。
5.3 OutOfMemoryError: insufficient memory
爬虫项目跑到一半突然OutOfMemoryError: insufficient memory,这几乎是必经之路。原因通常是:
第一,并行抓取时把响应体全部放在内存里。如果一次跑 50 个线程,每个页面响应 2MB,瞬间就是 100MB 内存。解决办法是控制线程池大小、解析完立刻释放引用。
第二,存储队列堆积。生产者抓取速度远大于消费者入库速度,内存队列里堆了几百万个对象。这时候要想着用有界队列,满了让生产者阻塞等待,而不是无限堆积。
第三,保存页面快照时太大。为了排查问题,我习惯在本地存一份 HTML 快照,但一次采集几十万页,直接把文件系统都撑爆。后来改成只保存失败页面的快照,成功的不落盘。
解决 OOM 的实战步骤是:先用jstat -gcutil <pid>看 GC 情况,再用jmap -dump:format=b,file=heap.bin <pid>抓堆转储文件,最后用 MAT 分析是哪个对象占了大头。如果分析完发现是 Jsoup 的Document对象堆积,那就考虑用Jsoup.parse(html)后立刻把不需要的字段清理掉,或者改用 SAX 流式解析。
5.4 编码、超时和反爬的日常问题
编码问题最阴间。很多老网站是 GBK 编码,不是 UTF-8。StringEntity和EntityUtils.toString(response.getEntity(), "UTF-8")如果写死了 UTF-8,拿到 GBK 页面就是满屏乱码。Jsoup 有一个友好的处理方式:Jsoup.parse(html, charset),它会在解析时识别 meta charset。但如果你在 HttpClient 层就把字节流转成了错误编码的字符串,那就彻底没法救回来了。正确写法是先取到原始字节流InputStream,再交给 Jsoup 用InputStream解析:
Document doc = Jsoup.parse(response.getEntity().getContent(), "UTF-8", url);超时问题前面提过,这里补充一个排查技巧:如果总在固定的第 N 个请求后超时,大概率是触发了目标网站的限流策略。此时不要想着通过调大超时来硬扛,应该降低抓取频率或换采集时间段。
反爬是爬虫绕不开的话题,但我必须强调一点:一切采集行为都应该遵守目标网站的 robots 协议和服务条款,只采集公开数据,不要攻击接口、不要绕过身份验证、不要高频请求影响对方服务。我一般会给程序加入随机延时:
Thread.sleep(ThreadLocalRandom.current().nextLong(1000, 3000));这么做不是为了“破解反爬”,而是模拟正常用户的访问节奏,降低对目标服务的压力。合理合规的采集,控制频率、控制并发、保留原始来源,这些是基本功。
6. 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 编译报错“源发行版 17 需要目标发行版 17” | maven compiler 与 JDK 版本不一致 | 统一 pom.xml 的 source/target 与 JDK 版本 |
| Lombok 注解不生效,IDE 标红 | Lombok 版本与 JDK 不兼容 | 升级 Lombok 到 1.18.24 以上 |
| 运行一段时间后 OOM | 线程池过大、队列堆积、引用未释放 | 检查堆转储,控制并发,使用有界队列 |
| 页面解析结果为 null | 页面结构变化或未找到元素 | 先用document.select(...).size()验证选择器 |
| 入库出现乱码 | 源码编码与解析编码不一致 | 从 InputStream 层传编码给 Jsoup,不要先转字符串 |
| 抓取一段后全部超时 | 触发限流,或连接池连接被回收 | 降低频率,检查连接池配置,设置重试 |
| 数据库出现重复数据 | 重试、多线程重复消费 | 建立唯一索引,写入时做幂等 |
频繁Too many open files | 每请求都 new 客户端,未关闭连接 | 使用连接池并复用 HttpClient |
除了表格里这些,我再补一个独家小技巧:日志必须带上 URL 和任务 ID,不然排查大量并发采集时的报错,你会被一坨无上下文的异常栈搞疯。我习惯用 MDC 或者简单的log.info("task={}, url={}, status={}", taskId, url, status),检索起来极快。
另外,IDEA 里本地调试爬虫时,如果用了多线程,建议把线程数调低到 2-3 个,不然断点会切到你怀疑人生。
结尾:一点个人经验
做爬虫项目这些年,我最深的体会是:技术难度其实不在“怎么把数据抓下来”,而在“数据抓下来之后怎么保证不出问题”。同样一段 Jsoup 解析代码,今天能跑,明天可能因为网站改版就崩了;今天几百条数据正常入库,明天数据量翻十倍可能就 OOM 了。所以我在这个项目里特意把所有可能变化的点都做成了配置:线程池大小放配置文件、选择器放常量类、目标站点 URL 放数据库表。每次网站改版,我只改选择器常量,主流程代码基本不动。
最后分享一个小技巧,也是帮了我大忙的:采集完成后,最好把每次任务的执行结果做一份统计报表,记录成功数、失败数、平均耗时、最大耗时。这不仅是给领导看的日报,更是你自己判断“网站是否反爬、服务器是否过载、代码是否退化”最直接的依据。网络爬虫从来不是一次性活,它是需要长期维护的小工程——把代码写得简单、清晰、可配置,比任何花哨技巧都重要。