Spring Boot整合WebMagic与MyBatis多数据源:爬虫工程化落地指南

发布时间:2026/10/13 23:46:28
Spring Boot整合WebMagic与MyBatis多数据源:爬虫工程化落地指南 简介面向Java开发者的SpringBootWebMagicMybatis多数据源爬虫框架资源适合已掌握基础爬虫或Spring Boot、希望构建企业级可扩展采集系统的学习者。资源共134个文件涵盖java源码、class编译文件、mybatis映射xml、properties与yml配置等压缩包约64.89MB目录按功能模块划分便于快速定位与复盘。项目完整演示了WebMagic核心组件Downloader、PageModel、Pipeline、Scheduler与SpringBoot的整合方式同时通过BaseDataSourceConfig、SecondDbDataSourceConfig等配置类实现多数据源切换并利用AOP管理事务保证数据一致性Mybatis动态SQL则简化了持久层开发。源码包含小说、漫画、截图等多类站点爬虫实例及ElasticSearch工具类可直接参考其解析逻辑与存储策略。已有1280人学习下载能够帮助读者掌握从页面解析、数据持久化到多库切换的完整爬虫工程化流程。1. 把爬虫做成 Spring Boot WebMagic MyBatis 多数据源的项目到底解决了什么你手头那个爬虫大概率还停在一个public static void main里启动靠 IDE 点运行抓取循环靠while(true)硬撑数据落库靠 JDBC 手写挂了没人知道跑了几天数据写乱了也没人知道。把爬虫工程化成“基于 Spring Boot WebMagic MyBatis 多数据源”的一套项目不是我技术过剩而是爬虫这东西只要过了单机演示阶段就必然碰到三个问题生命周期没人管、持久化没人管、业务数据与抓取中间数据混在一起没人管。这篇文章就把这套技术栈为什么这么配、怎么落地、参数怎么设、坑在哪一次说清楚。适合已经写过简单爬虫、想把采集能力做成正经服务的 Java 开发者。2. 工程骨架先弄清楚 WebMagic 的四个组件再谈 Spring Boot 整合2.1 WebMagic 的核心抽象Downloader、PageProcessor、Scheduler、Pipeline 的职责边界WebMagic 是一个轻量级 Java 爬虫框架它的核心抽象其实就四个接口理解这四个东西的边界比记 API 重要得多。Downloader 负责把 URL 变成 HTML 字节流默认用 HttpClient你基本不用动它。PageProcessor 是唯一需要你认真实现的组件职责是“给定一个页面提取哪些 URL 继续抓、提取哪些字段作为结果”。Scheduler 管 URL 队列和去重默认的 QueueScheduler 在内存里维护一个 Hash 去重集合只管单机不重启场景。Pipeline 是结果出口PageProcessor 产出结构化字段后由 Pipeline 消费并写入 MySQL、ES 或者文件。这套抽象的价值在于抓取、解析、调度、持久化四个环节完全解耦。你想换存储只改 Pipeline想换抓取策略只改 PageProcessor想让多个爬虫任务共享 URL 调度可以换 RedisScheduler。Spring Boot 在这里的角色不是重写 WebMagic而是接管这些组件的生命周期、依赖注入和运行管理让爬虫从“一次性的 main 方法”变成“常驻的服务”。2.2 依赖选型pom 里带哪些包版本陷阱在哪一个最小可跑的工程依赖控制在五个左右就够了。Spring Boot 用 2.7.x 系列稳定且和 MyBatis Starter 兼容好。WebMagic 的坐标是us.codecraft注意它没有进 Spring Initializr需要自己去 Maven 中央仓库拉。核心包和扩展包建议一起引入扩展包里有注解模式、Redis 调度器这些你可能用得上的工具。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdus.codecraft/groupId artifactIdwebmagic-core/artifactId version0.7.3/version /dependency dependency groupIdus.codecraft/groupId artifactIdwebmagic-extension/artifactId version0.7.3/version /dependencywebmagic 0.7.3 有一个隐性问题它传递依赖了老版本的 httpclient和 Spring Boot 自带的 httpclient 版本不一致时可能出现NoSuchMethodError。我这个项目里的做法是显式声明一个较新的 httpclient 版本覆盖它版本号用 4.5.14兼容性和稳定性都验证过。如果你用的是 Spring Boot 3.x 和 jakarta 命名空间建议先降回 2.7.x因为 webmagic 的代码基于 javax 编写迁移成本不值得在初期承担。2.3 包结构把爬虫业务和基础设施从第一天就分开包结构直接决定这个项目三个月后谁还敢改。我惯用的分包方式是按“基础设施”和“业务采集”两条线走。基础设施只放数据源、切面、配置类这些通用逻辑业务采集层放每个站点的 PageProcessor、Pipeline 和对应的 Mapper。这样做的好处是你接第二个采集目标时只需要在 crawler 下新增一个包不需要改动任何基础设施代码。├── config/ # 数据源、MyBatis、爬虫线程池配置 ├── datasource/ # 动态数据源路由、ThreadLocal 管理 ├── aop/ # 数据源切换切面 ├── crawler/ │ └── article/ # 某个站点抓取规则 │ ├── ArticlePageProcessor.java │ └── ArticlePipeline.java ├── mapper/ # 按业务域拆分的 Mapper 接口与 XML ├── service/ # 落库、清洗、任务调度逻辑 ├── entity/ # 领域模型 └── SpiderApplication.java实体类和 Mapper 不要放在 crawler 包里面。PageProcessor 是抓取规则Mapper 是持久化规则它们变更的节奏完全不一样站点改版你会改 PageProcessor表结构变更你会改 Mapper混在一起以后 git 历史会非常难看。2.4 把 Spider 交还给 Spring 管理启动、关闭和 Bean 生命周期新手最容易翻车的地方是在 CommandLineRunner 里new Spider()然后直接跑。这么做的后果是PageProcessor 和 Pipeline 都由你手动 new 出来Spring 容器完全不知道它们的存在Autowired注入的 Mapper 或 Service 全是 null。正确做法是把 PageProcessor、Pipeline 和 SpiderRunner 全部交给 Spring 管理。Component public class SpiderRunner implements CommandLineRunner { Autowired private ArticlePageProcessor pageProcessor; Autowired private ArticlePipeline pipeline; Override public void run(String... args) { Spider.create(pageProcessor) .addUrl(https://example.com/article/list/1) .thread(4) .addPipeline(pipeline) .runAsync(); } }用runAsync()而不是run()是因为run()会阻塞当前线程而 Spring Boot 的 CommandLineRunner 在 web 场景下阻塞会导致一些你不想遇到的启动时序问题。runAsync()启动后配合 JVM 的 ShutdownHook在应用关闭时调用spider.close()释放下载线程和连接池避免端口占用和连接泄漏。这点在你用kill -15优雅停机时尤其重要。3. 解析层设计用 PageProcessor 把页面结构和业务数据解耦3.1 先定义领域模型再写选择器写 PageProcessor 前先把结果模型定下来。很多教程直接往ResultItems里塞 XPath 字符串结果临时跑没问题但一旦字段多了Pipeline 里拿到的是一堆无类型的字符串清洗和落库都很难受。我会先写一个干净的实体类字段类型明确再考虑页面里的选择器怎么映射。public class Article { private Long id; private String title; private String content; private String author; private Date publishTime; private String sourceUrl; // getter / setter 省略 }注意一点sourceUrl必须保留原始链接。不要觉得它占空间就省掉它是后续增量抓取、去重、异常排查的唯一依据。没有源 URL数据出了问题你连回查都做不到。3.2 三种解析方式的选型XPath、CSS 选择器与正则WebMagic 的 Selector 体系提供了 XPath、CSS 和正则三种解析方式选型原则很简单结构清晰的页面用 XPath带 class 和 id 的页面用 CSSXPath 抓不到的边界情况用正则兜底。下面是它们各自适合的场景参照。解析方式适合场景劣势典型写法XPath文档结构稳定的页面属性提取方便依赖 DOM 层级页面改版易碎//h1[classtitle]/text()CSS 选择器页面用 class/id 标记内容只能选节点属性提取稍弱div.content h1正则从 HTML 属性或 JSON 片段里提取难维护优先用作兜底articleId:(\d)实际操作中我 80% 的场景用 XPath因为 WebMagic 的 XPath 扩展支持//div[classcontent]/html()这种直接拿内部 HTML 的写法比 CSS 选择器拼接省事得多。不要把三种选择器混用在同一个字段上代码会变得没法读。3.3 列表页到详情页URL 管理是调度器的活抓取一个资讯站的常规路径是入口 URL 是列表页列表页里包含详情页链接和下一页链接。PageProcessor 里用一个正则区分的策略让同一个 Processor 同时处理两种页面。public class ArticlePageProcessor implements PageProcessor { private static final String ARTICLE_URL https://example\\.com/article/\\d; private static final String LIST_URL https://example\\.com/article/list/\\d; private Site site Site.me() .setRetryTimes(3) .setSleepTime(300) .setCharset(UTF-8) .setUserAgent(Mozilla/5.0 (Windows NT 10.0; Win64; x64)); Override public void process(Page page) { if (page.getUrl().regex(ARTICLE_URL).match()) { page.putField(title, page.getHtml().xpath(//h1[classarticle-title]/text())); page.putField(content, page.getHtml().xpath(//div[classarticle-content]/html())); page.putField(publishTime, page.getHtml().xpath(//span[classdate]/text())); } else if (page.getUrl().regex(LIST_URL).match()) { page.addTargetRequests(page.getHtml().links().regex(ARTICLE_URL).all()); page.addTargetRequests(page.getHtml().links().regex(LIST_URL).all()); } } Override public Site getSite() { return site; } }这段代码的关键是addTargetRequests与putField的分工。详情页命中后只产出字段不放 URL 继续抓避免进入死循环列表页只负责发现新链接不产生业务数据。regex匹配用的是links()方法它会自动从a href中抽取绝对路径并做补全比手写正则提取 href 再拼域名省事很多也少踩绝对路径和相对路径混用的坑。3.4 Pipeline 做出口抓取结果如何交给持久化层putField的结果会通过ResultItems传给 PipelinePipeline 的职责只有两个判断结果是否为空、把字段映射成实体类后走 Service 落库。不要在 Pipeline 里写 XPath 选择器也不要在 PageProcessor 里写任何数据库相关代码。Component public class ArticlePipeline implements Pipeline { Autowired private ArticleService articleService; Override public void process(ResultItems resultItems, Task task) { MapString, Object fields resultItems.getAll(); if (fields.isEmpty()) { return; } String title (String) fields.get(title); if (StringUtils.isBlank(title)) { return; } Article article new Article(); BeanUtils.populate(article, fields); article.setSourceUrl(resultItems.getRequest().getUrl()); articleService.insert(article); } }这里有个容易被忽略的细节resultItems.getRequest().getUrl()拿到的才是真实抓取页面 URLputField塞进去的字段不带这个信息需要单独取。为空判断也不可少列表页虽然不 put 字段但ResultItems的getAll()不会因为你是列表页就返回 null——它在每个页面都会触发 Pipeline。这个空判断挡住的是页面结构变更时产生的垃圾数据。4. 多数据源落地为什么爬虫项目需要两个数据源以上以及动态路由怎么做4.1 爬虫项目多数据源的三个典型场景很多人觉得多数据源是过度设计但爬虫这个领域恰恰相反。第一个典型场景是原始快照和结构化数据分离抓下来的页面原文可能包含异常 HTML、脚本片段直接混合进业务表会让查询和索引都变得很慢单独放一个快照库更合适。第二个场景是 URL 去重库与内容库分离去重库的读写频率远高于内容库高频的 select 和 insert 如果共用连接池容易出现连接等待。第三个场景是写主库、读从库拆分爬虫任务写库压力大但业务侧要实时查询主从分离后抓取任务不阻塞查询。用 Spring 自带的AbstractRoutingDataSource做动态路由是这几个场景最省事的解法。它本质上是一个代理数据源维护一个 key 到真实数据源的映射表每次getConnection()的时候根据当前线程的 key 决定返回哪个真实数据源的连接。这个设计的精妙之处在于MyBatis、事务管理器甚至连接池都只感知到这一个数据源路由逻辑对上层完全透明。4.2 AbstractRoutingDataSource ThreadLocal动态路由的核心代码实现动态路由只需要三个类。第一个是 ThreadLocal 管理类负责保存当前线程要使用的数据源 key。第二个是路由数据源继承AbstractRoutingDataSource重写determineCurrentLookupKey()。第三个是配置类把两个真实数据源放进路由表。public class DynamicDataSourceHolder { private static final ThreadLocalString KEY new ThreadLocal(); public static void setKey(String key) { KEY.set(key); } public static String getKey() { return KEY.get(); } public static void clear() { KEY.remove(); } }public class DynamicDataSource extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { return DynamicDataSourceHolder.getKey(); } }Configuration public class DataSourceConfig { Bean(name masterDataSource) ConfigurationProperties(prefix spring.datasource.master) public DataSource masterDataSource() { return DataSourceBuilder.create().build(); } Bean(name slaveDataSource) ConfigurationProperties(prefix spring.datasource.slave) public DataSource slaveDataSource() { return DataSourceBuilder.create().build(); } Bean public DynamicDataSource dynamicDataSource( Qualifier(masterDataSource) DataSource master, Qualifier(slaveDataSource) DataSource slave) { DynamicDataSource dynamicDataSource new DynamicDataSource(); MapObject, Object dataSources new HashMap(4); dataSources.put(DataSourceKey.MASTER, master); dataSources.put(DataSourceKey.SLAVE, slave); dynamicDataSource.setTargetDataSources(dataSources); dynamicDataSource.setDefaultTargetDataSource(master); return dynamicDataSource; } }三点值得展开说。第一setDefaultTargetDataSource是保底用的如果 ThreadLocal 里没有 key 或者 key 不存在就落默认数据源避免配置遗漏直接报错。第二AbstractRoutingDataSource在创建连接时会调用determineCurrentLookupKey()所以 ThreadLocal 中的 key 必须在getConnection()之前设置。第三DataSourceBuilder.create().build()会读取spring.datasource.master.*前缀下的 url、username、password 和连接池参数这些配置写在application.yml里。4.3 注解式路由用 AOP 控制数据源切换而不是手动 set直接在业务代码里DynamicDataSourceHolder.setKey()再try/finally clear()也能跑但每个方法都写一遍就太啰嗦了。更好的方式是用一个注解加一个切面把路由逻辑集中在一处。先定义一个注解然后在 Mapper 方法或 Service 方法上标注目标数据源。Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) public interface TargetDataSource { String value() default DataSourceKey.MASTER; }Aspect Component public class DataSourceAspect { private static final Logger log LoggerFactory.getLogger(DataSourceAspect.class); Before(annotation(targetDataSource)) public void switchDataSource(JoinPoint point, TargetDataSource targetDataSource) { DynamicDataSourceHolder.setKey(targetDataSource.value()); log.info(route dataSource - {}, method: {}, targetDataSource.value(), point.getSignature().toShortString()); } After(annotation(targetDataSource)) public void clearDataSource(TargetDataSource targetDataSource) { DynamicDataSourceHolder.clear(); } }这里有一个非常关键的顺序问题切面的Before必须在事务开启之前执行否则事务管理器已经通过默认数据源拿到了连接后面再切 key 也晚了。Spring 默认情况下 AOP 切面的执行顺序在事务切面之前所以常规写法没问题。但如果你给切面加了Order调整了执行顺序就有可能出现路由失效。这就是为什么我要求把TargetDataSource放在 Service 层方法上而不是 Mapper 方法上——Service 层是事务边界数据和路由在同一个边界生效日志也容易定位。4.4 MyBatis 多数据源的 Mapper 归置包必须彻底拆开动态数据源解决了连接路由问题但 MyBatis 的 Mapper 扫描又带来一个新的组织问题。我的建议是两个数据源对应两个 Mapper 包分别用MapperScan指定绝不互相包含。Configuration MapperScan(basePackages com.example.spider.mapper.master, sqlSessionFactoryRef masterSqlSessionFactory) public class MasterMybatisConfig { Bean public SqlSessionFactory masterSqlSessionFactory( Qualifier(dynamicDataSource) DataSource dataSource) throws Exception { SqlSessionFactoryBean factoryBean new SqlSessionFactoryBean(); factoryBean.setDataSource(dataSource); factoryBean.setMapperLocations(new PathMatchingResourcePatternResolver() .getResources(classpath*:mapper/master/*.xml)); return factoryBean.getObject(); } }从库的配置把包名和 XML 路径换成 slave 的对应值即可。注意这里两个 SqlSessionFactory 用的都是同一个 dynamicDataSource而不是各自绑定独立 DataSource——这样 Mapper 方法才能通过动态路由切换数据源。真正的隔离靠的是 Mapper 接口所在的包而不是数据源本身的拆分。如果你不小心把两个包的 XML 都放到同一个目录下最终结果是某个 SqlSessionFactory 找不到 statement启动直接报错。5. 避坑实录WebMagic MyBatis 多数据源最容易踩的 5 个翻车现场5.1 PageProcessor 里注入的 Mapper 是 null一跑就 NPE现象PageProcessor 里Autowired了一个统计接口爬虫启动后第一行日志就报空指针。 原因你在Spider.create(pageProcessor)之前用new ArticlePageProcessor()创建了对象Spring 的依赖注入根本管不到这个手动 new 的实例。凡是脱离容器管理的组件Autowired全部无效。 解决把 PageProcessor 和 Pipeline 都注册成 Spring Bean在 CommandLineRunner 里通过构造注入或直接Autowired获取。如果 PageProcessor 内部有状态比如统计计数把它改成无状态 Bean统计逻辑放到独立的 Service 里避免多线程共享可变量。5.2 MapperScan 包写太宽两个 SqlSessionFactory 启动就打架现象启动时抛org.apache.ibatis.binding.BindingException: Invalid bound statement (not found)或者提示某个 Mapper 重复注册。 原因两个MapperScan的basePackages都扫到了同一个包Master 和 Slave 的 SqlSessionFactory 都尝试为同一个接口创建代理产生冲突。XML 文件的位置如果不按 SqlSessionFactory 的mapperLocations精确隔离也会出现 statement 匹配错乱。 解决包结构按数据源彻底拆分Master 只扫mapper.master.*Slave 只扫mapper.slave.*XML 分别放在resources/mapper/master/和resources/mapper/slave/下。这条规则在项目第一天就定下来别等到 Mapper 多了才整理。5.3 事务方法内部切换数据源路由死活不生效现象Service 方法标了Transactional里面又调用了标了TargetDataSource(SLAVE)的查询方法结果查出来的数据还是主库的。 原因Spring 事务管理器在进入事务方法时会立刻通过数据源拿一个连接并绑定到当前线程的事务同步管理器。后续在这个事务内执行的所有 SQL 都复用同一个连接路由 key 再变也改变不了已经拿到的物理连接。 解决把数据源切换放到事务边界之前或者把需要切换数据源的逻辑从当前事务中拆出去。常见做法是让事务方法内部通过一个不带事务的 Service 代理去执行查询确保getConnection()发生在determineCurrentLookupKey()返回正确 key 之后。// 错误写法key 设置发生在事务内部连接已经绑定 Transactional public void wrongWay() { DynamicDataSourceHolder.setKey(DataSourceKey.SLAVE); slaveMapper.queryRecentArticle(); } // 正确写法先切 key再进入事务方法 public void rightWay() { DynamicDataSourceHolder.setKey(DataSourceKey.SLAVE); queryService.queryRecentArticleInTx(); DynamicDataSourceHolder.clear(); }这条路我踩过不止一次现象和原因都极其典型排查时先看事务边界再看切面执行顺序基本能定位。5.4 跑了几小时后连接池耗尽爬虫假死现象爬虫任务刚开始正常跑了几个小时后日志出现Connection is not available, request timed out after 30000ms抓取速度骤降为 0。 原因HikariCP 默认最大连接池是 10而 WebMagic 的下载线程池默认 4 个线程Pipeline 写库慢加上列表页处理线程也在拿连接10 个连接很快就被占满。更隐蔽的是某个事务里连接没有被及时释放导致连接泄漏。 解决先把连接池调大再考虑异步化。maximum-pool-size我一般设在 30 左右minimum-idle保持 5connection-timeout设 30000 毫秒。同时把 Pipeline 的写库操作改成异步消费抓取结果先放入阻塞队列由单独的写库线程池消费下载线程不再直接等待数据库响应。这么改完连接池占用率会明显下降。5.5 页面中文乱码XPath 提取全错现象标题、正文全部乱码XPath 选择器拿不到任何合法文本。 原因目标站的页面编码不是 UTF-8而是 GBK而且没有在响应头里正确声明。WebMagic 默认编码探测对部分老站点会失败最终用错误编码解码。 解决在Site.me()里显式声明setCharset(GBK)前提是你知道目标站是什么编码。不知道编码时可以在 Downloader 环节拿到原始字节后先探测再设置但不建议在 PageProcessor 里动态设置因为page.getHtml()已经完成了字符解码。最省事的做法是把目标页面的编码确认写成站点配置而不是靠运行时自动探测。6. 进阶习惯让数据源路由跟着任务走不跟线程走多数据源爬虫项目跑稳之后最见功力的一点是路由 key 的生命周期管理。ThreadLocal 在单个线程串行执行时很好用但爬虫几乎一定会用线程池线程池的复用机制会让上一个任务遗留的 key 被下一个任务继承轻则该写主库的写进了从库重则数据直接串库。我吃过大亏才总结出三个习惯。第一个习惯是路由 key 作为任务上下文的一部分在提交任务时显式传入而不是靠 ThreadLocal 隐式传递。具体做法是定义一个CrawlTaskContext里面放 taskId、targetSite、dataSourceKey随任务一起提交。第二个习惯是用TaskDecorator拦截线程池的任务提交在每个任务真正跑起来之前清空 ThreadLocal。Bean(crawlExecutor) public ThreadPoolTaskExecutor crawlExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(100); executor.setTaskDecorator(runnable - () - { DynamicDataSourceHolder.clear(); try { runnable.run(); } finally { DynamicDataSourceHolder.clear(); } }); return executor; }这段代码看起来是清空实际上是把路由决策权交还给任务本身。线程池里跑的是上一个任务的残留 key还是当前任务应该设置的 key完全由任务体在进入业务方法前自己决定。第三个习惯是给切面加一行路由日志每次切换都记录“从哪个 key 切到哪个 key、是哪个方法触发的”。排查串库问题时这段日志能在十分钟内定位到是哪个任务、哪条链路、哪个线程干的比半夜翻代码高效得多。坦白说多数据源这个功能在爬虫项目里做对并不难难的是在异步、多线程、事务交叠的情况下始终保持路由正确。我现在的底线是凡是线程池跑的爬虫任务路由 key 一律放进任务上下文不留在 ThreadLocal 里过夜凡是涉及事务的方法数据源切换永远写在事务边界之前凡是不确定路由对不对的线上问题先看切面日志再查代码。这套习惯让我把多数据源爬虫的串库概率降到了零希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询