简介:一套基于SpringBoot、WebMagic和Mybatis的多数据源爬虫项目源码,面向Java后端及爬虫技术进阶开发者。项目将WebMagic的Downloader、PageModel、Pipeline等模块化组件,融入SpringBoot的依赖注入与AOP机制,通过Mybatis实现数据持久化,并给出多数据源动态切换的配置逻辑,以适应不同类型数据的存储策略。压缩包共134个文件,大小64.89MB,包含Java源码、class文件、Mybatis映射XML、多数据源配置及YML等工程文件,可在IDE中直接打开查看模块结构。资源已有1280人学习下载,附带ElasticSearch工具类和图书、小说、截图等爬虫示例,便于参考爬取逻辑与扩展思路。适合希望快速搭建企业级爬虫服务、理解SpringBoot与WebMagic整合及多数据源事务管理的开发者。
1. 先搞懂:这个爬虫项目到底在解决什么问题
如果你只是写个一次性脚本抓几百条数据,用不上 Spring Boot,也谈不上多数据源。真正让我决定把爬虫做成工程化方案的场景是这样的:抓取任务要跑成定时服务,抓下来的原始网页要存一个库,解析后的结构化数据要进另一个库,不同业务的抓取源还要分散到不同的 MySQL 实例,同时还要让团队成员能用统一的接口去启停爬虫任务、查看抓取进度。这个时候,"基于 SpringBoot + WebMagic + MyBatis + 多数据源" 就不再是技术名词的堆叠,而是一套可落地的爬虫工程骨架。它解决的核心问题是:把 WebMagic 的抓取能力和 MyBatis 的持久化能力放进 Spring Boot 的容器里管理,再用动态数据源路由把不同抓取结果写进不同数据库,而不需要在每个 Pipeline 里手工写 JDBC 连接。适合从单机脚本升级到工程化采集的开发者,也适合需要维护多个业务库、又不想重复造轮子的小团队。
2. 从零搭工程:Spring Boot + WebMagic + MyBatis 的依赖与骨架
2.1 选型理由:为什么用 WebMagic 而不是 HttpClient + Jsoup 自己拼
很多人在写爬虫时第一反应是 HttpClient 抓 HTML、Jsoup 解析,再自己管理线程池、重试、去重、URL 队列。这套组合在小规模场景下完全够用,但一旦要跑多线程、断点续抓、URL 去重、结果分类,自己手写框架的时间会远远超过写业务逻辑的时间。WebMagic 的优势在于它是一个成熟的垂直爬虫框架,四个核心组件(Downloader、PageProcessor、Scheduler、Pipeline)职责清晰,默认支持多线程和 URL 去重,而且可以直接嵌入 Spring Boot 作为普通 Bean 使用。它的设计有点类似 Scrapy,但你不用离开 Java 生态,和 MyBatis、Spring 的事务管理配合起来更顺。
MyBatis 在这套组合里的角色是持久层。相比 Spring Data JPA,MyBatis 对 SQL 有绝对控制权,尤其是面对爬虫产生的数据结构不稳定、经常要动态改字段的场景,写 XML 映射比自动生成 SQL 更直观。更重要的是,MyBatis 的批量插入(ExecutorType.BATCH)在写入几千上万条数据时性能优势明显,可以拉高爬虫的落地吞吐。多数据源则是最后一环:爬虫经常要按业务线分库,或者原始数据和清洗数据分离,用动态数据源可以避免在每个 Mapper 接口方法上重复指定 SqlSessionFactory。
2.2 依赖清单与实际工程结构
先用 Maven 搭工程,核心依赖如下。这里的版本号在多数 Spring Boot 2.x 项目里都能工作,如果你用的是 Spring Boot 3.x,注意把mybatis-spring-boot-starter换成适配版本。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>us.codecraft</groupId> <artifactId>webmagic-core</artifactId> <version>0.7.3</version> </dependency> <dependency> <groupId>us.codecraft</groupId> <artifactId>webmagic-extension</artifactId> <version>0.7.3</version> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.2</version> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>dynamic-datasource-spring-boot-starter</artifactId> <version>3.5.2</version> </dependency>工程结构我通常按业务边界分包,而不是按技术层分:
com.example.crawler ├── Application.java ├── config // 数据源配置、WebMagic 配置 ├── crawler │ ├── processors // 各站点 PageProcessor │ ├── pipelines // MyBatis Pipeline │ └── scheduler // 自定义 Scheduler(如果要去重) ├── entity // 数据库实体 ├── mapper // MyBatis Mapper 接口与 XML └── service // 启动爬虫、管理任务这里dynamic-datasource-spring-boot-starter直接解决了多数据源的路由与切换基础设施,比自己写 AbstractRoutingDataSource 省很多事,但它要求数据源配置走它自己的前缀,需要注意和原生 Spring Boot 数据源配置的差异。如果你不想引入这个 Starter,也可以自己实现一个基于AbstractRoutingDataSource的动态路由,后面第 3 章我会把两种方式的关节点都说清楚。
2.3 最小可运行的爬虫骨架:一个 PageProcessor + 一个 Pipeline
先跑通最小闭环。假设要抓一个公开的新闻列表页,抓取标题和链接。PageProcessor 负责解析页面,Pipeline 负责把结果打印出来,这一步先不接数据库。
@Component public class NewsProcessor implements PageProcessor { private Site site = Site.me() .setRetryTimes(3) .setSleepTime(1000) .setCharset("UTF-8"); @Override public void process(Page page) { List<String> titles = page.getHtml().xpath("//h2/a/text()").all(); List<String> links = page.getHtml().xpath("//h2/a/@href").all(); if (titles.size() == links.size()) { for (int i = 0; i < titles.size(); i++) { page.addResultItem("title", titles.get(i)); page.addResultItem("link", links.get(i)); } } } @Override public Site getSite() { return site; } }这里Site是 WebMagic 的抓取配置对象,setRetryTimes(3)表示重试 3 次,setSleepTime(1000)表示两次请求之间至少间隔 1000 毫秒。page.addResultItem会把数据存到 Page 的 ResultItems 里,后续传给 Pipeline。然后写一个 Pipeline 把数据打印出来:
@Component public class ConsolePipeline implements Pipeline { @Override public void process(ResultItems resultItems, Task task) { if (resultItems.get("title") != null) { System.out.println("标题: " + resultItems.get("title") + ", 链接: " + resultItems.get("link")); } } }启动爬虫的 Service 负责创建 Spider:
@Service public class CrawlerService { @Resource private NewsProcessor newsProcessor; @Resource private ConsolePipeline consolePipeline; public void start() { Spider.create(newsProcessor) .addPipeline(consolePipeline) .addUrl("https://example.com/news") .thread(4) .run(); } }注意.thread(4)启动 4 个抓取线程,WebMagic 的每个线程会独立执行 Downloader 和 Pipeline。在这个最小闭环里,Pipeline 是同步执行的,也就是每个抓取结果都会立即调用consolePipeline.process()。如果 Pipeline 里要做耗时的写库操作,建议把 Pipeline 做成异步,或者用批量收集的 Pipeline,否则抓取速度会被写库拖慢。
3. 用 MyBatis 写库:从单数据源到多数据源的演进
3.1 单库场景下 Pipeline 里写 Mapper 的常规写法
在最简单的情况下,只有一个数据库。我们在 ConsolePipeline 的基础改造成 MyBatisPipeline,把结果插入到 news 表。需要先定义实体类和一个 Mapper 接口。
public class News { private String title; private String link; // getter/setter 省略 }public interface NewsMapper { void insert(News news); }对应的 XML 映射文件放在 mapper 目录下:
<insert id="insert" parameterType="com.example.crawler.entity.News"> INSERT INTO news (title, link, create_time) VALUES (#{title}, #{link}, NOW()) </insert>Pipeline 的实现就很简单:
@Component public class NewsMybatisPipeline implements Pipeline { @Resource private NewsMapper newsMapper; @Override public void process(ResultItems resultItems, Task task) { String title = resultItems.get("title"); String link = resultItems.get("link"); if (title != null) { News news = new News(); news.setTitle(title); news.setLink(link); newsMapper.insert(news); } } }这里最关键的一点是:WebMagic 的 Pipeline.process 是在爬虫线程里执行的,而 Mapper 是 Spring 管理的单例 Bean。Spring 的 MyBatis 自动装配会把 SqlSessionTemplate 注入进来,它是线程安全的,所以直接用 @Resource 注入没问题。但是要注意,Pipeline 被 Spring 管理后,如果 Pipeline 内部有状态(比如计数器、List 缓存),多线程下会有并发问题,所以 Pipeline 最好做成无状态的。
3.2 多数据源的核心思路:动态路由 DataSource
单库写起来简单,但爬虫项目很快会遇到分库需求。常见情况有两种:一是原始网页和清洗后的数据分到两个库,避免脏数据影响查询性能;二是不同站点的数据归属不同业务部门,各有独立的 MySQL 实例。多数据源的实现思路其实不复杂:Spring 的AbstractRoutingDataSource允许我们在运行时根据某个 key 动态选择当前线程使用的 DataSource。
在动手之前,我建议先理解这个路由的调用链:Mapper 的执行要经过 SqlSession,SqlSession 从 DataSource 里拿 Connection,DataSource 如果继承了AbstractRoutingDataSource,它在getConnection()时就会调用determineCurrentLookupKey(),这个方法返回的 key 决定了实际使用哪个目标数据源。所以,只要在进入 Mapper 方法之前,把当前的 key 放到一个 ThreadLocal 里,路由就能自动切库。
如果你不想引入额外的依赖,完全可以手写这个路由。但实际工程里,我们用dynamic-datasource-spring-boot-starter更省心,它就实现了上述机制,还提供了@DS注解和DynamicDataSourceContextHolder手动切换两种方式。下面以它为例进行配置。
3.3 多数据源的配置与切换:注解 + ThreadLocal + AOP
使用dynamic-datasource-spring-boot-starter时,配置在application.yml里是这样:
spring: datasource: dynamic: primary: raw strict: false datasource: raw: url: jdbc:mysql://localhost:3306/raw_data?useSSL=false username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver parsed: url: jdbc:mysql://localhost:3306/parsed_data?useSSL=false username: root password: root driver-class-name: com.mysql.cj.jdbc.Driverprimary: raw表示默认数据源是 raw,所有没有显式指定 @DS 的 Mapper 都走 raw 库。strict: false允许切换到一个未声明的 key 时不报错(不过建议生产环境设为 true,方便尽早发现问题)。
接着在 Mapper 或 Service 方法上加注解:
@DS("parsed") public interface ParsedNewsMapper { void insert(ParsedNews parsedNews); }也可以加到 Service 类上,表示这个类里的所有方法都走某个库。但这里有个容易忽略的细节:@DS的切换是基于 Spring AOP 的,它默认拦截的是 public 方法,而且方法内部调用同类方法时,AOP 不会生效。比如一个 Service 方法里调用了同一个类的另一个方法,后者即使标了 @DS,也不会触发数据源切换,因为代理对象没有经过包装。
如果你更习惯手动切换,也可以用DynamicDataSourceContextHolder.push()和pull(),常见做法是在 Pipeline 里操作:
DynamicDataSourceContextHolder.push("parsed"); try { parsedNewsMapper.insert(news); } finally { DynamicDataSourceContextHolder.poll(); }这种方式的问题在于必须保证 finally 里释放线程变量,否则线程池复用线程时 key 会串到下一次请求。静态封装一个DataSourceSwitcher工具可以去缓解这个问题,但最安全的仍然是使用框架的 @DS 注解。
3.4 事务与多数据源:别让 @Transactional 失效
引入多数据源后,事务管理立刻成为最大的坑。默认的@Transactional用的是DataSourceTransactionManager,它只绑定了单一数据源。当你在一个事务里先写 raw 库,再切到 parsed 库,实际会出现两个事务,而不是一个原子事务。
这个问题的本质是:本地事务无法跨数据源,除非引入 JTA 或分布式事务。绝大多数爬虫项目其实不需要强一致,我更推荐“不跨库事务”的设计:要么一个事务只操作一个库,要么干脆去掉事务,用幂等插入来保证最终一致。比如抓取结果写入,可以先做去重再插入,即使重复执行,也不会产生脏数据。
如果你确实需要在某一步保证多条 SQL 同时成功或失败,请把这一步涉及的所有 Mapper 放到同一个数据源下,然后只在对应的 Service 方法上加@Transactional。例如:
@Service public class ParsedNewsWriteService { @Resource private ParsedNewsMapper parsedNewsMapper; @Transactional public void batchWrite(List<ParsedNews> list) { for (ParsedNews item : list) { parsedNewsMapper.insert(item); } } }这个类的方法不标 @DS,但 ParsedNewsMapper 接口上标了@DS("parsed"),所以执行时还是会走 parsed 库。@Transactional此时绑定的是由动态数据源派生出来的路由数据源,在事务开始时determineCurrentLookupKey()会先执行,拿到 parsed 对应的 Connection,后续 SQL 都复用这个连接,事务就能正常工作。这就是“先定数据源,再开启事务”的原则。
4. 多数据源实战:按业务把抓取结果分发到不同库
4.1 场景设计:库 A 存原始网页,库 B 存结构化数据
以一个比较典型的模拟项目X为例:我们要抓某行业公开的资讯站列表页,先把原始 HTML 完整存储到一个叫raw_data的库里,用于回放和排障;然后把解析后的标题、发布时间、正文摘要等结构化字段存到parsed_data的库里,供前端查询。两个库的表结构不同,写入时机也不同:原始 HTML 在 Downloader 拿到之后立即落库,结构化结果在 PageProcessor 解析完成后落库。
这个场景的工程难点在于:Pipeline 里没法一步到位同时拿到“原始 HTML”和“解析后的结果”。WebMagic 默认的流程是 Downloader 下载页面 -> PageProcessor 解析 -> Pipeline 持久化。原始 HTML 只有在 Downloader 阶段能看到,这时候 PageProcessor 还没执行。为了实现双库写入,我一般会用两个 Pipeline:第一个 Pipeline 专门负责把原始 HTML 写入 raw 库,第二个 Pipeline 负责把解析后的字段写入 parsed 库。但两个 Pipeline 的执行顺序是可以控制的,这正好绕开了上面说的阶段冲突。
4.2 自定义路由注解 @TargetSource 的完整实现
除了框架的 @DS,我更喜欢自己封装一层注解,原因在于团队里不同人写 Mapper 时容易把 @DS 注解忘在接口上,一旦默认数据源没有建对应的表,启动时就报错。手动封装可以强迫在调用入口显式声明目标库。
下面实现一个简单的@TargetSource注解和切面。注解作用在 Service 方法上,指定库名:
@Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface TargetSource { String value(); }切面基于 AOP 实现,在方法执行前 push 数据源,在方法执行后 pull 掉:
@Aspect @Component public class TargetSourceAspect { @Around("@annotation(targetSource)") public Object switchSource(ProceedingJoinPoint joinPoint, TargetSource targetSource) throws Throwable { DynamicDataSourceContextHolder.push(targetSource.value()); try { return joinPoint.proceed(); } finally { DynamicDataSourceContextHolder.poll(); } } }这里@Around指定了注解作为切点,Spring 会拦截所有标注了@TargetSource的方法。注意finally里的poll()必须执行,否则线程池复用线程时,ThreadLocal 里的 key 会被下一个任务读到,造成串库,这是动态数据源最常见的故障之一。
有了这个注解,Service 方法就可以写成:
@Service public class RawPageService { @Resource private RawPageMapper rawPageMapper; @TargetSource("raw") public void saveRawHtml(RawPage page) { rawPageMapper.insert(page); } }4.3 在 Pipeline 中切换数据源并完成批量写入
现在写具体的 Pipeline。原始 HTML 落库的 Pipeline 可以放在爬虫的 Downloader 之后、Processor 之前吗?WebMagic 并没有提供前置 Pipeline 的 API,所以我常用的做法是自定义 Downloader。这里为了聚焦多数据源的内容,我换成更简单的方案:自定义一个RawPagePipeline,它接收的 ResultItems 里包含“原始 HTML”的字段,但需要在 Processor 里把它原样传递。
实际上,WebMagic 的 PageProcessor 里也可以拿到原始 HTML,通过page.getHtml().get()获取,但此时已经经过了解析。如果确实需要 Downloader 阶段的完整 HTML,就重写 Downloader:
@Component public class MyDownloader implements Downloader { @Resource private RawPageService rawPageService; @Override public Page download(Request request, Task task) { // 这里用 HttpClient 下载,简化省略 String html = HttpUtil.get(request.getUrl()); Page page = new Page(); page.setRequest(request); page.setRawText(html); // 直接落库原始 HTML RawPage rawPage = new RawPage(request.getUrl(), html); rawPageService.saveRawHtml(rawPage); return page; } @Override public void setThread(int threadNum) { } }这样原始 HTML 在下载阶段就进了 raw 库,不需要在 PageProcessor 里做额外传递。这里的RawPageService使用了@TargetSource("raw")注解,确保这个方法里的 Mapper 走 raw 数据源。
结构化数据解析和落库的 Pipeline 则专注于写 parsed 库,为了效率使用批量收集:
@Component public class ParsedNewsPipeline implements Pipeline { @Resource private ParsedNewsService parsedNewsService; private Queue<ParsedNews> queue = new ConcurrentLinkedQueue<>(); @Override public void process(ResultItems resultItems, Task task) { String title = resultItems.get("title"); if (title == null) return; ParsedNews news = new ParsedNews(); news.setTitle(title); news.setLink(resultItems.get("link")); news.setPublishTime(resultItems.get("publishTime")); queue.offer(news); // 每积累到 100 条就批量写一次 if (queue.size() >= 100) { flush(); } } public void flush() { List<ParsedNews> list = new ArrayList<>(); ParsedNews item; while ((item = queue.poll()) != null) { list.add(item); } if (!list.isEmpty()) { parsedNewsService.batchInsert(list); } } }批量写服务:
@Service public class ParsedNewsService { @Resource private ParsedNewsMapper parsedNewsMapper; @TargetSource("parsed") public void batchInsert(List<ParsedNews> list) { parsedNewsMapper.batchInsert(list); } }这里有两个性能关键点:一是flush方法需要确保在爬虫停止时被调用一次,否则最后不足 100 条的数据会滞留在队列里。二是我用ConcurrentLinkedQueue是因为 Pipeline 的 process 会被多个爬虫线程并发调用。不过这个“积累 100 条”的做法在并发时可能同时多个线程凑够 100,导致多次 flush,不过因为队列是线程安全的,不会丢失数据,只是批量效果打折扣。更稳妥的做法是用LinkedBlockingQueue加一个定时批量任务。
4.4 验证数据确实进了正确的库
配置完成后,怎么确认切换生效?最简单的办法是分别查询两个库的表:
mysql -h127.0.0.1 -uroot -pUSE raw_data; SELECT COUNT(*) FROM raw_page; USE parsed_data; SELECT COUNT(*) FROM parsed_news;如果两个表都有数据,说明路由没有问题。但要注意:如果 raw 库里配置的表名和 parsed 库里不一致,或者字段不匹配,MyBatis 启动时不会报错,只有真正执行 SQL 时才会暴露。所以建议在每个 Mapper 的 XML 里写INSERT时要用完整的库表前缀吗?不需要,因为连接本身已经在对应的库上了。这里需要提醒的是:MyBatis 的 mapper XML 中 SQL 不能带库名,否则即使是多数据源,也会被强制写到指定库里,这在切换时会非常迷惑。
5. 爬虫接入多数据源时的避坑指南:五个高频翻车现场
5.1 坑1:动态数据源切换后事务回滚无效
现象:某个服务方法标注了@Transactional,方法内部调用了不同数据源的 Mapper,其中一步抛异常,但前一步写入的数据没有回滚,甚至两个数据源分别写入了部分数据。
原因:Spring 的声明式事务默认使用DataSourceTransactionManager,它只管理一个数据源上的连接。当你切换数据源时,事务管理器已经持有了旧数据源的连接,新的 SQL 走了另一个数据源,事务管不住第二个库。
解决:按照第 3.4 节说的,不要在同一个事务里跨库操作。要么拆分事务,要么让整个方法只操作一个数据源。如果非要跨库且要求原子性,只能引入消息表做本地消息 + 定时补偿,或者换成 Seata 那种分布式事务方案,但爬虫场景下真没必要。
5.2 坑2:WebMagic 的爬虫线程拿不到当前数据源
现象:在 Pipeline 里手动调用DynamicDataSourceContextHolder.push后,Mapper 执行时报“Cannot determine target DataSource for lookup key”,或者干脆走了默认库。
原因:动态数据源的路由依赖 ThreadLocal,而 WebMagic 默认的 Spider 是自定义线程池,它复用了线程。如果在 Pipeline 的某个异步回调里切换数据源,ThreadLocal 的值不会传递到子线程。更隐蔽的是,如果你在 process 方法里 push 了 key,但 process 方法异常退出,finally 块里的 poll 没执行,key 就残留在当前爬虫线程上,导致下一次 process 串库。
解决:不要在 Pipeline 里手动管理 ThreadLocal。把数据源切换上移到 Spring 管理的 Service 方法,使用自定义@TargetSource注解,由 AOP 统一管理 push/poll,保证即使异常也能释放。WebMagic 的 Pipeline 里只做数据收集,调用 Service 时由 AOP 切面去处理数据源上下文。
5.3 坑3:MyBatis 批量插入时主键生成策略冲突
现象:使用useGeneratedKeys="true"批量插入时,某些记录的主键没有回填,或者直接报主键冲突。
原因:在多数据源下,如果两个库的自增主键初始值相同,而批量插入又用了INSERT INTO ... VALUES ...多条,数据库自增主键不会因为库不同而互斥。更常见的是,爬虫数据里某些字段本身就带主键,比如业务编号,你又设置了数据库自增,两者一冲突就插入失败。
解决:优先不要依赖数据库自增主键,让业务主键由代码生成。如果确实需要自增,用SELECT LAST_INSERT_ID()在单条插入后回填。批量插入时删除useGeneratedKeys,改为显式指定业务主键字段。这样在不同的库里插入不会互相干扰。
5.4 坑4:多数据源下 PageHelper 分页插件失效
现象:在某个库上查询分页数据时,PageHelper 自动拼装的LIMIT条件失效,甚至报了无效的排序字段。
原因:PageHelper 通过拦截器截获查询,它绑定在 SqlSessionFactory 上。多数据源配置了多个 SqlSessionFactory 时,PageHelper 没有在每个 SqlSessionFactory 上都加载拦截器,导致部分库的分页不生效。
解决:使用dynamic-datasource-spring-boot-starter时,它会复用同一个 MyBatis 配置,但 PageHelper 插件仍然要检查。在 MyBatis 配置类里手动加上 PageHelper 的拦截器,或者直接避免在爬虫项目里用 PageHelper。爬虫的查询大多是简单的 COUNT + LIMIT,手写比引入插件更可控。如果已经引入,统一在配置中为所有 SqlSessionFactory 追加分页插件:
@Bean public ConfigurationCustomizer mybatisConfigurationCustomizer() { return configuration -> { PageInterceptor pageInterceptor = new PageInterceptor(); Properties properties = new Properties(); properties.setProperty("helperDialect", "mysql"); properties.setProperty("reasonable", "true"); pageInterceptor.setProperties(properties); configuration.addInterceptor(pageInterceptor); }; }注意这段配置要确认所有 SqlSessionFactory 都应用了同一个ConfigurationCustomizer。使用动态数据源 Starter 的多数据源场景下,它是一个全局配置,通常只需写一次。
5.5 坑5:连接池配置不当导致抓取速度越快越容易断连
现象:爬虫运行一段时间后,Mapper 执行 SQL 报Connection is not available, request timed out,或者数据库端出现大量Aborted connection。
原因:抓取线程数设置得过高,比如thread(16),而连接池的最大连接数只有 10。WebMagic 的 Pipeline 同步写库时,每个线程都会占用一个数据库连接,并且处理时间比抓取长,连接很快被耗尽。再加上多数据源的每个库都有自己独立的连接池,默认配置可能没有合理分配。
解决:把 Pipeline 改成批量异步写入,避免短连接频繁占用连接池;同时根据抓取线程数来调整连接池参数。常见做法是spring.datasource.dynamic.datasource.raw.hikari.maximum-pool-size设置为抓取线程数的 2 倍。另外,WebMagic 的Site.setSleepTime不要设成 0,给数据库留出写入的时间窗口。
6. 进阶:让这套爬虫更稳的两个习惯——抓取限速与数据校验
工程化的爬虫不只是把数据抓下来、写进去就结束了。运行一周后你一定会遇到反爬、重复数据、脏数据这三座大山。这里分享两个我后来补上的习惯,它们不复杂,但能显著降低维护成本。
第一个习惯是给 WebMagic 的 Site 配置加上合理的限速和超时。很多人会为了让抓取更快,把sleepTime设为 0,结果被对方法限制后又恼怒于连接被频繁重置。我通常会这样设置:
Site site = Site.me() .setRetryTimes(5) .setSleepTime(1800) .setTimeOut(10000) .setUserAgent("Mozilla/5.0 (compatible; MyCrawler/1.0)");sleepTime(1800)表示每次请求间隔约 1.8 秒,这个值不是玄学,是实测很多公开站点的访问频率限制都在 1 秒一次附近,留出余量能减少 60% 的 403 响应。setTimeOut(10000)是连接和读取超时,防止某个慢页面把线程挂死。如果抓的是内容更新稳定的站点,可以再配合setCycleRetryTimes控制循环重试次数。
第二个习惯是在入库前做数据校验和幂等控制。多数据源场景下,幂等不能依赖唯一索引,因为不同库不允许跨库约束。我常用的办法是在写入前先按业务主键查一次目标库,存在则更新,不存在则插入。写成 MyBatis 可以这样:
<select id="selectByBizId" resultType="ParsedNews"> SELECT id FROM parsed_news WHERE biz_id = #{bizId} LIMIT 1 </select>Pipeline 里批量收集数据后,每次 flush 时先取出所有待插入的 bizId,批量查询已存在的记录,再过滤出真正需要插入的数据。这个过程中要注意的是,查询和插入要落在同一个数据源里,也就是同一个 Service 方法内部,否则会出现查的是 A 库、插的是 B 库的翻车。我经历过一次因为 Service 方法里先调了一个不带 @DS 的查询 Mapper,又调了带 @DS 的插入 Mapper,导致所有数据都重复写入,从那以后我把这个校验逻辑固定封装在标注了@TargetSource("parsed")的方法内部,外部没法干预。
这套“SpringBoot + WebMagic + MyBatis + 多数据源”的方案走到这一步,已经不是一个爬虫脚本,而是一个可以持续运行的数据采集服务。它的边界在于:多数据源解决的是“分库存放”的问题,不是“分布式事务”的问题;WebMagic 解决的是“页面抓取流程”的问题,不是“分布式爬虫调度”的问题。如果你想把这些能力再进一步,方向可以延伸为:把 Spider 实例注册成启停可控的 Bean,加上任务调度框架,再配合数据质量检测,就能接进更完整的数据平台。希望这套实践能让你少踩几个坑,把精力放在真正有价值的数据处理上。
本文还有配套的精品资源,点击获取