1. 我为什么用Java写爬虫,而不是Python
说到爬虫,很多人下意识就想到Python。这个问题我几乎每次分享都会被问到,尤其在团队内部做技术选型的时候。我的答案其实很简单:爬虫的核心从来不是语言,而是抓取、解析、存储和调度这套链路。Python在快速原型上有优势,但Java在工程化、并发控制、系统稳定性上更贴合我所在的业务环境。
我第一次做Java爬虫是在处理一批电商数据的时候。当时团队已有的技术栈全是Java,数据服务、管理后台、定时调度都跑在Spring Boot上。如果为了爬虫单独引一套Python服务,就得额外维护脚本环境、部署链路和数据交接逻辑。用Java直接嵌进现有工程里,一条数据流从抓取到入库不需要跨语言,出了问题也能直接复用团队熟悉的排查手段。
另一个让我坚定用Java的场景是并发。爬虫做到后面一定绕不开多线程,Java的线程池、并发容器、原子类这些工具,在控制抓取速率和资源消耗方面非常顺手。你可以在几个文件里就实现一套带速率限制、队列缓冲的多线程抓取框架,而不用额外引入分布式任务系统。
当然,我不否认Python在某些场景下更香。比如只是想验证一个网站的数据结构,用requests加解析库几行代码就能出结果;再比如要做复杂的文本清洗,Pandas和正则配合起来很顺。所以这篇文章不是劝你放弃Python转Java,而是把Java这套方案的思路、选型和细节讲透,供那些已经在Java体系里工作、想自己动手做数据采集的朋友参考。
2. 技术选型:HttpClient、Jsoup还是WebMagic
很多新手问我第一步应该学什么框架,我的建议是不要一上来就套大而全的框架,先把底层组件用明白。Java爬虫生态里绕不开几个名字:Apache HttpClient、OkHttp、Jsoup、WebMagic、HtmlUnit,再加上一个Selenium。它们各自解决的问题不一样,用错位置会非常难受。
2.1 三个核心依赖的定位差异
Apache HttpClient / OkHttp负责HTTP通信,也就是把网页内容拿回来。HttpClient的老版本我记得是4.x,现在稳定分支是5.x,API有所调整,不过核心概念没变:连接池、请求配置、cookie存储、重定向策略。OkHttp是另一个优秀选择,它的连接池和HTTP/2支持很好,API也更现代。我的习惯是:简单抓取用OkHttp,项目里需要细粒度控制超时、重试和连接池参数时,直接用HttpClient 5.x。
Jsoup是HTML解析库,负责从拿回来的HTML里提取数据。它支持CSS选择器,也支持部分XPath语法,还有一套类似jQuery的API。Jsoup最强大的一点是能够自动修正不规范的HTML,很多抓下来的网页标签是嵌套错乱的,拿正则去匹配会痛不欲生,但Jsoup能把它解析成一棵干净的DOM树。它的底层是基于Java的Sax解析器改进而来,所以性能和内存控制都不错。
WebMagic是国内开发者开源的一个爬虫框架,把整个爬虫流程抽象成Downloader、PageProcessor、Scheduler、Pipeline四个组件。它上手快,写一个爬虫只需要实现一个PageProcessor接口。我自己会用WebMagic做原型验证,但落到生产环境时,我往往还是会把它拆开,因为它对请求头的自定义、连接池的性能调优不如直接用HttpClient灵活。框架帮你封装了流程,也同时把一些可调空间给遮住了。
2.2 推荐组合:HttpClient 5 + Jsoup + MyBatis-Plus
我目前在正式项目里用的组合是HttpClient 5负责抓取,Jsoup负责解析,MyBatis-Plus负责持久化。为什么不用WebMagic全家桶?因为我的抓取逻辑并不总是“一个URL进、一个页面出”,有时需要先调接口拿JSON,再根据JSON里的字段拼接下一个地址;有时要处理分页、异步加载、登录态。场景复杂之后,一个松耦合的组合反而更好扩展。
用Maven引入依赖时,需要注意版本兼容。先给个基础的pom片段:
<dependencies> <dependency> <groupId>org.apache.httpcomponents.client5</groupId> <artifactId>httpclient5</artifactId> <version>5.3.1</version> </dependency> <dependency> <groupId>org.jsoup</groupId> <artifactId>jsoup</artifactId> <version>1.17.2</version> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.7</version> </dependency> </dependencies>这里特别提醒一个细节:HttpClient 4.x和5.x的包名不一样,4.x是org.apache.http,5.x是org.apache.hc.client5。很多老博客里的代码直接迁移过来会报找不到类。你如果搜到的是4.x的教程,要么按它的版本继续用,要么把import全部改掉,别混着写。
2.3 Selenium到底要不要用
遇到页面内容是JavaScript动态渲染的,光靠HttpClient抓HTML是抓不到的。这时你拿到的HTML里只有一堆<script>标签,或者是空的<div id="app">,真正的内容是浏览器执行完JS后才出现的。这种情况有三个解法:
- 找接口:很多前端框架的数据都来自JSON接口,直接在浏览器开发者工具里找到XHR请求,用HttpClient模拟那个接口就行。
- 用无头浏览器:Selenium或Playwright启动一个无头Chrome或Firefox,让浏览器真实渲染后再提取。
- 用HtmlUnit模拟浏览器环境:它能执行一部分JavaScript,但渲染能力有限。
我的原则是“能走接口绝不上浏览器”。无头浏览器非常吃内存,一个Chrome实例动辄几百MB,并发一多服务器就扛不住。Selenium适合抓取量小、交互复杂的页面,比如需要点击、滚动、拖拽才能加载更多内容的站点。在前面的项目里,我在一个航班数据采集模块用了Selenium,最后把并发数压到了4个实例,每天只跑增量任务,才勉强控制住资源占用。
3. 从零实现一个可扩展的Java爬虫骨架
下面我把一个正经可运行的Java爬虫骨架拆开讲。它不是Hello World那种demo,而是能支撑几十万级数据量、带多线程和断点续抓的最小实现。
3.1 请求封装:让每个请求看起来都像真人
网站反爬的第一道防线就是看你的请求头。如果一段Java代码发出的请求没有User-Agent,或者User-Agent固定不变,很容易被识别。我通常会维护一个UA池,并构造一个完整的请求上下文。
private static final String[] USER_AGENTS = { "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1" }; private CloseableHttpResponse fetch(String url, Map<String, String> headers) throws IOException { try (CloseableHttpClient client = HttpClients.custom() .setDefaultRequestConfig(RequestConfig.custom() .setConnectTimeout(Timeout.ofSeconds(5)) .setResponseTimeout(Timeout.ofSeconds(10)) .build()) .setConnectionManager(PoolingHttpClientConnectionManagerBuilder.create() .setMaxConnPerRoute(20) .setMaxConnTotal(100) .build()) .build()) { HttpGet request = new HttpGet(url); request.setHeader("User-Agent", USER_AGENTS[ThreadLocalRandom.current().nextInt(USER_AGENTS.length)]); request.setHeader("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"); request.setHeader("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8"); if (headers != null) { headers.forEach(request::setHeader); } return client.execute(request); } }这里有个容易踩的坑:很多人在for循环里每次new一个HttpClient,这是极其浪费资源的。HttpClient内部维护了连接池,你应该在整个应用生命周期里复用同一个实例。上面这段为了示例简化了,在实际项目里我会把CloseableHttpClient做成Spring Bean的单例,连接池参数在配置文件中管理。
Cookie的处理也很关键。有些站点的列表页不需要登录,但详情页要登录态。一个方案是用HttpClient的BasicCookieStore来保存登录后返回的Cookie,后续请求共用这个store。在调用登录接口后,把响应里的Set-Cookie塞进store,再把这个store绑定到后续请求的上下文里。这样一路请求下来,服务端的session就不会丢。
3.2 页面解析:Jsoup的三种常用姿势
拿到HTML之后就是解析环节。Jsoup支持CSS选择器、XPath和正则三种方式,但实用建议是优先用CSS选择器,内部实现效率高、写法直观,可以解决的场景超过90%。
假设我们要抓一个商品列表页,每条商品的结构大概是<div class="item"> <a class="title">手机</a> <span class="price">2999</span> </div>,用Jsoup写起来是这样:
Document doc = Jsoup.parse(html); Elements items = doc.select("div.item"); for (Element item : items) { String title = item.selectFirst("a.title").text(); String price = item.selectFirst("span.price").text(); String link = item.selectFirst("a.title").attr("href"); System.out.println(title + " | " + price + " | " + link); }CSS选择器在Jsoup里能完成#id、.class、div > a这种常用的定位。但有时某个标签没有合适的class或id,这时可以用XPath来兜底。感谢热词提醒,我专门提一下XPath里的text()函数——它在提取“包含特定文字的元素”时很常用。比如想找到页面上“库存紧张”这几个字所在的节点:
Elements stockWarnings = doc.selectXpath("//span[contains(text(),'库存紧张')]");注意,Jsoup的XPath支持是通过selectXpath方法提供的,内部依赖的语法完整度略弱于标准XPath 2.0,但常用的//、[]、contains、text()都是支持的。遇到复杂XPath报错时,我一般会退回两步:先用selectXpath定位外层容器,再用CSS选择器往下找具体元素,把两种方式混用,解析逻辑会清晰很多。
再说一个经验:不要用正则去解析HTML。正则处理嵌套标签是个无底洞,尤其是在页面结构发生变化时,正则会瞬间变成“玄学”。Jsoup把HTML解析成DOM,你再通过选择器取内容,即使页面结构微调,大多数情况下代码还能跑,但正则往往一改版就崩。
3.3 数据落库:MyBatis-Plus根据实体类生成建表SQL
抓下来的数据最终要落库。我在Spring Boot项目里用的持久层是MyBatis-Plus。热词里有一个很典型的场景:根据Java实体类生成创建表的SQL语句,这个需求做数据采集时特别常见。你不想手写一大堆DDL,又希望表结构能和实体类保持同步。
先说通用做法:MyBatis-Plus自带一种基于实体类的代码生成器,它生成的不是SQL,而是数据库表对应的实体类。但反过来的需求——有了实体类,自动建表——MyBatis-Plus原生并不直接支持,需要借助一个叫TableInfoHelper的内部机制。不过更实用的方案是配合Flyway或Liquibase这类数据库迁移工具,同时结合实体类上的注解写一份标准SQL。
举个例子,一个商品实体类:
@Data @TableName("t_product") public class Product { @TableId(type = IdType.AUTO) private Long id; @TableField("sku_code") private String skuCode; @TableField("title") private String title; @TableField("price") private BigDecimal price; @TableField(value = "create_time", fill = FieldFill.INSERT) private LocalDateTime createTime; }对应的建表SQL可以这样写:
CREATE TABLE t_product ( id BIGINT AUTO_INCREMENT PRIMARY KEY, sku_code VARCHAR(64) NOT NULL COMMENT '商品SKU编码', title VARCHAR(512) NULL COMMENT '商品标题', price DECIMAL(10,2) NULL COMMENT '商品价格', create_time DATETIME NULL COMMENT '创建时间', UNIQUE KEY uk_sku_code (sku_code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品信息采集表';这里有两个容易踩的坑。第一,MySQL的utf8mb4不是默认的,如果你的库表还是utf8,遇到emoji表情或者一些生僻字会直接报错或变成问号,所以建表语句里一定要写清楚CHARSET=utf8mb4。第二,商品标题字段务必给足长度,很多电商标题实际有六七十个字符,VARCHAR(255)都常有不够用的时候,别以为标题短。
还有一个思路是使用MyBatis-Plus 3.5.x的TableInfoHelper去解析实体字段,然后拼DDL。我写过一段工具代码,遍历实体类的字段注解,自动拼出建表语句,好处是表结构跟着代码走,坏处是复杂的索引、外键、分区没法覆盖。最终我选择的是实体类负责描述字段,Flyway负责管理版本化的SQL脚本,既能在实体上加注释做ORM映射,又能让数据库结构变更变得可追溯、可回滚。
3.4 多线程抓取:线程池与并发控制
单线程爬虫的问题不是慢,而是被反爬策略盯上后请求全部失败。所以设计多线程时,核心不只是“并发”,而是“可控的并发”。
我建议用ThreadPoolExecutor自定义一个线程池,设置核心线程数、最大线程数、队列容量,并用有界队列防止任务堆积把内存撑爆:
private final ThreadPoolExecutor executor = new ThreadPoolExecutor( 8, 16, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(1000), new ThreadFactory() { private final AtomicInteger count = new AtomicInteger(0); @Override public Thread newThread(Runnable r) { return new Thread(r, "crawler-worker-" + count.incrementAndGet()); } }, new ThreadPoolExecutor.CallerRunsPolicy() );CallerRunsPolicy很关键。当队列满了,新任务不会丢弃,而是由提交任务的线程自己执行,相当于自动减速。这样在目标网站响应变差、抓取速度下降时,生产者线程会被迫参与执行,整体的请求频率就会自然降下来,起到了流控作用。
同时,每个线程处理完一个页面后,要随机sleep一段合理的时间。这个时间通常取200到1500毫秒。不是固定值,而是随机值,因为固定间隔本身就是一种明显的机器痕迹。我还会在每个请求之前加一个轻量级的分布式锁或者信号量来限制单位时间内的请求数,比如每秒钟最多5个请求,避免把目标站打挂。这是个从业者基本的职业道德问题,也是防止自己IP被封的自我保护。
4. 反爬对抗:Java侧的应对方案
爬虫和反爬就像是攻防演练,你做采集就必须理解对方为什么反爬。一方面是为了保护数据资产,另一方面是防止爬虫拖垮服务器。我在这部分讲几个正经可用的对抗思路。
4.1 请求头伪装与随机延迟
前面已经提到了User-Agent池,这里再深入一点。很多站点的反爬策略并不只看UA,而是综合判断多个维度:UA是否常见、Accept头是否齐全、HTTP版本是否合理、请求频率是否异常、Cookie是否稳定。
我常用的一个策略是“指纹一致性”。不要让UA是Chrome的,但Accept-Language是英文,这种矛盾组合容易触发风控。更保险的做法是打开浏览器开发者工具复制一份真实的request header,把UA、Accept、Accept-Language、Referer、Sec-Fetch-*这些字段在代码里原样保留。Referer尤其容易被忽略,很多详情页会校验这个头,空Referer的请求直接404。
随机延迟要放在整个流程的设计里,不是每个请求后简单sleep一下。我建议设计成“一组请求一个节奏”:比如这个页面有20条链接,我会先抓列表页,然后逐条抓详情页,但详情页之间的间隔使用一个指数范围,比如1到3秒随机,并且在一个批次结束后休息5秒左右。这种节奏比固定间隔更接近人的操作。
4.2 登录态与验证码
需要登录才能看到的数据,爬虫就要多走一步。常规方案是把登录接口调用一次,拿到Cookie里的Session ID,后续请求带着这个Cookie走。Java里用BasicCookieStore可以维护这个状态,还可以配合持久化把Cookie序列化到本地,避免每次重启程序都要重新登录。
验证码是另一个话题。常见的图形验证码可以通过OCR识别简单的数字字母,但现在的验证码越来越复杂,滑块、点选都可以通过行为轨迹判断机器。我的建议是优先规避验证码:控制请求频率、延迟波动、尽可能晚触发风控。如果不得不打验证码,可以接第三方打码平台,但这种方式有成本,而且你仍然要解决账号被封的隐患。
给一个更工程化的建议:把登录凭证和Cookie配置化,不要写死在代码里。我们在项目里把Cookie放在数据库或配置中心里,启动时读取,运行时定时刷新。这样某个账号失效时,可以在不重启爬虫的情况下动态替换。
4.3 JS渲染页面的处理方案
前面提到Selenium。这里补充一个更轻的替代方案:用CDP(Chrome DevTools Protocol)直接操作无头浏览器。Java生态里有一个库叫org.openqa.selenium:selenium-java,它底层的driver就是通过CDP协议和浏览器通信的。如果想再轻一点,可以考虑Browserless这类远程无头浏览器服务,把渲染压力从本地剥离。
不过,在走渲染这条路之前,请先认真抓包找接口。我遇到过一个旅行网站,页面本身的HTML确实是空的,但抓包发现它调用了/api/flights?from=...&to=...这样的接口,返回的是标准JSON。我直接用HttpClient模拟那个接口,带好必要的header和签名参数,轻松拿到了数据,完全不需要渲染。后来看了一下它的接口参数,发现有一个_signature字段,是前端通过某个算法算出来的,我在调试时就照抄了一次,后面动态生成的签名才慢慢补全。
所以拿到动态页面时,我的检查顺序是:先看XHR请求,再看WebSocket,最后才考虑无头浏览器。这条顺序能帮你省掉大量资源。
4.4 URL去重与断点续抓
爬虫最怕两件事:重复抓和中断重来。如果不对URL做去重,同一个商品可能被几十个线程同时抓,浪费带宽,还给对方服务器添麻烦。Java里最简单的去重是HashSet,但在大数据量下内存扛不住,而且进程重启就全丢了。我推荐用Redis的Set或布隆过滤器做去重。
布隆过滤器是一个很划算的选择:它用多个哈希函数判断一个URL是否“可能在集合中”,存在一定的误判率,但空间占用小。用Redisson的RBloomFilter实现几行代码就能跑起来。
断点续抓的核心是把任务状态持久化。我们可以在数据库里建一张抓取任务表,记录每个URL的状态:PENDING、SUCCESS、FAILED。启动时把所有FAILED和未完成任务重新捞出来,丢进队列继续抓。配合上面说的线程池,整个爬虫就有了基本的可靠性。注意任务表要加唯一索引,防止同一批URL被多个节点重复捞出来。
5. 落地过程中踩过的坑与排查思路
这部分的每一个坑,我都见过身边的人踩过,有的还踩了不止一次。写出来希望你能避开。
5.1 字符编码:乱码的根源
Java里最常见的问题就是乱码。目标网页如果不声明编码,或者声明和实际不一致,Jsoup解析出来就是满屏的问号或者方块。我建议抓回来后第一件事就是用工具或代码打印实际的编码。
判断编码有两个入口:一个是HTTP响应头里的Content-Type,比如text/html; charset=utf-8;另一个是HTML页面<head>里的<meta charset="...">。两者冲突时,以HTTP头为准,但也可能是错的。保险做法是用一个库自动检测编码,比如juniversalchardet,或者结合作者经验判断:中文站点常用的是UTF-8、GB2312、GBK。
在Jsoup解析时手动指定编码也很简单:
Document doc = Jsoup.parse(inputStream, "UTF-8", url);第二个参数是字符集名,第三个参数是baseUri,用于解析相对链接。如果某些页面编码不够清晰,可以先用CharsetDetector识别后再调用。还有一个细节:Jsoup.parse(File, charset)默认读取文件时按平台默认编码,这在Windows上会踩坑,因为Windows默认可能是GBK,最好显式传编码。
5.2 超时、重试与重定向
爬虫请求外部网站,网络波动几乎是常态。没有超时控制的代码,一旦某个连接挂起,线程池里的线程就会被拖死,整个爬虫的吞吐量断崖式下跌。我在HttpClient里把连接超时设成3~5秒,响应超时设成10秒。这个值不是拍脑袋定的,我测试过大量页面,正常页面响应一般都在2秒内,10秒的响应超时已经能容忍绝大多数慢站点,同时不会让线程长期卡死。
重试策略要小心。如果对方返回429(Too Many Requests)或者503(Service Unavailable),重试是合理的,但要配合退避时间。我采用的是指数退避加上随机抖动:第一次失败等2秒,第二次等4秒,第三次等8秒,最多重试3次。超过3次就标记失败,把URL存进待处理队列,等下一轮任务再跑。盲目重试会放大负载,反而更容易被封。
重定向也是容易被忽略的点。HttpClient默认会跟随重定向,但跨协议的重定向可能会出问题,比如从HTTP跳到HTTPS,或者跳到登录页。遇到异常情况时,我会打开重定向上下文,记录每跳的URL,一旦发现最后落在登录页而不是目标内容页,就说明登录态失效了,需要触发重新登录流程。
5.3 线程资源与连接池泄漏
用原生URLConnection写爬虫是可以的,但它不提供连接池,每个请求新建TCP连接,性能差不说,还会触发目标服务器的连接频率风控。HttpClient/OkHttp都内置连接池,但连接池用不好也有问题。
最典型的问题是:响应流没关闭。HttpClient的execute返回的CloseableHttpResponse必须被close,或者用try-with-resources。如果你只是读取了响应体,但没有关闭响应对象,底层连接就不会归还到连接池,时间长了连接池被占满,新的请求就会原地等待直到超时。这是我排查线上爬虫问题时最常见的根因,代码里少了一个finally块的事,线上却表现为“爬虫跑着跑着突然变慢、最后全部超时”。
还有一类问题来自线程池的队列无限大。你直接把Executors.newFixedThreadPool拿来做爬虫,队列默认是LinkedBlockingQueue无界的,一旦目标网站变慢,任务会全部堆积在内存里,还没被处理完,内存已经爆了。用有界队列加拒绝策略,才是生产环境该有的样子。
5.4 日志与可观测性
大型爬虫调试非常痛苦,尤其是多线程并发时,你不知道哪些线程在做什么、抓到了什么、失败了是什么原因。所以从项目一开始就要把日志打足。
我推荐用SLF4J加Logback。每个请求打印一行摘要日志:URL、状态码、耗时、抓到的数据量。每个失败请求打印异常原因和当前重试次数。这种日志格式看起来啰嗦,但排查问题的时候能少走很多弯路。配合ELK或者简单的grep,你可以快速回答三个问题:今天抓了多少条?失败率是多少?主要集中在哪些URL上?
另外建议监控线程池的关键指标:活跃线程数、队列大小、拒绝任务数、已完成任务数。用Spring Boot Actuator可以直接暴露这些指标,接入Prometheus加Grafana后就能看到一个实时爬虫仪表盘。这一步对判断要不要扩容、目标站是不是在针对你风控,都很有价值。
6. 从单机脚本到分布式爬虫的扩展路径
当数据量从几万涨到几千万,单机方案就会遇到瓶颈。我自己的项目就是从单机脚本一步步改到分布式架构的,这里简单讲一下演进方向,不一定适合所有人,但思路可以参考。
6.1 用Redis做任务队列
分布式的第一步是把任务和调度分离。单机版里,任务队列放在ArrayBlockingQueue或者数据库表里,多机协作时就要换成统一的队列。Redis的List非常适合做FIFO队列,生产者用LPUSH推入URL,消费者用BRPOP阻塞取出,天然支持多消费者。
要注意的一点是去重。多机同时消费时,同一个URL可能被两个节点同时取到。解决方案依然是布隆过滤器或者Redis的SADD去重集合。更稳的方案是用Redis的SETNX加锁,在开始处理某个URL之前先设置一个处理中标记,处理完成后删除标记。其他节点看到标记存在就跳过,能有效防止重复抓取。
6.2 分布式需要注意的三个细节
第一,任务结果要集中落库,不能写在各节点的本地文件里。统一走数据库,配合幂等约束。商品表里建唯一索引,用INSERT ... ON DUPLICATE KEY UPDATE或者MyBatis-Plus的saveOrUpdate。
第二,请求代理要统一管理。分布式爬虫经常要使用代理IP,不要让每个节点自己维护代理池。我建议把代理池做成一个独立服务,提供获取、上报、失效剔除的API,各节点按需获取。这样代理的利用率和稳定性都能统计。
第三,节点要设计优雅退出。分布式环境下节点随时可能重启,好的做法是在JVM关闭钩子(ShutdownHook)里保存当前正在处理的任务状态,并放回队列,等下次启动时继续抓。这个细节决定了你的爬虫是“重启就丢”还是“重启不慌”。
6.3 持续优化方向
分布式之后,优化重点会从“能不能抓”变成“抓得好不好”。我会关注三个指标:抓取成功率、单位时间抓取量、单条数据的完整率。日常优化方向包括:
- 精确控制每个站点的并发上限,防止触发风控。
- 针对不同站点维护独立的Cookie和代理配置。
- 对抓过的页面做增量更新策略,只抓变更部分。
- 对响应体的解析逻辑做模板化,用配置替代硬编码。
- 完善失败任务的退避策略,避免集中重试。
这套架构看着复杂,但其实每一步都是自然演进的。先跑通单机,再上队列,再拆独立模块。别一开始就搞Kafka加Spark,那只会让你把大量时间花在运维而不是数据采集本身。
我在实际项目里最大的体会是:爬虫的难点从来不在“写代码”,而在“如何优雅地控制节奏、持续稳定地获取数据”。Java的强类型和工程化约束,恰恰能帮你把这种稳定性落地。只要把请求封装好、解析逻辑写清、任务状态管理到位,Java写爬虫的体验一点不比Python差。
最后分享一个小技巧:在写页面解析逻辑时,最好把页面HTML先保存一份到本地,解析代码调通后再删掉。这样页面结构改了,你能快速定位是页面变了还是代码错了,省去一次次请求远程页面的等待时间。这个习惯我用了很多年,实测非常省心。