ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot+WebMagic+MyBatis多数据源爬虫实战:从架构到避坑

SpringBoot+WebMagic+MyBatis多数据源爬虫实战:从架构到避坑

简介:这份资源面向具备一定Java基础、希望系统掌握爬虫工程化落地的开发者,围绕SpringBoot、WebMagic、Mybatis与多数据源整合,构建可扩展的抓取与存储框架,可用于数据分析、市场研究等场景的采集需求。压缩包共134个文件,约64.89MB,以42个java源码与44个class文件为主体,辅以16个xml配置、5个properties及yml等配置文件,另有jsp页面与日志、构建相关文件,便于直接导入运行和二次改造。已有1290人学习下载,具备一定参考热度。项目将WebMagic的Downloader、PageModel、Pipeline、Scheduler等组件与SpringBoot依赖注入结合,并用Mybatis完成持久化,同时通过多数据源配置类与AOP事务管理实现数据源切换,读者可据此理解爬虫框架的分层组织、组件协作与多库存储策略,快速搭建自己的采集服务。

1. 一个采集任务跑三天,卡在哪了

去年接手一个数据采集需求:十几个来源站点,字段结构各不相同,还要把结果分别落到业务库和统计库。第一版用单数据源硬写,跑到第三天开始翻车——采集线程还在跑,写库连接池先被打满,业务查询跟着超时。后来把架构改成 SpringBoot 打底、WebMagic 管抓取、MyBatis 管落库、多数据源做读写分离,才算稳住。这套「爬虫基于 Springboot+WebMagic+Mybatis+多数据源」的组合,适合已经会写 CRUD、想正经做一个可维护采集服务的开发者,也适合手里有多个数据源、需要把抓取结果分流落库的团队。下面按「能干什么 → 怎么跑 → 怎么改」拆开讲。

2. 技术选型与整体架构:四个组件各管一段链路

2.1 为什么是这四个凑一起

单看每个组件都不新鲜,关键是职责边界划得清。SpringBoot 负责容器、配置、定时任务和生命周期管理;WebMagic 负责下载、解析、URL 调度和去重,它自带 Scheduler 和 Pipeline 抽象,天然适合把「抓」和「存」解耦;MyBatis 负责 SQL 映射,采集场景字段经常变,XML 里改 SQL 比改实体注解灵活;多数据源解决的是「一份采集结果要进多个库」的问题,比如原始数据进采集库、清洗后的指标进统计库。

我一般会把整条链路想成四段:调度触发 → 页面下载 → 字段抽取 → 分流落库。WebMagic 的Spider覆盖前三段,最后一段交给自定义Pipeline,Pipeline 里再通过多数据源路由决定写哪个库。这样拆的好处是,换站点只动 PageProcessor,换存储只动 Pipeline,互不牵连。

2.2 分层结构与目录约定

一个能跑起来的工程,目录大致长这样:

src/main/java/com/example/crawler ├── CrawlerApplication.java // 启动类,开启定时任务 ├── config │ ├── DataSourceConfig.java // 主从/多数据源注册 │ ├── DynamicDataSource.java // AbstractRoutingDataSource 实现 │ └── WebMagicConfig.java // Spider Bean 与线程池参数 ├── processor │ └── ArticlePageProcessor.java // 站点解析逻辑 ├── pipeline │ └── MultiDbPipeline.java // 分流落库 ├── mapper │ ├── RawDataMapper.java │ └── StatDataMapper.java └── task └── CrawlTask.java // @Scheduled 入口

这个结构不是摆设。config放的是「一次性配好就不常动」的东西,processor和pipeline是「按站点、按业务频繁改」的地方,mapper跟着表结构走。新人接手时先看task知道什么时候跑,再看processor知道抓什么,最后看pipeline知道存哪,路径是顺的。

2.3 数据流:从 URL 到两条库的落点

一次采集的完整流向是:定时任务触发Spider.create(processor).addUrl(seed).addPipeline(pipeline).thread(5).run();WebMagic 内部 Downloader 取回 HTML,PageProcessor 用 CSS 选择器或 XPath 抽字段,封装成自定义的CrawlResult对象;Pipeline 拿到对象后,先写采集库(原始快照),再根据业务规则判断是否需要写统计库(聚合指标)。两个写操作走不同的SqlSessionTemplate,由DynamicDataSource在方法调用前切换数据源 key。

这里有个容易忽略的点:WebMagic 的 Pipeline 是同步调用的,如果两个库都写,单条耗时会叠加。所以我在 Pipeline 里对统计库的写入做了批量缓冲,攒够 50 条或超过 2 秒再 flush,避免每条都开一次事务。

3. 核心模块拆解:PageProcessor 与多数据源 Pipeline

3.1 PageProcessor:把页面字段抽干净

PageProcessor 是整个项目里改动最频繁的类,因为站点结构一变就得跟着调。下面是一个典型实现,抽标题、发布时间、正文和来源:

public class ArticlePageProcessor implements PageProcessor { // 站点级配置:重试次数、抓取间隔、编码 private Site site = Site.me() .setRetryTimes(3) .setSleepTime(1000) .setTimeOut(10000) .setCharset("UTF-8"); @Override public void process(Page page) { // 列表页:把详情页链接加入待抓队列 if (page.getUrl().regex(".*/list/.*").match()) { page.addTargetRequests( page.getHtml().css("div.item a::attr(href)").all() ); return; } // 详情页:抽取字段,封装成业务对象 CrawlResult result = new CrawlResult(); result.setTitle(page.getHtml().css("h1.title", "text").get()); result.setPublishTime( page.getHtml().xpath("//span[@class='time']/text()").get() ); result.setContent(page.getHtml().css("div.article-body").get()); result.setSourceUrl(page.getUrl().toString()); // 字段缺失直接跳过,避免脏数据入库 if (result.getTitle() == null || result.getContent() == null) { page.setSkip(true); return; } page.putField("data", result); } @Override public Site getSite() { return site; } }

逻辑说明:process方法里先判断当前 URL 是列表页还是详情页,列表页只做链接提取并return,详情页才做字段抽取。page.setSkip(true)是 WebMagic 提供的跳过机制,字段不全的记录不会进入 Pipeline,省掉后续判空。参数上,setRetryTimes(3)应对偶发的 5xx,setSleepTime(1000)是礼貌抓取,别调太小,否则容易被目标站点限流。setTimeOut(10000)对慢站点要适当放大,我一般按实测 P95 响应时间的两倍来设。

3.2 多数据源配置:路由 key 怎么切

多数据源的核心是AbstractRoutingDataSource,它根据当前线程绑定的 key 决定用哪个DataSource。配置类大致如下:

@Configuration public class DataSourceConfig { @Bean @ConfigurationProperties("spring.datasource.raw") public DataSource rawDataSource() { return DataSourceBuilder.create().build(); } @Bean @ConfigurationProperties("spring.datasource.stat") public DataSource statDataSource() { return DataSourceBuilder.create().build(); } @Bean public DataSource dynamicDataSource() { Map<Object, Object> target = new HashMap<>(); target.put("raw", rawDataSource()); target.put("stat", statDataSource()); DynamicDataSource ds = new DynamicDataSource(); ds.setTargetDataSources(target); ds.setDefaultTargetDataSource(rawDataSource()); return ds; } }

DynamicDataSource继承AbstractRoutingDataSource,重写determineCurrentLookupKey()返回线程变量里的 key。参数说明:spring.datasource.raw和spring.datasource.stat分别对应两套连接池配置,建议把maximum-pool-size分开设,采集库写多读少可以小一点,统计库如果有报表查询就留大一些。默认数据源设成采集库,避免忘记切 key 时写错地方。

3.3 Pipeline:一次采集写两个库

Pipeline 里做两件事:写原始库、按规则写统计库。切换数据源用自定义注解加 AOP,或者直接在方法里手动 set/clear,后者更直白:

public class MultiDbPipeline implements Pipeline { private final RawDataMapper rawMapper; private final StatDataMapper statMapper; private final List<CrawlResult> buffer = new ArrayList<>(); @Override public void process(ResultItems items, Task task) { CrawlResult data = items.get("data"); if (data == null) return; // 1. 写采集库:原始快照,逐条落 DataSourceContext.set("raw"); try { rawMapper.insertRaw(data); } finally { DataSourceContext.clear(); } // 2. 写统计库:批量缓冲,减少事务开销 buffer.add(data); if (buffer.size() >= 50) { flushStat(); } } private void flushStat() { DataSourceContext.set("stat"); try { statMapper.batchInsert(buffer); buffer.clear(); } finally { DataSourceContext.clear(); } } }

逻辑说明:DataSourceContext是一个ThreadLocal<String>包装,set之后当前线程后续的 MyBatis 操作都会走对应数据源,finally里必须clear,否则线程池复用时会串库——这是血泪经验,第一次上线就因为这个把统计库写进了采集库。buffer的阈值 50 和 flush 时机要结合采集速率调,太快没意义,太慢丢数据风险高,建议再加一个定时 flush 兜底。

3.4 定时任务与并发参数

入口任务用@Scheduled触发,Spider 的线程数不要拍脑袋:

@Component public class CrawlTask { @Scheduled(cron = "0 0 2 * * ?") public void run() { Spider.create(new ArticlePageProcessor()) .addUrl("https://example.com/list/1") .addPipeline(pipeline) .thread(5) // 并发线程数 .run(); } }

thread(5)意味着同时有 5 个下载线程,配合Site.setSleepTime(1000),实际 QPS 大约在 5 左右。如果目标站点有反爬,先降到 2~3 观察;如果自己的库写入是瓶颈,加线程只会让 Pipeline 排队更严重,这时候该优化的是批量写入而不是并发数。

4. 跑起来:环境、配置与启动验证

4.1 环境与依赖清单

跑这个项目需要 JDK 8 及以上、Maven 3.6+、MySQL 5.7/8.0 两套库(采集库和统计库可以同实例不同 schema)。核心依赖版本按 SpringBoot 2.x 的兼容矩阵来,WebMagic 用 0.9.x 系列即可,MyBatis 用 starter 方式引入。依赖不用列全,关键是别把 WebMagic 自带的 httpclient 版本和 SpringBoot 管理的版本冲突,遇到NoSuchMethodError先查依赖树。

4.2 配置文件怎么写

application.yml里两套数据源分开配,注意 URL 参数带上时区和编码:

spring: datasource: raw: jdbc-url: jdbc:mysql://localhost:3306/crawler_raw?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: crawler password: ****** maximum-pool-size: 10 stat: jdbc-url: jdbc:mysql://localhost:3306/crawler_stat?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: crawler password: ****** maximum-pool-size: 20

注意这里用的是jdbc-url而不是url,因为手动DataSourceBuilder构建时认的是jdbc-url,写成url会启动报「找不到驱动」。两套库的建表 SQL 要提前执行,raw_data表至少包含source_url(唯一索引,用于去重)和create_time,stat_data表按业务维度建聚合字段。

4.3 启动与验证步骤

启动顺序建议:先起数据库、执行建表脚本,再mvn spring-boot:run。验证分三步走:第一步看日志里 Spider 是否打印出「抓取成功」的条数;第二步查采集库select count(*) from raw_data,确认有增量;第三步查统计库,确认批量写入生效。如果采集库有数据而统计库没有,先看 buffer 是否没到阈值就结束了——Spider 跑完不会自动 flush,需要在任务结束时手动调一次flushStat(),这个坑我踩过,数据卡在内存里没落库。

提示:首次跑建议把种子 URL 换成一个结构简单的测试页,确认解析规则和落库链路都通,再换真实站点。

5. 避坑与排查:五个真实翻车现场

5.1 数据源串库,统计库被写进采集数据

现象:统计库里出现原始快照记录,字段对不上。原因:ThreadLocal的 key 没在finally里清理,线程池复用后下一个任务继承了上一个任务的 key。解决:所有set必须配对clear,最好封装成模板方法,用 try-finally 包死,别在业务代码里裸调。

5.2 字段抽取返回 null,整批数据被跳过

现象:日志显示抓取成功,但库里没数据。原因:站点改版后 CSS 选择器失效,get()返回 null,page.setSkip(true)把记录全跳了。解决:给关键字段加兜底选择器(CSS 失败再试 XPath),并在 Pipeline 里对 null 字段做计数告警,连续 N 条为空就发通知,别等跑完才发现。

5.3 连接池被打满,业务查询超时

现象:采集任务运行时,业务接口偶发超时。原因:采集库和业务库共用连接池,或者采集线程数远大于池大小。解决:采集库独立连接池,maximum-pool-size按「线程数 + 缓冲」估算;统计库的批量写入单独配小池,避免长事务占连接。

5.4 重复抓取,唯一索引报错中断

现象:日志里大量Duplicate entry异常。原因:WebMagic 的 Scheduler 默认去重只在本轮有效,重启后重新抓同一批 URL。解决:raw_data.source_url建唯一索引,插入用insert ignore或on duplicate key update,把去重交给数据库兜底,同时考虑把已抓 URL 持久化到一张crawl_seed表。

5.5 批量缓冲丢数据

现象:任务结束后统计库条数少于采集库。原因:buffer 没到 50 条,Spider 结束时不触发 flush。解决:在CrawlTask的run()之后显式调一次 flush,或者用@PreDestroy兜底;更稳的做法是给 buffer 加定时 flush,双保险。

6. 二次开发:换站点、加数据源、补监控

改造这套项目,最常做的三件事我按优先级排一下。换站点只动PageProcessor,把Site配置和选择器抽到配置类里,别硬编码在process方法中,这样新增站点就是加一个配置块加一个 Processor 实现,用工厂模式按站点标识取实例。加数据源在DataSourceConfig里多注册一个 Bean,DynamicDataSource的 target map 加一项,然后在 Pipeline 里多一次set调用,注意新库的连接池参数要单独评估,别直接复制。

补监控是我认为最值得投入的改造。采集类项目最怕「静默失败」——任务在跑、日志没报错、数据没进来。我的做法是在 Pipeline 里埋三个计数器:抓取条数、落库条数、跳过条数,用 Micrometer 暴露成指标,再配一个简单的阈值告警。下面是一个最小埋点示例:

private final Counter fetched = Metrics.counter("crawl.fetched"); private final Counter saved = Metrics.counter("crawl.saved"); private final Counter skipped = Metrics.counter("crawl.skipped"); @Override public void process(ResultItems items, Task task) { fetched.increment(); CrawlResult data = items.get("data"); if (data == null) { skipped.increment(); return; } // ... 落库逻辑 saved.increment(); }

验证改造是否生效,别只看日志,用「采集库条数 ≈ fetched,统计库条数 ≈ saved,两者差值 ≈ skipped」这个等式去对,对不上就说明中间有环节吞了数据。另外,改完 Processor 后先拿 10 条 URL 小批量跑一遍,确认字段映射和库表列对得上,再放开全量——这个习惯是从一次全量跑完发现时间字段格式不匹配、整表回滚重来的教训里养成的。从那以后我每次改解析规则,都强制先跑小批量对账,再上定时任务。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进