
简介面向Java后端开发者与爬虫初学者的一套完整爬虫框架实战工程围绕SpringBoot整合WebMagic、Mybatis并落地多数据源方案解决单数据源难以兼顾缓存与关系型存储的痛点。压缩包共134个文件、约64.89MB以42个java源码与44个class编译文件为主体辅以16个xml配置、5个properties及yml等配置文件另有jsp页面与日志、工程元数据文件目录结构清晰便于对照阅读与二次开发。已有1290人学习下载。工程中可见ElasticSearchUtils、BookInfoCrawler、NovelCrawler、ScreenShotCrawler等抓取与工具类以及BaseDataSourceConfig、SecondDbDataSourceConfig等数据源配置类读者可据此掌握Downloader、Pipeline、Scheduler组件的定制方式、多数据源连接池配置与AOP事务切换思路并参考ElasticSearch写入实践快速搭建可扩展的采集与存储链路。1. 从一次爬虫项目启动失败说起Spring Boot WebMagic MyBatis 多数据源环境怎么搭上周帮一个做数据采集的朋友重启他的爬虫项目代码拉下来直接mvn spring-boot:run结果启动日志里一片红DataSource注入冲突、WebMagic的Spider拿不到Pipeline里的 Mapper、MyBatis 的SqlSessionFactory只认了一个库。这套 Spring Boot WebMagic MyBatis 多数据源的组合几乎是中小型爬虫项目的标配——Spring Boot 管生命周期WebMagic 管抓取调度MyBatis 管落库多数据源把「抓取元数据」和「业务结果数据」分开放。装不好不会报编译错误而是运行到一半数据写错库、爬虫线程池把连接池打满排查起来相当玄学。这篇记录的是我按版本锁定、依赖顺序、数据源隔离、验证跑通这条线走完的完整过程适合刚接手这类项目、卡在环境配置上的开发者照着复现。2. 版本与依赖清单先把 Spring Boot、WebMagic、MyBatis 的版本对齐这套环境翻车最多的地方不是代码是版本。WebMagic 的发布节奏和 Spring Boot 完全不在一个频道上MyBatis 的 Spring Boot Starter 又对 Spring Boot 大版本敏感三者随便挑「最新版」拼在一起十有八九在启动阶段就炸。我一般先把版本钉死再动手后面所有配置都围绕这张表来。2.1 我实际用的版本组合组件版本选它的理由JDK8 或 11WebMagic 0.9.x 在 JDK 8 上最稳JDK 11 需要额外注意反射相关告警Spring Boot2.7.x2.7 是 2.x 最后一个稳定线对DataSource自动配置行为可预期WebMagic0.9.00.9.x 是使用面最广的稳定版Spider、Pipeline、Downloader接口成熟MyBatis Spring Boot Starter2.3.x与 Spring Boot 2.7 匹配避免SqlSessionFactory自动配置冲突数据库驱动MySQL Connector/J 8.0.x与 MySQL 8 服务端匹配注意serverTimezone参数连接池HikariCPSpring Boot 内置不额外引 Druid减少多数据源下的配置面版本对齐的核心原则只有一条Spring Boot 定大版本MyBatis Starter 跟着 Spring Boot 走WebMagic 单独锁小版本。WebMagic 0.9.0 内部依赖的 HttpClient、Jsoup 版本较老如果项目里又引了新版 Jsoup 做页面解析容易出现NoSuchMethodError这种运行期报错比启动失败更难查。2.2 pom.xml 里的依赖顺序与排除项依赖不是引进来就行顺序和排除项决定了会不会出现类冲突。下面是我项目里pom.xml的关键片段!-- Spring Boot 父工程锁定整体版本线 -- parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version /parent dependencies !-- Web 与 JDBC 基础能力 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency !-- WebMagic 核心排除它自带的旧版 slf4j 绑定 -- dependency groupIdus.codecraft/groupId artifactIdwebmagic-core/artifactId version0.9.0/version exclusions exclusion groupIdorg.slf4j/groupId artifactIdslf4j-log4j12/artifactId /exclusion /exclusions /dependency !-- 需要注解式抽取时再加不需要就别引减少依赖面 -- dependency groupIdus.codecraft/groupId artifactIdwebmagic-extension/artifactId version0.9.0/version /dependency !-- MyBatis 与 Spring Boot 整合 -- dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.2/version /dependency !-- MySQL 驱动运行期才需要 -- dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId version8.0.33/version scoperuntime/scope /dependency /dependencies这段配置里有两个点值得说清楚。第一webmagic-core排掉slf4j-log4j12是因为 Spring Boot 默认用 Logback两个绑定同时存在会在启动时打印「Class path contains multiple SLF4J bindings」虽然不致命但日志会重复输出排查问题时干扰很大。第二webmagic-extension只在用TargetUrl、ExtractBy这类注解抽取时才需要纯Page.getHtml().xpath()的写法可以不引少一个依赖就少一类冲突。2.3 目录结构约定多数据源项目最容易乱的是包结构。我习惯按「数据源归属」而不是「技术分层」来切这样 Mapper 扫描路径和事务边界都好界定src/main/java/com/example/crawler ├── config │ ├── PrimaryDataSourceConfig.java │ └── SecondaryDataSourceConfig.java ├── primary # 抓取元数据任务表、URL 队列表 │ ├── mapper │ └── entity ├── secondary # 业务结果抓取到的结构化数据 │ ├── mapper │ └── entity ├── pipeline # WebMagic Pipeline 实现 └── CrawlerApplication.javaprimary和secondary两个包各自持有 Mapper配置类里用MapperScan分别指向避免一个MapperScan扫全项目导致 Mapper 绑错SqlSessionFactory。这个约定看起来是小事但后面排查「数据写错库」时包结构清晰能省掉大量时间。3. 多数据源配置两个 DataSource 怎么隔离、怎么绑定 Mapper多数据源的难点不在「配两个连接」而在「让每个 Mapper 明确知道自己该用哪个连接」。Spring Boot 的自动配置默认只认一个DataSource一旦你手动声明了第二个自动配置就会退让所有细节都得自己接管。这一章按「配置类 → 参数 → Mapper 绑定」的顺序落地。3.1 application.yml 里的双数据源参数先把连接参数写进配置文件用前缀区分避免键名冲突spring: datasource: primary: jdbc-url: jdbc:mysql://127.0.0.1:3306/crawler_meta?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: crawler_meta password: change_me driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 10 minimum-idle: 2 connection-timeout: 30000 secondary: jdbc-url: jdbc:mysql://127.0.0.1:3306/crawler_data?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: crawler_data password: change_me driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000这里有个高频坑多数据源场景下键名必须用jdbc-url而不是url。HikariCP 绑定的是jdbcUrl属性写成url时 Spring Boot 不会报错但连接池初始化时拿不到地址启动直接抛jdbcUrl is required。我第一次配的时候就是照抄单数据源的url写法卡了半小时。连接池大小也要分开考虑。primary存的是任务和 URL 队列读写频繁但单次数据量小10 个连接够用secondary是抓取结果落库批量插入多给到 20。两个池加起来 30 个连接要确认 MySQL 的max_connections留有余量否则爬虫并发一上来就是Too many connections。3.2 主数据源配置类主数据源要加Primary让没显式指定的事务和自动配置有个默认落点Configuration MapperScan( basePackages com.example.crawler.primary.mapper, sqlSessionFactoryRef primarySqlSessionFactory ) public class PrimaryDataSourceConfig { // 用 Primary 标记默认数据源未指定时优先注入它 Primary Bean(name primaryDataSource) ConfigurationProperties(prefix spring.datasource.primary) public DataSource primaryDataSource() { return DataSourceBuilder.create().build(); } Primary Bean(name primarySqlSessionFactory) public SqlSessionFactory primarySqlSessionFactory( Qualifier(primaryDataSource) DataSource dataSource) throws Exception { SqlSessionFactoryBean bean new SqlSessionFactoryBean(); bean.setDataSource(dataSource); // 指定该数据源对应的 Mapper XML 位置 bean.setMapperLocations( new PathMatchingResourcePatternResolver() .getResources(classpath:mapper/primary/*.xml)); return bean.getObject(); } Primary Bean(name primaryTransactionManager) public DataSourceTransactionManager primaryTransactionManager( Qualifier(primaryDataSource) DataSource dataSource) { return new DataSourceTransactionManager(dataSource); } }ConfigurationProperties(prefix spring.datasource.primary)负责把 yml 里的参数绑到DataSourceBuilder上省掉手写setJdbcUrl之类的代码。sqlSessionFactoryRef是MapperScan的关键参数它告诉扫描器「这个包下的 Mapper 全部用primarySqlSessionFactory」这是多数据源不串库的根本保障。mapperLocations按数据源分目录存放 XML路径写错时启动不会报错但运行期调用会抛Invalid bound statement所以路径要和实际目录严格对应。3.3 从数据源配置类从数据源配置和主数据源结构一致去掉Primary换掉前缀和扫描包Configuration MapperScan( basePackages com.example.crawler.secondary.mapper, sqlSessionFactoryRef secondarySqlSessionFactory ) public class SecondaryDataSourceConfig { Bean(name secondaryDataSource) ConfigurationProperties(prefix spring.datasource.secondary) public DataSource secondaryDataSource() { return DataSourceBuilder.create().build(); } Bean(name secondarySqlSessionFactory) public SqlSessionFactory secondarySqlSessionFactory( Qualifier(secondaryDataSource) DataSource dataSource) throws Exception { SqlSessionFactoryBean bean new SqlSessionFactoryBean(); bean.setDataSource(dataSource); bean.setMapperLocations( new PathMatchingResourcePatternResolver() .getResources(classpath:mapper/secondary/*.xml)); return bean.getObject(); } Bean(name secondaryTransactionManager) public DataSourceTransactionManager secondaryTransactionManager( Qualifier(secondaryDataSource) DataSource dataSource) { return new DataSourceTransactionManager(dataSource); } }两个配置类都写完后启动时 Spring 会创建两个DataSource、两个SqlSessionFactory、两个事务管理器。这里要注意方法参数上的Qualifier不能省。如果只按类型注入容器里有两个DataSource时会抛NoUniqueBeanDefinitionException报错信息里会列出两个候选 Bean 名看到这个报错基本就是漏了Qualifier。3.4 WebMagic 的 Pipeline 怎么拿到指定数据源的 MapperWebMagic 的Pipeline是普通 Spring Bean只要在构造时注入对应 Mapper 即可但要注意Spider的创建方式——如果用Spider.create(new MyProcessor())手动 newPipeline 里的依赖不会自动注入。我一般把Spider也交给 Spring 管理Component public class ArticlePipeline implements Pipeline { // 注入从数据源的 Mapper抓取结果写业务库 private final ArticleMapper articleMapper; public ArticlePipeline(ArticleMapper articleMapper) { this.articleMapper articleMapper; } Override public void process(ResultItems resultItems, Task task) { Article article resultItems.get(article); if (article ! null) { // 单条插入批量场景可改为攒批后 foreach 插入 articleMapper.insert(article); } } }ArticleMapper位于secondary.mapper包下被secondarySqlSessionFactory扫描所以这条写入链路天然落到业务库。如果 Pipeline 里同时要写元数据库比如记录抓取状态就再注入一个primary包下的 Mapper两个 Mapper 各自绑定各自的SqlSessionFactory互不干扰。这里的原则是Mapper 的包归属决定数据源不要在业务代码里手动切数据源手动切容易在事务里失效。4. 爬虫核心装配Spider 启动、线程数与抓取入口的落地写法数据源通了之后才轮到爬虫本体。WebMagic 的装配看起来简单但线程数、去重、启动时机这几个参数配错表现是「跑得动但跑不久」——要么内存涨、要么目标站点封 IP、要么任务重复抓。这一章把启动链路拆开写。4.1 Spider 的创建与核心参数Configuration public class SpiderConfig { Bean(destroyMethod close) public Spider articleSpider(ArticlePipeline articlePipeline, ArticleProcessor articleProcessor) { return Spider.create(articleProcessor) // 抓取入口可换成从元数据库读取待抓 URL .addUrl(https://example.com/list) // 抓取线程数按目标站点承受能力和本机资源调 .thread(5) // 每个站点两次请求的间隔单位毫秒 .setDownloader(new HttpClientDownloader()) .addPipeline(articlePipeline); } }thread(5)是抓取并发数不是越大越好。单机跑、目标站点没有明确限流时5 到 10 是比较稳的区间调到 20 以上常见结果是目标站点返回 429 或直接断连反而拖慢整体进度。addUrl这里写死了一个入口实际项目里我一般改成从primary库的 URL 队列表读取用for循环addUrl这样重启后能续抓。destroyMethod close保证应用关闭时 Spider 的线程池被回收不加的话本地开发反复重启会残留线程。4.2 Processor 里的抽取逻辑与异常兜底Component public class ArticleProcessor implements PageProcessor { // 列表页链接规则与详情页链接规则 private Site site Site.me() .setRetryTimes(3) .setSleepTime(1000) .setTimeOut(10000) .setUserAgent(Mozilla/5.0 (compatible; Crawler/1.0)); Override public void process(Page page) { // 列表页抽取详情链接加入待抓队列 if (page.getUrl().regex(/list).match()) { page.addTargetRequests( page.getHtml().xpath(//a[classitem]/href).all()); } else { // 详情页抽取字段抽取失败时跳过而不是抛异常中断 String title page.getHtml().xpath(//h1/text()).get(); if (title null) { page.setSkip(true); return; } Article article new Article(); article.setTitle(title); article.setUrl(page.getUrl().get()); page.putField(article, article); } } Override public Site getSite() { return site; } }setRetryTimes(3)和setSleepTime(1000)是防封的基本盘重试次数别设太高否则一个失效链接会拖住线程。page.setSkip(true)是抽取失败时的兜底跳过当前页而不是让异常冒到 Spider 层——WebMagic 对 Processor 里抛出的异常处理比较粗一个页面解析失败可能影响整批任务的状态记录。page.putField(article, article)的 key 要和 Pipeline 里resultItems.get(article)严格一致写错时 Pipeline 拿到 null数据静默丢失这种问题不看日志很难发现。4.3 启动时机与优雅停止爬虫不适合在CommandLineRunner里同步跑会阻塞应用启动。我一般用异步方式触发并留一个手动停止的入口Component public class CrawlerBootstrap implements ApplicationRunner { private final Spider articleSpider; public CrawlerBootstrap(Spider articleSpider) { this.articleSpider articleSpider; } Override public void run(ApplicationArguments args) { // 异步启动避免阻塞 Spring 启动流程 new Thread(() - articleSpider.run(), crawler-main).start(); } }ApplicationRunner在容器初始化完成后执行此时数据源、Mapper、Pipeline 都已就绪。用独立线程跑spider.run()主线程继续走完启动流程健康检查接口能正常响应。停止时调用spider.stop()它会等当前任务处理完再退出比直接kill -9安全——直接杀进程的后果是连接池里的连接没归还、正在写的数据可能半条落库这种脏数据后面清理起来很麻烦。5. 避坑与排查多数据源爬虫项目最常见的五类翻车这套环境的问题有个共同特征启动成功不代表配置正确很多错误要跑到第一条数据落库才暴露。下面五条是我实际踩过、并且带别人排查时反复出现的按「现象 → 原因 → 解决」记下来。5.1 启动报 jdbcUrl is required现象应用启动时 HikariCP 抛jdbcUrl is required with driverClassName但 yml 里明明写了连接地址。原因多数据源配置下把键名写成了url。单数据源时 Spring Boot 会自动把spring.datasource.url映射到jdbcUrl但自定义前缀 DataSourceBuilder的组合不会做这层映射。解决把spring.datasource.primary.url改成spring.datasource.primary.jdbc-url两个数据源都要改。改完重启日志里能看到 HikariPool 正常初始化的记录。5.2 Mapper 注入报 NoUniqueBeanDefinitionException现象启动时提示expected single matching bean but found 2: primaryDataSource, secondaryDataSource。原因某个Bean方法或构造器参数按类型注入DataSource容器里有两个候选无法决定用哪个。解决在所有注入DataSource、SqlSessionFactory、TransactionManager的地方补Qualifier(beanName)。配置类内部的方法参数最容易漏逐个检查一遍。5.3 数据写进了错误的库现象抓取任务正常跑但业务表里没数据去元数据库一看数据全写进了任务表所在的库。原因MapperScan的basePackages范围写大了比如主数据源配置类扫了com.example.crawler整个包把从数据源的 Mapper 也扫了进去后加载的SqlSessionFactory覆盖了绑定。解决两个MapperScan的包路径必须精确到各自的mapper子包不能有包含关系。改完后在启动日志里搜Mapped Statements collection确认每个 Mapper 的方法都注册在预期的SqlSessionFactory下。5.4 爬虫跑一段时间后连接池耗尽现象运行十几分钟后开始报Connection is not available, request timed out重启后又能跑一阵。原因Pipeline 里每条数据一个事务、一次连接获取抓取速度快于数据库写入速度时连接被长时间占用或者某处手动开了SqlSession没关。解决Pipeline 改成攒批写入比如每 50 条或每 2 秒 flush 一次用foreach批量插入同时确认没有手动openSession()的代码。连接池的maximum-pool-size不要盲目调大先看是不是写入模式的问题。5.5 抽取字段为 null 但无报错现象数据能落库但某些字段一直是 null日志里没有任何异常。原因XPath 或 CSS 选择器写错page.getHtml().xpath(...).get()返回 null代码里没做判空就直接 set 进实体或者putField的 key 和 Pipeline 里get的 key 不一致。解决在 Processor 里对关键字段做判空并打日志把当前 URL 和选择器一起输出putField的 key 抽成常量Processor 和 Pipeline 共用同一个常量杜绝手写字符串不一致。6. 装完怎么验证三条链路跑通才算环境搭好环境搭没搭好不看启动日志有没有报错看三条链路是否各自跑通元数据库能读能写、业务库能批量落库、爬虫能完整跑完一轮并优雅停止。我习惯按下面的顺序验证每一步都有明确的观察点。6.1 数据源连通性验证先不启动爬虫单独验证两个数据源。写一个临时的CommandLineRunner或者用测试类分别从两个DataSource拿连接执行select 1SpringBootTest class DataSourceSmokeTest { Autowired Qualifier(primaryDataSource) private DataSource primaryDataSource; Autowired Qualifier(secondaryDataSource) private DataSource secondaryDataSource; Test void bothDataSourcesConnect() throws Exception { // 分别取连接执行简单查询确认两个池都能拿到连接 try (Connection c1 primaryDataSource.getConnection(); Connection c2 secondaryDataSource.getConnection()) { assert c1.isValid(3); assert c2.isValid(3); // 打印实际连接的库名确认没有串库 System.out.println(primary - c1.getCatalog()); System.out.println(secondary - c2.getCatalog()); } } }getCatalog()打印出来的库名必须和 yml 里配置的一致这一步能直接抓出「两个数据源指向同一个库」的低级错误。isValid(3)的超时给 3 秒超过说明网络或连接池有问题。6.2 Mapper 绑定验证数据源通了不代表 Mapper 绑对了。对每个数据源各调一个 Mapper 方法观察 SQL 实际打到哪个库。最直接的办法是开 MyBatis 的 SQL 日志logging: level: com.example.crawler.primary.mapper: debug com.example.crawler.secondary.mapper: debug启动后调用一次articleMapper.insert(...)日志里应该出现 Preparing: INSERT INTO article ...同时确认这条 SQL 出现在secondary数据源的连接上。如果日志里 SQL 正常但数据没进业务库回到 5.3 检查MapperScan范围。6.3 爬虫完整跑一轮的观察点前两步通过后启动应用观察这几个点观察点正常表现异常时看哪里Spider 启动日志出现抓取线程启动、URL 入队记录SpiderConfig的addUrl是否为空页面抽取每个详情页有字段抽取日志Processor 的选择器与判空逻辑Pipeline 落库业务库行数随抓取增长5.3、5.4 两条停止调用spider.stop()后线程退出、连接归还destroyMethod是否配置跑一轮之后去业务库count(*)对比抓取日志里的成功条数两个数字对得上说明抽取、Pipeline、数据源绑定整条链路是通的。对不上就按差值定位差在抽取阶段看 Processor 日志差在落库阶段看 SQL 日志。6.4 我每次都会走一遍的习惯这套环境我前后配过好几次最省时间的做法不是一次配全而是先单数据源跑通爬虫再加第二个数据源。单数据源阶段确认 Spider、Processor、Pipeline 没问题加第二个数据源时只需要关注配置类和MapperScan问题范围小一半。从那以后我每次搭多数据源项目都强制先跑通单库版本再拆拆完必跑 6.1 和 6.2 两个验证不跳过。希望这套顺序能帮到你少在启动日志里绕圈。本文还有配套的精品资源点击获取