☰
Java爬虫工程化实践:多线程采集框架的设计与二次开发
2026/10/21 8:46:49 网站建设 项目流程

简介:压缩包内含一份完整的Java爬虫项目源码,面向有Java基础、希望学习网络数据抓取的开发者,覆盖请求发送、页面解析、数据保存的完整过程。包内共1959个文件,大小约243MB,以png图片、jar依赖库、编译后的class文件、java源文件为主,同时包含js、css、xml、jsp等前端与配置资源,以及sql脚本和pdf说明文档,便于理解项目前后端结构和建表思路,已有135人浏览或学习。项目涉及页面解析库使用、多线程抓取、正则表达式提取、延迟加载内容处理、代理设置与反爬策略应对、异步处理等关键点,也体现了常见爬虫框架的设计思想。阅读源码可以了解日志记录、单元测试、数据持久化等工程化做法,适合需要实战演练、快速搭建采集任务或准备面试的开发者;文件按模块组织,能较快定位核心抓取代码。无论是用于课程设计还是企业内部分析任务,都有直接参考价值。

1. Java爬虫工程化:一套能直接跑、也能当骨架改的采集框架

做过采集开发的都知道,真正的难点从来不是“发一个HTTP请求拿到页面”,而是把请求调度、去重、解析、存储、重试这一整条链路组织成一套稳得住、改得动的代码。这份Java爬虫项目实战源码,拿到的就是这样一个工程——它不是单文件的玩具demo,而是一个分模块的多线程采集框架,对新手来说能看明白每层在干什么,对熟手来说能直接往里面塞代理池、换解析规则、接消息队列。我用一个下午把它跑通,又从改解析规则到加代理校验,验证了这套代码的扩展性确实在线。这篇文章按“它能干什么、结构怎么搭、怎么跑起来、踩过哪些坑、怎么二次开发”的顺序把它拆开讲。

2. 技术选型与工程结构:不依赖重量级框架的轻骨架

2.1 技术栈选型

这套源码的核心思路是“能少依赖就少依赖”。HTTP客户端用的是Apache HttpClient 4.5,HTML解析用的是Jsoup,JSON处理用的是fastjson,数据存储走的是MySQL配合DBUtils这种轻量封装,没有引入Spring Boot、MyBatis这种重量级容器。对一个爬虫项目来说,这意味着你不需要搭一套复杂的环境,拿到源码之后只需要装好JDK 8+ 和 Maven 3.6+,配好数据库脚本,就能把工程跑起来。

为什么这么选?我在改这套代码的时候深有体会:爬虫项目跟业务系统不一样,它最常改的是“解析规则”和“调度策略”,而不是复杂的业务事务。引入Spring Boot当然能把代码组织得更“规范”,但也会把启动流程变重——每次调解析逻辑都要等容器起来,这对频繁试错来说是很伤的。而这套源码把核心逻辑放在core层和parser层,改动之后直接跑入口类就能验证,省掉了框架层的干扰。另一个值得说的选择是去重手段同时支持本地内存和Redis两种模式,在没有外部依赖的环境下你用本地去重,部署到线上再切Redis,这个切换只需要改配置文件的一行,不用动业务代码。

2.2 目录结构与模块边界

源码的包结构划分得很干净,我刚打开的时候就有一种“这代码能看”的感觉。整体是这样的:

crawler/ ├── src/main/java/com/crawler/ │ ├── core/ │ │ ├── Scheduler.java // 任务调度器 │ │ ├── BloomFilter.java // 布隆过滤器实现 │ │ ├── Downloader.java // HTTP下载 │ │ └── ThreadPoolManager.java // 线程池管理 │ ├── parser/ │ │ ├── PageParser.java // 解析接口 │ │ └── ResultParser.java // 默认解析实现 │ ├── storage/ │ │ ├── DBHelper.java // 数据库连接 │ │ └── DataExporter.java // 数据入库 │ ├── model/ │ │ └── Page.java // 实体模型 │ ├── util/ │ │ └── ConfigLoader.java // 配置读取 │ └── Main.java // 启动入口 ├── src/main/resources/ │ ├── config.properties │ └── db_config.properties ├── sql/ │ └── init.sql └── pom.xml

六大模块各管一件事:core管请求和调度,parser管从HTML里抽数据,storage管持久化,model定义数据形状,util统一读配置。我拆的时候就感受到,这个边界划分不是硬凑的,而是按爬虫数据流天然切开的。举个例子,你想换掉Jsoup用正则解析,只需要改parser包;你想从MySQL换成SQLite,只需要动storage包,其它层基本不受影响。这种模块化设计,是我给这份源码评分最高的地方。

2.3 数据流向:一次采集的完整生命周期

理解这套代码最有效的方式是跟一条数据走一遍。启动之后,Main先读config.properties拿到种子URL列表和全局参数,然后把种子URL交给Scheduler的调度队列。调度器从队列取URL,先去BloomFilter里确认这个地址是否已经抓过,没抓过就交给Downloader发请求,拿到HTML之后丢给PageParser解析出结构化数据,最后DataExporter把结果批量写入MySQL。整个过程里,失败重试、线程调度、IP切换这些横向问题由core层统一处理。

这条链路里有一个细节很关键:Downloader拿到的原始HTML和PageParser解析后的数据模型是分开存储的,原样HTML会在你后续想“不加代码就重新抽一遍”时发挥大作用,我后面会详细讲。这种“抓取与解析解耦”的做法,也直接决定了二次开发时你能只改一处就完成新站点的接入。

3. 核心模块代码拆解:从调度到存储,一条链路走到底

3.1 调度器Scheduler:队列、优先级与去重

调度器是整个爬虫的心脏。它的职责不只是维护一个待抓URL队列,还要负责控制采集节奏、标记失败任务、配合去重模块跳过已处理地址。核心数据结构是一个无界阻塞队列——为什么用无界?因为采集任务通常是先找出一批种子,再在解析过程中不断发现新URL往里放,用有界队列很容易在某一瞬间打满导致调度线程直接抛异常。

public class Scheduler { private LinkedBlockingQueue<String> queue = new LinkedBlockingQueue<>(); private BloomFilter filter = new BloomFilter(10_000_000, 0.01); private Set<String> visited = ConcurrentHashMap.newKeySet(); public void addSeed(String url) { if (filter.contains(url)) { return; } filter.add(url); visited.add(url); queue.offer(url); } public String poll() throws InterruptedException { String url = queue.take(); if (visited.contains(url)) { return poll(); } visited.add(url); return url; } }

这段代码的逻辑分两层。addSeed用于初始种子的去重写入:BloomFilter先挡一遍,避免重复入队,visited集合再记录一次,用于poll时的二次校验。poll是调度线程的工作方法,它从队列取出URL之后又一次检查visited,防止并发情况下两个线程拿到同一个地址。我特意提这个设计是因为很多写爬虫的会把去重和调度混在一起,结果在并发场景下去重失效,同一个页面被反复抓取几十次。这套代码把“入队时去重”和“取出时校验”拆开,算是处理了这个隐蔽问题。

BloomFilter的初始化参数也值得关注。第一个参数是期望容错的数量,第二个参数是误判率。10_000_000代表这个过滤器最多容纳一千万个URL的指纹,误判率控制在1%,这在单机采集的场景下是一个比较合理的折中。误判会导致极少数没抓过的URL被当成“已抓过”跳过,但对海量采集来说这点损耗可以接受。如果你抓的是几万量级的小站,可以把第一个参数改小,节省内存占用。

3.2 下载器Downloader:超时、重试与响应处理

Downloader这层做的是最脏最累的活。网络请求要处理连接超时、读超时、响应码异常、页面编码乱码、被拒绝服务……任何一个环节没兜住,线程就会卡死或抛异常退出。这套源码的Downloader用了一个比较实用的模式:默认重试三次,第一次失败后等待2秒,第二次失败后等待5秒,三次全部失败才放弃。

public class Downloader { private CloseableHttpClient httpClient; private int maxRetry = 3; public String get(String url) { for (int i = 1; i <= maxRetry; i++) { try { HttpGet request = new HttpGet(url); request.setHeader("User-Agent", ConfigLoader.get("user.agent")); request.setHeader("Accept", "text/html,application/xhtml+xml"); try (CloseableHttpResponse response = httpClient.execute(request)) { int status = response.getStatusLine().getStatusCode(); if (status == 200) { return EntityUtils.toString(response.getEntity(), "UTF-8"); } else if (status == 403 || status == 429) { Thread.sleep(5_000); continue; } } } catch (Exception e) { // 记录日志后重试 } } return null; } }

这段代码有两个值得关注的参数。maxRetry是重试上限,我建议按目标站点的稳定性来调:稳定的站点可以降到2次,反爬严格或者网络不稳定的站点建议涨到5次。status为403或429时手动sleep 5秒再进入下一次循环,这是处理反爬常用的退避策略——这两个状态码代表“你被盯上了”,继续高频请求只会加重封禁。如果你改完代码发现某个站点隔一段时间就抓不到数据,优先检查这里的处理逻辑。

另外有个实现细节容易被忽略:EntityUtils.toString指定了UTF-8编码。如果目标页面用的是GBK,这里会出现中文乱码,但抓到的数据不会报错,等你发现库里全是乱码才反应过来。这种问题排查起来很恶心,所以源码里把编码写死在配置里而不是从响应头里自动检测,反倒更可控。

3.3 解析器PageParser:从HTML到结构化数据的抽取模板

解析模块定义了PageParser接口,面向接口编程是这套代码最值得学的地方。它只规定一个解析方法,输入是原始HTML和页面URL,输出是一个Page模型。生产环境里的解析逻辑千奇百怪,有的页面需要正则捉取,有的需要JSON解析嵌套字段,但不管内部实现多复杂,对外的方法签名保持稳定,调度层就不需要感知解析细节。

public interface PageParser { Page parse(String html, String url); }

默认的ResultParser实现针对的是文章列表页,它用Jsoup的选择器语法把每一块需要的数据抠出来。选择器的写法我建议直接参考你自己用浏览器开发者工具看到的元素结构,而不是猜。一般流程是:先用div.list-item定位列表项,再在每一项里用.title a定位标题和链接,最后在详情页层面解析正文内容。这套源码把“列表页解析”和“详情页解析”拆成了两个方法,这在工程上更清晰——列表页只负责产出URL,详情页才负责产出正文,避免一个方法里逻辑太混杂。

Jsoup选择器有几个常见写法要注意:Elements items = doc.select("div.list-item")拿到的是所有匹配元素的集合;item.selectFirst(".title a")返回的是第一个匹配项;attr("href")拿链接,text()拿文本内容。这三个方法组合起来,能覆盖大多数静态页面的解析需求。

3.4 存储层DataExporter:批量写入与去重更新

存储层用的是批量插入而不是逐条insert,这是采集程序能跑得动大量数据的关键。逐条插入数据库在数据量上来之后会非常慢,每次都要建立连接、执行SQL、提交事务,改成批量提交后性能能提升一个数量级。

public void export(List<Page> pages) { String sql = "INSERT INTO page_info(url, title, content, fetch_time) VALUES (?, ?, ?, ?) " + "ON DUPLICATE KEY UPDATE title = VALUES(title), content = VALUES(content)"; Connection conn = DBHelper.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); for (Page page : pages) { ps.setString(1, page.getUrl()); ps.setString(2, page.getTitle()); ps.setString(3, page.getContent()); ps.setTimestamp(4, new Timestamp(System.currentTimeMillis())); ps.addBatch(); } ps.executeBatch(); conn.commit(); }

这里有一个很容易被忽略的关键点:SQL末尾的ON DUPLICATE KEY UPDATE做了“URL重复时更新内容”的处理。意思是同一个URL如果被重复抓取,不会插入两条记录,而是把标题和内容刷新为最新版本。这跟调度的去重形成双保险——就算调度层的去重出现漏判,存储层也能兜住。DBUtils里的批量提交需要手动调用commit,如果你改代码时把这段复制出去,忘记写commit,数据会一直堆积在事务缓冲区里不落库。

4. 从零跑通:环境准备、配置修改与启动验证

4.1 环境要求与初始准备

跑这套源码需要准备的东西并不多。JDK 8或更高版本是必需的,Maven 3.6以上用来拉依赖和打包;MySQL 5.7以上用来存数据。如果你本机没有MySQL,也可以用Docker快速起一个,这套源码没有用到任何数据库特化功能,标准SQL就行。资源附带的sql/init.sql脚本建好了一张page_info表,字段包括自增ID、URL、标题、内容、抓取时间和抓取状态,这张表结构同时支持了数据存储和去重两种用途。

我第一次跑的时候花了20分钟卡在数据库连接上:源码默认数据库账号是root,密码是123456,如果你的本地MySQL密码不是这个,启动时会出现数据库连接失败。这是这类源码最常见的翻车点,解决办法就是改配置文件,位置在src/main/resources/db_config.properties。

4.2 配置项逐条说明

配置文件是整个项目最容易出细节问题的部分。我把config.properties和db_config.properties的关键配置项列一下:

配置项值示例作用与改法
seed.urlshttps://example.com/list种子URL,多个用逗号分隔
thread.count8工作线程数,按目标站点承受能力调
max.retry3请求失败重试次数
user.agentMozilla/5.0...UA标识,尽量改成真实浏览器的值
request.interval500两次请求间隔毫秒数,容易被封的站调到1000+
redis.enabledfalse是否启用Redis去重,本地跑保持false
jdbc.urljdbc:mysql://localhost:3306/crawler数据库地址
jdbc.usernameroot数据库账号
jdbc.password123456数据库密码,必改项

thread.count和request.interval是一对需要搭配调整的参数。如果你把线程数调到16,但间隔还是500毫秒,实际效果是16个线程都在拼命请求,目标站点压力很大,很容易触发反爬。理性的改法是线程数越高,间隔也相应加大,保持整体请求速率在一个安全区间内。

4.3 启动步骤与验证方法

跑起来的具体步骤是:先执行sql/init.sql建表,然后mvn clean package打jar包,最后java -jar target/crawler.jar启动。启动之后观察控制台日志,正常情况应该看到调度器打印“开始抓取种子URL”和“已完成第N页抓取”的信息。

验证是否跑通有两条路径。一条是直接查MySQL数据库,执行SELECT COUNT(*) FROM page_info,数据量在持续增长说明整条链路是通的。另一条是看抓取到的内容质量,随机抽几条记录看标题和正文字段是否为空,如果全为空,大概率是解析选择器跟目标站点的实际结构对不上了。源码里自带的解析规则针对的是某类固定结构的页面,你实际抓的站结构不同,改ResultParser里的选择器即可。

4.4 日志排查的阅读方法

这套源码的日志虽然不花哨,但信息量足够。它用得最多的是Java自带的java.util.logging,在控制台直接输出。日志分几个关键位置:调度器每处理完一个URL会输出当前队列剩余量;下载器失败重试会打印失败原因和剩余重试次数;数据库写入成功会输出本次批量入库的记录数。排查问题时我建议优先看下载器日志,因为采集链路里80%的问题出在请求阶段——对方封了IP、连接超时、返回非200码,这些都会在这里留下痕迹。如果你发现队列一直在堆积但日志里没有报错,多半是解析规则把内容抽空了,数据一直在全量写入空记录。

5. 避坑指南:运行这套爬虫源码最容易掉的五个坑

5.1 线程池队列积压,抓取速度却上不去

现象:启动之后队列里一直有任务,但数据库增长很慢,CPU占用也不高。

原因:我排查后发现是request.interval设置得太小,导致线程频繁在短时间睡眠后再次发起请求,大量时间耗在系统调度和上下文切换上,真正抓取的时间反而变少。

解决:先把request.interval调到1000毫秒,线程数降到4,观察一段时间。如果速度恢复正常,说明是并发参数不合理而不是代码逻辑问题。爬虫不是线程越多越快,要结合目标站点和网络环境反复试。从那以后我每跑一个站点都会先做一轮参数小扫描,而不是直接沿用默认配置。

5.2 页面解析结果全部为空,但没有报错

现象:日志显示抓取成功、入库成功,但查数据库发现标题和内容全是空字符串。

原因:这是爬虫最隐蔽的翻车现场。请求正常返回了HTML,但页面结构里根本没有Jsoup选择器匹配的类名。可能是站点改版了,也可能是有部分内容是JavaScript动态渲染的,初始HTML里只有空壳。

解决:先用Downloader单独拉一个页面保存成HTML文件,用手工方式查看目标数据的真实标签结构,再修ResultParser里的选择器。动态渲染的问题则需要在解析之前引入无头浏览器组件来执行JS,或者找到数据返回的XHR接口直接抓JSON。这个坑提醒我,解析规则永远带一个“数据完整性校验”,入库前检查字段是否为空,为空就报警而不是静默通过。

5.3 爬到一半IP被封,所有请求返回403

现象:运行一段时间后,日志里连续出现403状态码,重试几次之后全部失败,采集进程不再产出新数据。

原因:目标站点的反爬系统检测到同一IP的高频请求并在短时间内封禁了它。

解决:最直接的方式是换IP池。源码里已经把Downloader的请求逻辑封装好了,你可以在get()方法里加上代理参数,从代理池中取一个可用IP发给HttpClient。代理池的维护也是个独立的活,常见做法是定时从免费代理网站采集,然后逐个验证联通性和速度,把有效的放进池子。如果只是临时跑一次小批量采集,把request.interval调大到3到5秒,线程数降到2,也能在IP被封之前跑完。

5.4 时间字段全是一个值,看不出抓取频率

现象:数据表里的fetch_time几乎相同,或者全部是同一天的同一个小时。

原因:批量插入使用的是同一个System.currentTimeMillis()循环内部生成的值,批次之间间隔很短,所以时间戳集中在同一秒范围内。这不是SQL的问题,而是写入策略导致的采样失真,容易让后续数据分析误判采集频率。

解决:如果你需要分析“什么时间抓到的数据”,建议把时间字段改成不依赖手动设置,而是让MySQL在插入时自动生成:把fetch_time字段的默认值设为CURRENT_TIMESTAMP,代码里就不再对这个参数赋值。要注意,这个改动不影响已有数据,新插入的记录会自动使用数据库时间。做采集项目时,时间维度经常被忽视,但等到你要复盘请求频率时就会发现它极其重要。

5.5 修改解析规则后重新打包,但数据没变化

现象:改了ResultParser重新打包运行,数据库里还是旧数据或者没数据。

原因:源码在启动阶段会把当前解析规则的关键信息缓存起来,如果你直接改的是类名或方法签名,但调度的调用入口没变,它可能还在走旧的类路径。另一个更常见的原因是解析目标站点的页面有缓存策略,CDN层返回的是旧页面。

解决:先在浏览器里用无痕模式访问目标页面,确认当前HTML的结构确实变了,再改解析选择器。改动类名时注意同时检查Scheduler和Main里的实例化代码。采集开发有一条铁律:改完代码必须清一遍库里的对应URL记录再重新跑,否则去重模块会把“已抓过”的判断当成真理,你的新解析规则根本没有机会执行。

6. 二次开发的三个方向:从接新站到接消息队列

这套源码最有价值的地方不是“能跑”,而是它留出了清晰的改造口子。我基于它做了三个方向的扩展,每个都能在单层内完成,不牵动全局。

第一个方向是接入新站点。流程是:在parser包里新增一个实现PageParser接口的类,针对新站点的HTML结构写选择器;然后在Main启动时根据配置动态决定用哪个解析器;最后在配置文件的seed.urls里填入新站点的种子页。整个过程只动了parser层和配置,调度和存储完全不用改。

第二个方向是引入Redis做分布式去重。当前默认的BloomFilter是单机内存版的,到了多实例部署时,每个实例都有独立过滤器,同一个URL会被多个实例重复抓取。改造方式是把BloomFilter类里的contains和add方法改成通过Jedis调用Redis的SETBIT和GETBIT命令。外部依赖变了,但调用方无感知。

第三个方向是把解析结果输出到消息队列,供下游系统消费。在DataExporter里加一个sendToKafka(Page page)方法,在export批量写数据之前逐条发送。这样采集系统和数据处理系统解耦,采集端只管抓和解析,下游想怎么处理数据是下游的事。

验证改造有没有生效的流程,我一般这样走:先用10个种子URL小批量测试,观察日志里是否走了新的解析器;再查库确认新增字段有值;最后把请求间隔调到最小做短时压测,看线程池和新模块的吞吐。记住一条经验:每次做爬虫项目改造,我都强制自己先跑一遍“单页抓取→解析验证→入库检查”的demo链路,确保最小闭环通了再切换全量任务。希望这套源码和这份拆解能帮你少走几个月的弯路。

本文还有配套的精品资源,点击获取

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

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

立即咨询