ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Java比价爬虫源码拆解:从线程池调度到持久化设计

Java比价爬虫源码拆解:从线程池调度到持久化设计 简介基于Java语言的比价网爬虫开源项目源码包面向需要构建比价网站数据抓取系统的开发者提供完整的爬虫架构与数据处理方案适合有Java基础、希望学习网络爬虫或电商数据采集的初中级开发者。压缩包共2000个文件大小约122.75MB核心代码涵盖303个Java源文件配合698个JavaScript文件、430个HTML页面、199个CSS样式及122个JSP动态页面另有135个Markdown文档和XML、JSON等配置数据文件覆盖前端交互、后端逻辑、页面解析与数据存储等环节。项目内置readme与pom.xml等工程说明src与db目录结构清晰可直接导入IDE分析。已有81人学习通过阅读源码可快速掌握爬虫请求、页面解析、数据持久化等实现思路为自研比价或信息采集工具提供基础。1. 比价 Spider 源码怎么拆从 6073 个文件里找到 Java 的主线解压这套基于 Java 编写的比价网 Spider 开源源码包第一眼看到的不是类文件而是一个容量惊人的资源池6073 个文件里Java 源文件只有 303 个JavaScript、HTML、图片和样式文件加起来超过 4000 个。这个比例直接暴露了比价爬虫的真实工作量——抓请求、存数据只是起点解析异构网页、归一化商品信息、组织前端展示才是投入的大头。这套源码适合两类人一类是想用 Java 搭商品数据采集链路的工程师另一类是准备 Java 相关岗位面试时需要一个完整项目的学习者。它覆盖了从 HTTP 请求、HTML 解析、JSON 中间态到数据库落地的完整闭环也保留了真实爬虫项目常见的前端资源目录和运维文档。后面的拆解会按“文件结构 → 抓取链路 → 持久化 → 上线验证”四条线展开核心代码段可以直接改到自己的工程里。2. Maven 工程与前端资源层文件构成反向推导模块边界2.1 文件类型统计哪个文件才是项目的真主体先看全貌。按文件类型统计这套源码的构成如下文件类型数量大致角色JavaScript1640页面交互、图表渲染、前端数据处理HTML796目标页面样本、解析对象、管理后台视图PNG754商品图、界面切图、校验码样本GIF672动图资源、价格走势动画JSON408抓取结果、前端配置、接口测试数据LESS356未编译的样式源文件CSS306编译后的样式文件Java303抓取调度、解析、存储核心逻辑Markdown165项目文档、开发笔记、变更记录JPG140商品图等位图资源JSON 文件有 408 个这个数量说明项目把结构化了的数据当作一等公民来对待爬虫跑完一轮结果先落到 JSON再由后续任务把 JSON 写入数据库或推给前端。LESS 和 CSS 并存说明项目里含一套完整的前端编译流程比如用 Less 源码编译出 CSS 再发布到静态资源目录。此外项目里还出现了 Ruby 和 PHP 文件这类文件在 Java 主导的仓库里通常承担辅助角色PHP 文件可能是数据导出工具Ruby 脚本可能是运维或日志处理工具出现频率不高不必把它们当作主链路。2.2 pom.xml 与 src/main/javaMaven 模块怎么定位pom.xml 是整个工程的装配说明它定义了三件事依赖哪些第三方库比如 HTTP 客户端、HTML 解析器、JSON 序列化库、用 Maven 的哪个生命周期完成编译打包、子模块之间怎么组织依赖。拿到这套源码第一步先执行依赖树命令可以直接看出解析层和序列化层的选型mvn dependency:tree -Dincludesorg.jsoup,com.fasterxml.jackson.core这条命令会过滤出 HTML 解析器和 JSON 处理相关的依赖树。如果 Jsoup 出现在结果里说明页面解析用了 DOM 选择器方案如果 Jackson 的依赖被排除掉了则可能换成 Gson 或 Fastjson后续看代码时对ObjectMapper的搜索方向也要跟着调整。依赖树为空则说明解析逻辑用正则或自研解析器实现这时要重点看 Java 代码里Pattern和Matcher的密度。src/main/java 下的 303 个 Java 文件是项目主逻辑拆这类项目有个经验先把类按包名归类controller包一般是任务入口service包负责业务编排dao或mapper包负责读写entity包对应数据模型。包名往往直接反映模块边界比逐行读注释更快。2.3 db 目录与 readme.txt持久化与运行约定db 目录通常会放建库建表脚本比价场景至少会有商品表、价格历史表、抓取任务表三类第四章会给出参考表结构。readme.txt 是运行的第一落点它会写明 JDK 要求、Maven 配置、数据库初始化方式。由于项目里有 165 个 Markdown 文件说明维护者习惯把设计文档也放进仓库这对二次开发是重要帮助——很多开源项目源码好懂、文档却缺这个项目把文档密度拉得较高属于适合直接接手研究的那一类。在本地跑起来之前先确认 Java 环境变量已配置好java -version和mvn -v能正常输出。常见做法是在 IDEA 里以 Maven 项目导入根目录等依赖下载完成后按 readme.txt 里的顺序执行 SQL 脚本最后启动一个带main方法的入口类观察日志输出。如果启动时报找不到驱动类优先检查 db 目录下 SQL 脚本是否完整执行以及 pom.xml 里数据库驱动的 scope 是否被误标成了provided。3. 抓取链路实现线程池调度、Jsoup 解析与价格归一化3.1 调度层有界队列与线程池的参数选择比价爬虫的调度层需要同时面对两个约束目标网站响应慢单线程抓取太慢并发过高又容易被限流。常见做法是维护一个抓取 URL 队列用ThreadPoolExecutor控制并发度队列用有界实现防止任务积压撑爆内存。// 抓取任务调度有界队列 丢弃最旧任务策略 BlockingQueueRunnable queue new ArrayBlockingQueue(5000); ThreadPoolExecutor pool new ThreadPoolExecutor( 8, // 核心线程数 16, // 最大线程数 60L, TimeUnit.SECONDS, // 空闲线程回收时间 queue, new ThreadPoolExecutor.DiscardOldestPolicy() ); // 提交商品详情页抓取任务 for (ProductSeed seed : seedList) { pool.execute(new FetchTask(seed, parser, sink)); }这里几个参数值得细看。核心线程数 8 对应目标站点响应耗时与单机带宽的折中如果目标页面平均响应在 200ms 以内可以提高到 16最大线程数不建议超过 32否则触发限流的概率明显增加。ArrayBlockingQueue容量设成 5000是为了防止种子 URL 膨胀时任务堆积过深DiscardOldestPolicy的语义是队列满了就丢弃最老的任务在爬虫场景里比AbortPolicy实用因为抓取有新鲜度要求老任务价值低。线程池参数推荐取值设置依据corePoolSize8单页平均响应耗时 × 期望并发数maximumPoolSize16峰值流量下的上界超过容易触发限流queue capacity5000内存容量与任务新鲜度的折中keepAliveTime60s空闲线程回收降低消耗线程数与队列容量的关系也是 Java 面试里常被追问的点任务积压时线程池是先创建线程到最大线程数还是先往队列里塞这套代码的默认行为是先用满核心线程再进队列队列满了才扩张到最大线程数。能把这个顺序讲清楚再结合本项目为什么选DiscardOldestPolicy而不选抛异常比单纯背八股文更有说服力。3.2 解析层Jsoup 抽取商品字段的写法拿到响应后页面解析是 Bug 最容易出现的位置。用 Jsoup 操作时要先抓页面结构特征再写选择器。比价站页面通常存在重复的商品块div.product-item这类选择器是常见入口。// 解析商品列表页抽取名称、价格、链接、销量 Document doc Jsoup.connect(url) .userAgent(UA_POOL[index]) .timeout(8000) .followRedirects(true) .get(); Elements items doc.select(div.product-item); for (Element item : items) { String title item.selectFirst(a.title).text(); String rawPrice item.selectFirst(span.price).text(); String detailUrl item.selectFirst(a.title).absUrl(href); String sales item.selectFirst(span.sales-num) null ? 0 : item.selectFirst(span.sales-num).text(); Product p new Product(); p.setTitle(title); p.setPrice(normalizePrice(rawPrice)); p.setDetailUrl(detailUrl); p.setSales(Integer.parseInt(sales.replaceAll([^0-9], ))); sink.write(p); }UA_POOL[index]是提前准备的一组 User-Agent运行期间轮换具体策略放第五章。selectFirst没有命中元素时返回 null所以销量字段先做空判断absUrl(href)会把相对路径拼成完整链接省掉自己拼接域名这一步。注意价格文本不能直接转数字必须经过normalizePrice处理否则1,299.00这类字符串会在解析阶段直接抛出异常。3.3 数据层从 HTML 到 JSON 的标准化过程价格归一化是比价项目里最容易被新手忽略的一环。不同页面给出的价格格式差异很大1,299.00、1299元、1 299、降价至 1299都会出现。统一做法是剥离货币符号和千分位再按小数点转成以分或毫为单位的整数避免浮点误差。// 价格归一化各种字符串统一成 Long 型分值 private static long normalizePrice(String raw) { if (raw null || raw.length() 0) { return -1L; } String cleaned raw .replaceAll([^0-9.], ) // 去货币符、空格、中文 .replaceAll(^0, ); // 去首部多余的0 if (cleaned.isEmpty()) { return -1L; } try { BigDecimal decimal new BigDecimal(cleaned); return decimal.movePointRight(2).longValue(); // 元转分 } catch (NumberFormatException e) { return -1L; } }replaceAll([^0-9.], )把数字和小数点之外的所有字符都清掉这一步对1,299.00和1299元都有效movePointRight(2)把元转成分保证后续比较、求差价时不丢精度。返回-1L表示解析失败在写入时直接过滤掉这类脏数据。标准化之后的Product对象会序列化成 JSON 存到本地文件或消息队列。408 个 JSON 文件的来源就在这里一台机器跑完一轮按日期分目录输出products-20240101.json再由独立任务去读取入库。这种中间态设计让抓取和存储解耦即使数据库暂时不可用抓取任务也不会失败。4. 持久化设计JSON 缓存、MySQL 建表与全量增量切换4.1 为什么 JSON 文件还要再落 MySQLJSON 文件适合做中间缓存和跨系统交换但不适合做聚合查询。要回答“某商品最近 30 天价格中位数是多少”这类问题解析 JSON 比执行一条 SQL 慢几个数量级。所以这套项目的典型落地方式是抓取模块只管写 JSON入库模块定期把 JSON 分批导入 MySQL。抓取与入库通过文件目录衔接比在抓取线程里直接写库更稳健数据库慢查询或锁等待也不会反向拖垮采集任务。全量与增量切换也需要想清楚。首次上线跑全量把历史种子 URL 全部抓一遍之后每天跑增量只抓crawled_at大于上次最大时间的商品。增量起点的获取用一条 SQL 就能完成mysql -h localhost -u price_user -p price_db \ -e SELECT MAX(crawled_at) FROM product_price;把这个时间点传到抓取任务的参数里种子 URL 只保留该时间之后有变化的商品。注意全量跑完不要直接删表保留一个月以上的历史价格后续才能画价格走势曲线。4.2 product_price 表设计与去重键比价库里的核心表是商品价格历史表它的设计决定数据质量。价格记录需要支持三个维度的查询按商品维度看价格走势、按时间维度看全网均价、按来源维度对比各平台差价。CREATE TABLE product_price ( id BIGINT AUTO_INCREMENT PRIMARY KEY, product_hash CHAR(32) NOT NULL COMMENT 商品去重指纹, source VARCHAR(64) NOT NULL COMMENT 来源站点标识, product_title VARCHAR(255) NOT NULL, detail_url VARCHAR(512) NOT NULL, price_cents BIGINT NOT NULL COMMENT 价格单位分, sales_count INT DEFAULT 0, crawled_at DATETIME NOT NULL COMMENT 抓取时间, UNIQUE KEY uk_source_hash_time (source, product_hash, crawled_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;product_hash由商品标题加来源站点标识生成哈希算法直接决定去重效果。这里唯一键是“来源站点 商品指纹 抓取时间”它避免了同一分钟重复入库同一商品又不阻拦不同时段的价格快照——价格历史需要保留每次变化。price_cents用 BIGINT 存分值避免浮点数带来的比较误差。注意detail_url长度要留够部分电商平台商品链接带长参数255 不够时要扩到 512 或 768。配合这张表的索引常见查询先走source product_hash找到某商品全部历史价格再按crawled_at排序取时间序列。如果数据量级上到千万行再考虑按季度分表或者把一年前的数据归档到冷表。4.3 Maven 依赖清单与批量写入pom.xml 里除了 Jsoup还需要数据库连接与 JSON 处理相关的依赖。参考依赖如下dependency groupIdorg.jsoup/groupId artifactIdjsoup/artifactId !-- 版本按构建环境匹配JDK 8 及以上选择可用版本 -- /dependency dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId /dependency dependency groupIdcom.zaxxer/groupId artifactIdHikariCP/artifactId /dependency引入 HikariCP 后批量写入建议打开 JDBC 的重写开关它能把多条 INSERT 合并成一条多值语句写入效率提升明显。连接串示例jdbc:mysql://localhost:3306/price_db?rewriteBatchedStatementstrueuseServerPrepStmtstruerewriteBatchedStatementstrue是 MySQL 驱动在批量插入时的关键开关不开的话addBatch实际是逐条发送性能不会提升。useServerPrepStmtstrue让预编译在服务端进行配合 HikariCP 连接池长任务跑批时内存占用更平稳。入库模块读取 JSON 时逐行解析比整文件加载更安全// 逐行读取 JSON 文件跳过解析失败的记录攒批写入 try (BufferedReader reader Files.newBufferedReader(jsonPath)) { String line; while ((line reader.readLine()) ! null) { try { Product p objectMapper.readValue(line, Product.class); insertBatch.add(p); } catch (JsonProcessingException e) { log.warn(skip bad line: {}, line); } if (insertBatch.size() 1000) { jdbcTemplate.batchUpdate(INSERT_SQL, insertBatch); insertBatch.clear(); } } }这里用readLine逐行读而不是整文件readValue因为抓取结果文件可能很大一次性加载入内存容易触发 OOM单条解析失败只记日志并跳过不影响整批任务。攒到 1000 条执行一次batchUpdate是写性能和内存占用之间的常用折中点。实际跑批时还可以把INSERT_SQL改成INSERT INTO ... ON DUPLICATE KEY UPDATE配合唯一键让价格更新自动完成。5. 上线前的验证与限速抓取质量检查和 User-Agent 策略5.1 用日志统计抓取成功率一套爬虫上线前先看三组数字请求总数、解析成功数、入库去重数。比较快的做法是在FetchTask的结束位置输出结构化日志之后用 grep 统计grep -c fetch_success app.log grep -c fetch_fail app.log成功率的健康线一般要求在 95% 以上。低于这个值先排查超时时间和 User-Agent 是否被目标站点识别解析成功但字段为空的情况则需要单独统计price_cents 0的记录数这类脏数据往往是页面结构改版导致的。建议在日志里把解析失败的 HTML 片段截断输出方便直接定位是选择器失效还是页面跳转到了验证码页。5.2 User-Agent 轮换与限速参数UA 轮换是低成本反识别手段。准备一个覆盖桌面和移动端的 UA 池每次请求前随机取一个同时请求间隔加上随机抖动避免定时器被对方抓到规律// 请求前随机延迟降低请求特征的规律性 Thread.sleep(800 ThreadLocalRandom.current().nextLong(1200));nextLong(1200)让每次等待时间在 800ms 到 2000ms 之间波动比固定延时 1 秒更隐蔽。注意限速参数要和线程数联动核心线程数 × 平均延迟就是这台机器的平均 QPS按目标站点的容忍度反推参数。如果对方 1 秒内允许 5 个请求核心线程数就得控制在 10 以内平均延迟 2 秒时总 QPS 才 5。5.3 断点续抓的校验技巧长时间跑批最怕中途挂掉后全部重来。常见做法是给每个详情页 URL 算一个 MD5 指纹运行时用内存集合过滤入库时依赖唯一键去重// 已抓取集合启动时从数据库加载运行时内存判断 if (seenHashes.contains(hash)) { return; } seenHashes.add(hash);注意seenHashes的规模受内存限制几百万量级用HashSet可以支撑再往上涨就要换成 Redis 的 SET 结构。重启后从数据库把最近 24 小时的product_hash重新加载进内存即可继续缺失的时段会在下轮增量任务里自动补齐。跑批过程中如果频繁出现超时优先降低线程数而不是调大超时——线程数翻倍带来的收益在目标站点限流后会直线下降反而让超时异常堆积在队列里。本文还有配套的精品资源点击获取
返回列表