前言
当我第一次接到这个需求时,觉得很简单:
每天早上 08:00 自动采集10000条商品数据,采集完成后立即验证数据完整性,然后生成报表推送到业务系统。
用 Scrapy 写个爬虫,加个定时任务,不就搞定了?
但实际执行时我发现了一个致命问题:
plain
08:00 - 触发采集任务
08:?? - 采集什么时候结束?
08:?? - 数据验证要等到什么时候才能开始?
这个看似简单的"等待采集完成"的需求,让我重新审视了爬虫框架的设计。
一、问题的本质:爬虫是异步的,但业务流程是同步的
业务流程的期望
我的业务逻辑是一个清晰的流水线:
plain
[采集数据] → [验证完整性] → [清洗] → [推送业务系统]
每一步都依赖上一步的完成信号。
爬虫框架的现实
但 Scrapy 这类框架的运行模式是:启动后在后台运行,你不知道它什么时候结束,拿不到执行结果,无法在结束的那一刻触发下一步。
核心矛盾:
- 爬虫框架是异步执行的(后台运行)
- 业务流程需要同步等待(等采集完成再继续)
二、两种方案:不同统计时机的选择
你在什么时候统计采集结果?
这个看似简单的问题,决定了整个架构的设计。
方案A:爬虫结束时统计 → 一次性进程模式
核心思路
让爬虫任务"有始有终",结束时自然获得统计数据。
架构流程:
plain
外部调度器 → 启动爬虫进程 → 绑定本次URL列表 → 爬虫处理完毕
→ 进程退出时生成统计 → 外部读取统计文件 → 继续下一步
实现思路
如何统计:
- Scrapy 自带的 Stats Collector 会记录所有指标(成功数、失败数、耗时等)
- 爬虫关闭时将统计写入文件或数据库
- 外部调度器通过"进程退出"这个信号,知道可以读取统计了
如何等待:
- 使用阻塞式的进程调用(subprocess.run)
- 进程不退出,调用方就一直等
- 退出后立即读取统计数据
这个方案的好处
- 实现极简:不需要 Redis、消息队列等中间件
- 完成信号明确:进程退出就等于任务完成,不存在模糊判断
- 统计数据可靠:Scrapy 原生的统计系统已经很完善
- 任务隔离好:每次都是全新进程,不会互相影响
- 调试友好:一个任务一个日志,出问题容易定位
但也有明显的问题
- 启动开销:每次都要初始化框架(加载配置、连接数据库、初始化下载器),可能需要2-5秒
- 不适合高频任务:如果每小时甚至每10分钟执行一次,频繁启停很浪费
- 重试逻辑复杂:如果第一次采集失败了200条,需要重新启动进程处理失败URL,状态传递麻烦
- 分布式实现困难:需要额外设计任务分发机制
适合什么场景
- 低频定时任务(每天1-2次)
- 任务边界清晰(明确的开始和结束)
- 单机或小规模爬虫
- 对实时性要求不高的场景
方案B:爬虫运行中统计 → 常驻进程模式
核心思路
爬虫永不停止,统计数据在运行过程中持续更新。
架构流程:
plain
爬虫进程常驻运行 → 监听任务队列(Redis/RabbitMQ) → 收到任务
→ 开始采集并实时更新统计 → 任务完成标记状态 → 外部轮询状态
→ 发现完成 → 读取统计 → 继续下一步
实现思路的演进
这个方案看起来简单,但实际上有很多细节要处理。我按从简单到复杂的顺序来说。
2.1 初级版本:简单轮询
统计方式:
- 在 Scrapy 的 Pipeline 或中间件中,每处理一个 item 就更新 Redis 计数器
- 成功数:
redis.incr('task:20241204:success') - 失败数:
redis.incr('task:20241204:failed')
完成检测:
- 外部调度器不断轮询 Redis:
while redis.get('task:20241204:status') != 'completed' - 爬虫在队列为空且等待一段时间后,设置状态为
completed
问题在哪:
- 完成判断不准确:队列空了不等于真的完成了(可能正在下载、可能有重试任务)
- 轮询延迟:每10秒查一次,意味着最多10秒的延迟
- 状态不一致风险:如果爬虫崩溃,Redis 状态可能是错的
2.2 进阶版本:心跳机制
改进思路:
- 不仅统计数量,还记录"最后更新时间"
- 爬虫每处理一条数据,就更新心跳:
redis.set('task:20241204:heartbeat', now()) - 外部调度器检测:如果心跳超过 N 秒未更新,且队列为空,则认为完成
这样改进后:
- 能检测爬虫是否卡死或崩溃
- 完成判断更可靠(结合心跳+队列状态)
但还是有问题:
- 参数难调:心跳超时设多久?太短会误判,太长会延迟
- 仍然是轮询:无法做到"完成瞬间通知"
2.3 高级版本:任务状态机 + 实时计数
核心改进:不再依赖"队列是否为空",而是追踪每个 URL 的状态。
状态机设计:
plain
每个 URL 的生命周期:
pending → downloading → success
↓ (失败)
→ retry_1 → retry_2 → ... → max_retry_exceeded
统计方式:
在 Redis 中维护详细的任务状态:
- 总任务数:
redis.hset('task:20241204', 'total', 10000) - 成功数:
redis.hset('task:20241204', 'success', 9800) - 失败数:
redis.hset('task:20241204', 'failed', 200) - 重试中:
redis.hset('task:20241204', 'retrying', 150) - 最终放弃:
redis.hset('task:20241204', 'abandoned', 50)
完成判断逻辑:
plain
if success + abandoned == total:
任务真正完成了
关键改进点:
- 不依赖队列状态:即使队列空了,如果
success + abandoned < total,说明还有任务在重试 - 精确感知完成:只有当所有 URL 都达到终态(成功或放弃),才算完成
- 支持复杂重试逻辑:可以清楚知道有多少在重试、重试了几次
实现细节:
- 启动任务时:将所有 URL 标记为
pending,记录total - 每次请求成功:更新
success++,从状态机中移除该 URL - 每次请求失败:检查重试次数
- 未达上限:标记为
retrying,重新加入队列 - 达到上限:标记为
abandoned++
- 未达上限:标记为
- 完成检测线程:不断检查
success + abandoned == total
方案B总体来看
这个方案的优势:
- 资源利用率高:进程复用,不用反复初始化
- 适合高频任务:如果每小时甚至更频繁,常驻模式更合适
- 支持分布式:多个爬虫进程从同一队列消费,天然负载均衡
- 重试逻辑内聚:失败的 URL 直接放回内部队列,不需要重启
但代价也很明显:
- 架构复杂:需要 Redis、消息队列、状态管理、心跳检测等
- 状态管理成本高:需要维护详细的任务状态,代码量大
三、真实场景暴露的新问题
先假设上面的方案都可以很快实现, 但是在实际使用中,还有一个致命的业务约束。
问题场景
假设我要采集 10000 条商品数据:
- 第一轮采集:9800 条成功,200 条失败(网络超时、反爬拦截等)
- 按照常规流程:标记"完成" → 验证 → 生成报表
但业务要求:
在进入清洗环节前,必须确保采集任务 100% 成功。
如果有失败的 URL,必须立即重试,直到成功或达到最大重试次数。
为什么有这个要求?
因为我的下游是实时数据分析系统:
- 数据一旦进入清洗,就会被推送到分析系统
- 分析系统基于"完整数据集"计算指标(价格趋势、库存预警)
- 如果数据不完整(缺了200条),计算结果就是错的,会触发业务误判
所以,采集阶段必须保证数据完整性。
方案A的困境
如果用一次性进程:
- 第一次启动:9800成功,200失败
- 需要重新启动处理失败的200条
- 如果还有失败...继续启动
- 可能需要启动3-5次才能100%完成
问题在于:
- 启动开销累积:每次2秒,5次就是10秒纯浪费
- 状态传递麻烦:失败的 URL 怎么传给下一次?写文件?存数据库?
- 重试逻辑分散:一部分在爬虫内,一部分在调度器,难维护
方案B的天然优势
常驻进程 + 运行中统计的架构,天生适合这种场景:
- 失败的 URL 直接放回爬虫内部队列
- 无需重启,持续处理直到达到最大重试
- 重试逻辑完全内聚在爬虫内部
所以对于"必须完整采集"的场景,方案B更合适。
四、方案B的深层挑战:如何精确感知"真正完成"?
使用常驻进程后,新的问题出现了。
完成的定义变复杂了
在有重试机制的情况下,"任务完成"的判断变得模糊:
plain
初始队列:10000 个 URL
↓
第一轮采集:9800 成功,200 失败 → 200个自动重试
↓
第二轮采集:180 成功,20 失败 → 20个继续重试
↓
第三轮采集:15 成功,5 失败 → 5个达到最大重试次数
↓
最终:9995 成功,5 彻底失败
↓
现在才算"真正完成"
这就回到了最初的问题:我怎么知道它"真的"完成了?
梦回同步爬虫。
五、第三种可能:调度器实时获取爬虫状态
那么,有没有一种框架,能同步获取到发布后的任务结果、是否成功等状态,同时消费者依然能自定义消费速度、并发数?
换句话说:生产者能获取消费者的结果,但同时消费者又是解耦的?
我想要的理想状态
plain
调度器提交任务 → 同步等待 → 爬虫持续运行并实时反馈统计
→ 所有URL达到终态 → 立即返回结果 → 开始下一步
有!
下篇续。