立项“MeteorSeed”这个名字,其实有点临时起意。当时自己正在维护一批分散的采集任务,目标数据源分布在几十个站点,数量多但单个站点数据量小,用重型采集框架总觉得杀鸡用牛刀。后来我把这套东西归纳成一个轻量分布式采集与任务播种框架,起名MeteorSeed,“种子分发、流星执行”。核心思路很简单:把一个一个采集任务当作种子投递出去,由轻量worker像流星一样快速划过、处理完再回去待命。这篇文章从头拆解这个项目的设计、实现和踩坑,给同样是“采集任务多而杂”场景的开发者一份可以直接抄的参考。如果你平时已经对Node.js、Redis有一定基础,但又不想为了一个边角料站点去维护一套复杂的采集平台,那这篇基本就是按你可复用的工程模板来写的。
1. 设计思路与架构拆解
1.1 “种子分发,流星执行”到底指什么
很多人第一眼看到MeteorSeed,会以为是不是和某个JS全栈框架有关系。真不是。起名时我想到的是两件事:种子(Seed)代表“可投递、可孕育”,流星(Meteor)代表“短暂、快速、成群划过”。一个任务从产生到结束,确实要经历这两种状态。
种子阶段,任务里只包含入口信息,可能是一组URL、一个时间范围、一批搜索关键词。体积很小,调度中心可以快速投递到队列。执行阶段,worker拿到种子后,展开成具体的采集、解析、结构化、落库动作,整个过程短则几秒长则几分钟,处理完上下文就销毁,不长期驻留。
这种分层设计有个天然优势:调度中心不需要知道任务内部细节,worker也不需要关心任务来自哪里。任务和执行之间的耦合被降到最低,团队协作也清晰——谁要数据谁就往队列丢种子,谁管采集谁就开着worker跑。后来我加了新的数据源,发现根本不用动worker代码,只需要在配置中心注册一个种子模板,这种解耦的价值就体现出来了。
1.2 选型决策:为什么不用老牌采集框架
其实最早我试过Python系的采集框架,功能确实全,自带调度、去重、中间件,但用下来有几个别扭的地方。
第一,框架对“大规模单站点深度抓取”很强,可我的场景是“小站点多、规则差异大”。每个站点解析规则、频率限制、代理要求都不同,在框架里写成中间件反而绕。
第二,部署和运维偏重。Python环境、各类依赖、无头浏览器支持都得伺候,容器化打包也比Node麻烦。
第三,框架整体调度能力是内置的,想做“种子分级、结果幂等回传”这种自定义流转,反而要绕开框架本身的机制。
所以最终决定自建轻量框架。Node.js的异步非阻塞IO天然适合网络采集场景,复杂页面需要无头浏览器时,用独立子进程池管理也能兜底。整体技术栈非常克制:Node.js加ioredis加MongoDB,不够再加ClickHouse。
队列中间件选了Redis而不是RabbitMQ或Kafka,原因很实在:第一,Redis本来就是基础设施,不用额外引入组件;第二,任务并发量远没到Kafka那种日志型消息系统的规模;第三,Redis 5.0之后自带Stream数据结构,配合消费组可以实现“至少一次消费加手动确认”,正好够用。
| 对比维度 | 老牌重型采集框架 | MeteorSeed方案 |
|---|---|---|
| 安装部署 | 环境依赖重,容器化要处理很多细节 | Node.js单一运行时,Docker镜像小而快 |
| 调度能力 | 内置但定制复杂 | 自定义种子分层,Redis Stream做队列 |
| 规则配置 | 多写代码/中间件 | JSON配置外置,热加载 |
| 水平扩展 | 集群组件多 | worker副本直接横向扩 |
| 适用场景 | 大规模单站深度抓取 | 多站点、小数据量、规则多变 |
1.3 四个模块:调度、执行、回传、配置
MeteorSeed拆成四个核心部分:
- dispatcher,调度中心:接收管理后台或API写入的种子,做校验、去重、拆分,然后投递到Redis Stream。
- worker,执行节点:消费任务消息,执行请求、解析、抽取、回传。
- sink,结果回传:接收worker的结构化结果,做规范化、幂等去重、写库。
- config,配置中心:用JSON/YAML维护站点规则、代理池、全局并发配置。
单个任务流大概是:分析师写一个种子(site_id、URL列表、解析模板ID、优先级),dispatcher把它展开成N个独立任务消息,写入Redis Stream;worker消费消息并执行“请求—解析—抽取”,失败则按策略重试;结果回传sink,sink做幂等去重后落库。
这个架构看着略有分层,但实际操作中帮了大忙。有一次目标站点改版,解析选择器全失效,程序员只需要在config中心更新选择器配置,触发热加载,worker无感切换。整个过程不需要改一行代码,也不需要重启任何服务。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心实现细节与实操要点
2.1 任务先分两层:种子任务和采集任务
最开始我图省事,把一个任务里混入所有字段,入口、抓取规则、回传地址全放一起。结果发现两个问题:一是调度中心不得不感知执行细节,二是任务变更时很难做版本管理。后来把任务分成两层,开发体验彻底顺了。
种子任务(seed task)字段是:seed_id、site_id、task_type、payload(URL列表或关键词)、schedule、priority、timestamp。它的定位是“描述一类数据从哪来、多久抓一次”。
采集任务(exec task)字段是:task_id、seed_id、url、method、headers、parse_rule、retry_count、created_at。它的定位是“针对一个具体资源的最新一次抓取动作”。
这套分层带来的实际收益是:某个站点每天更新一次,我只需要每天向队列投一个种子任务,dispatcher自动展开成几十个采集任务。采集过程中某一个URL失败了,重试的是展开后的采集任务,和种子本身没关系。统计调度效果时按seed_id聚合,排查单条数据时按task_id定位,各不耽误。
2.2 Redis Stream队列:让每个任务都有回执
Redis Stream是这套系统的核心。它不像List那样“读完就删”,而是每个消息都有ID,消费组内部分为“已读未确认”和“未读”两部分。常用命令如下:
- XADD queue:exec * payload '...':追加消息。
- XGROUP CREATE queue:exec worker_group 0:创建消费组。
- XREADGROUP GROUP worker_group consumer_1 COUNT 10 STREAMS queue:exec >:读取新消息。
- XACK queue:exec worker_group message_id:确认消息处理完毕。
用>表示只读取新消息,XACK确认已处理。这种机制天然支持“至少一次”:消息只要没被ack,就一直躺在Pending列表里。worker处理到一半崩溃,重启后这些Pending消息可以重新投递给其他消费者继续处理。配合XAUTOCLAIM(Redis 6.2+)可以自动接管长时间未确认的消息。
要注意的一点是,Stream消息体不要塞入大对象。我的经验是队列消息只存JSON字符串,控制在KB级别。网页HTML等大体量数据不经过队列,由worker解析完直接落库。这么设计是为了避免Redis内存和网络带宽被“大消息”浪费。我还习惯在XADD时加MAXLEN ~ 100000近似裁剪,防止队列无限增长把Redis内存撑爆。这是我在实际压测中调整出来的参数,100万条积压可能意味着消费端已经出问题了,留太多反而掩盖故障点。
2.3 并发控制:令牌桶与信号量
Node.js单线程,但异步IO并发等价于“同时挂起多个网络请求”,并发采集本质上是IO并发,不是多线程。我用一个Semaphore类控制同时处于网络中的请求数:获取信号量、发起请求、完成后释放。
javascript复制class Semaphore {
constructor(max) {
this.max = max;
this.count = 0;
this.waiting = [];
}
async acquire() {
while (this.count >= this.max) {
await new Promise(r => this.waiting.push(r));
}
this.count++;
}
release() {
this.count--;
const next = this.waiting.shift();
if (next) next();
}
}
默认并发数我设置为10到20,对大多数中小站点都能稳定跑。有些站点比较敏感,我会单独为它的site_id配置并发上限1到3。有人可能会问,为什么不用现成的库,我的回答是:这个信号量只有30行,自己写的控制逻辑更透明,排查问题时一眼看明白。
2.4 去重策略:布隆过滤器加精确集合
去重要处理两个维度:任务去重和结果去重。
任务去重放在dispatcher里。先用布隆过滤器快速判断URL是否投递过,命中后再查Redis里的精确去重集合,集合的key是URL的SHA256哈希。布隆过滤器只做第一层筛查,用15bit/元素配合4个哈希函数,误判率大概在1%,对海量URL预筛选足够。精确集合的作用是避免布隆过滤器误判导致漏投。
结果去重放在sink里。用“site_id + 数据主键”做幂等键,数据库加唯一索引保证重复提交只写一次。这样即使worker因任务重试提交同一份数据,也不会产生重复记录。
这个双层去重方案的思路,和缓存常用的“先Bloom后精确”是一回事。单靠布隆过滤器会漏,单靠精确集合在大规模场景下成本又高,两层配合把成本都控制住了。
2.5 采集执行流程:请求解析抽取落库
worker执行一次任务的完整流程:
- 解析任务消息,拿到URL、headers、解析规则。
- 从代理池获取可用代理,失败则换下一个。
- 令牌桶取令牌,控制对目标站的请求频率。
- 发起请求,设超时默认10秒,跟随重定向,记录状态码。
- 按解析规则抽取字段,用cheerio处理静态HTML。
- 数据标准化,比如日期格式化、URL相对路径转绝对路径、数字字段去千分位。
- 回传sink。
- XACK确认消息。
抓取逻辑绝对不硬编码,解析规则全部外置到config,用JSON描述选择器。例如:
json复制{
"site_id": "example_news",
"selectors": {
"title": "h1.article-title",
"content": "div.article-content",
"author": ".author-name",
"publish_time": "time[datetime]"
}
}
站点改版只需要更新配置再热加载,worker不用重启。这个设计坚持下来之后,日常维护成本降了一大半。需要补充的是,遇到JS动态渲染页面,cheerio拿不到渲染后DOM,我会先调用无头浏览器渲染脚本,再拿渲染后的HTML进解析模块。无头浏览器非常吃内存,我一般只对特定站点开启,并且放到独立进程池,处理完强制回收,防止worker主进程内存被拖垮。
3. 实操过程与核心环节实现
3.1 最小环境:Docker编排起一套
部署环境我用docker-compose管理,组件包括Redis、MongoDB、dispatcher、worker、sink。最小配置大致如下:
yaml复制version: "3.9"
services:
redis:
image: redis:7-alpine
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
ports:
- "6379:6379"
mongo:
image: mongo:6
ports:
- "27017:27017"
dispatcher:
build: ./dispatcher
environment:
REDIS_URL: redis://redis:6379/0
depends_on:
- redis
worker:
build: ./worker
deploy:
replicas: 2
environment:
REDIS_URL: redis://redis:6379/0
MONGO_URL: mongodb://mongo:27017
CONCURRENCY: "12"
depends_on:
- redis
- mongo
注意worker和dispatcher之间不直接通信,全部走Redis。这就是去中心化的好处:只要Redis扛得住,worker副本可以随便加。Redis我开了持久化和内存上限,内存策略用allkeys-lru,防止意外膨胀把宿主机器内存拖垮。
3.2 dispatcher实现:种子展开与去重投递
dispatcher的核心是接收种子,展开URL列表,跳过已处理的URL,把任务写进Stream。用ioredis实现很直接:
javascript复制const { Redis } = require('ioredis');
const crypto = require('crypto');
const redis = new Redis(process.env.REDIS_URL);
function hashUrl(url) {
return crypto.createHash('sha256').update(url).digest('hex');
}
async function submitSeed(seed) {
const urls = await expandSeed(seed);
for (const url of urls) {
const existed = await redis.sismember('seen:url', hashUrl(url));
if (existed) continue;
const task = {
task_id: `${seed.seed_id}_${Date.now()}_${Math.random().toString(36).slice(2)}`,
seed_id: seed.seed_id,
url,
site_id: seed.site_id,
parse_rule: seed.parse_rule,
retry_count: 0
};
await redis.xadd('queue:exec', 'MAXLEN', '~', 100000, '*', 'payload', JSON.stringify(task));
await redis.sadd('seen:url', hashUrl(url));
}
}
MAXLEN ~ 100000这里的~是近似裁剪,Redis执行时不会精确到字节,但会把队列控制在十万条消息附近。expandSeed函数实际就是从外部数据源读取URL列表,这里省略细节。一个实用经验是:expandSeed要做分页和节流,避免一次性把几十万URL全拉进内存,我是分批处理,每批500个URL投递完再取下一批。
3.3 worker执行器:消费、执行、确认
worker的核心循环是XREADGROUP加XACK的配对。关键代码如下:
javascript复制const { Redis } = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);
const concurrency = Number(process.env.CONCURRENCY || 10);
let active = 0;
async function handleTask(task) {
const html = await fetchPage(task.url, task.parse_rule);
const data = extract(html, task.parse_rule);
await pushToSink(task, data);
}
async function consume() {
while (active < concurrency) {
active++;
const res = await redis.xreadgroup(
'GROUP', 'worker_group', `worker_${process.pid}`,
'COUNT', 1, 'STREAMS', 'queue:exec', '>'
);
if (!res || res.length === 0) {
active--;
await sleep(200);
continue;
}
const { messageId, message } = res[0][1][0];
try {
const task = JSON.parse(message.payload);
await handleTask(task);
await redis.xack('queue:exec', 'worker_group', messageId);
} catch (err) {
await handleError(messageId, message, err);
} finally {
active--;
}
}
}
setInterval(consume, 10);
这段代码的核心点在于:没有XACK的消息永远躺在Pending列表里,这就是崩溃恢复的基础。fetchPage失败时,我会根据HTTP状态码区分处理策略。429和403说明被对方限流,应当sleep一段时间再重试;5xx是服务端问题,正常重试;404和410说明资源不存在,直接标记失败不再重试。这个细节显著提升了整体吞吐,因为不会在无效URL上反复消耗时间。
3.4 sink幂等实现
sink的任务是接收worker回传的结构化数据,做归一化、去重、落库。幂等方式很朴素,用站点ID加数据主键作为文档_id,执行upsert:
javascript复制async function pushToSink(task, data) {
const idKey = `${task.site_id}:${data.unique_key}`;
await mongo.collection('items').updateOne(
{ _id: idKey },
{ $set: { ...data, task_id: task.task_id, updated_at: new Date() } },
{ upsert: true }
);
}
这里的unique_key由站点页面的实际主键生成,比如新闻资讯用“文章URL的哈希”,商品页用“商品ID”。因为做了upsert,重复提交不会重复存储,这是保证“至少一次不重”的最后一道防线。
3.5 运行验证
全部起起来之后,往dispatcher的接口提交一个种子:
bash复制curl -X POST http://localhost:3000/dispatch \
-H "Content-Type: application/json" \
-d '{
"seed_id": "seed_001",
"site_id": "example_news",
"urls": ["https://example.com/news/1", "https://example.com/news/2"],
"parse_rule": "example_news_rule"
}'
观察worker日志,理想情况是:
code复制worker_123: start task seed_001_xxx
worker_123: fetch ok https://example.com/news/1 (200, 4521 bytes)
worker_123: extract 3 fields
worker_123: sink acked task_xxx
如果长时间没有日志,先查三个地方:Redis里有没有消息、消费组是否存在、目标站是否在限速。用XLEN queue:exec看积压量,用XINFO GROUPS queue:exec看消费组状态。这三个命令基本能定位90%的问题。
4. 常见问题与排查方案
4.1 队列堆积,worker消费不过来
队列积压最常见原因是并发数太小或者某个站点响应极慢,worker全被超时拖住。我遇到过一次某目标站突然变慢,单请求平均响应从2秒变成30秒,并发10的worker全部卡在等待上,队列积压从几百涨到几万。
排查路径是先看XLEN queue:exec确认积压量,再看worker日志里响应时间分布。解决方式有几个:对慢站点单独设置更小超时,比如5秒;把全局并发数调大;再不行就加worker副本,docker compose scale worker=5。
给慢站点单独限流是经验之谈:不要用一套并发参数打天下,不同的站点对压力的容忍度完全不同。
4.2 任务重复执行
重复执行的原因是worker在fetch完成后、XACK之前崩溃,或者Pending消息被XAUTOCLAIM重新投递。这是“至少一次”语义的正常副作用。
可以完全依靠sink的幂等来防重,但有些场景需要避免浪费请求,比如同一个URL已经抓到了,重放时其实没必要再抓一次。
我用的方案是租约机制:fetch前把task_id写进Redis的处理中集合,处理完删除。如果重放时发现task_id仍在该集合且时间超过租约期,说明前一个worker卡死了,此时可以重新接管。实际操作中,租约期设置为任务超时时间的2倍最稳妥。
4.3 worker内存持续上涨
这是Node进程最常见的问题。原因通常是:HTTP keep-alive连接句柄泄漏、无头浏览器子进程没回收、日志对象积累。
解决办法:每处理完一批任务打印process.memoryUsage()打点;无头浏览器走独立进程池并设超时回收;使用全局http agent时要设置maxSockets上限,避免开太多连接不释放。
我踩过最狠的一次坑是连续跑了一周,内存从200MB涨到2GB,最后OOM重启。当时查遍了代码,结果是某个第三方解析库内部保存了引用,后来给那个模块包了一层每次处理完强制gc才稳下来。
4.4 目标站返回429/403
返回429和403说明频率太高、UA不对或缺少必要Cookie。处理优先级我排成四条:
- 全局限速,令牌桶每毫秒一个,从源头控制频率。
- 代理池,针对被封站点自动切换出口IP。
- 模拟UA池,从配置里随机取,不要每次都用同一个。
- 需要Cookie的站点,先访问首页拿Cookie再带token访问目标页。
关于随机延时设置多少,我的经验是1到3秒之间,配合指数退避。429返回后,等待时间设为2的retry次方秒再加随机数,效果远好于固定延时。
4.5 解析结果出现空字段或数据错位
这是站点改版最典型的表现。选择器突然失效、HTML结构变化,都会导致结果全是空字段。
解决方式是在sink里增加字段级校验,核心字段为空视为解析失败,写进“异常队列”而不是直接丢弃。选择器配置里加校验器,例如title字段为空就触发告警。这样能在质量问题扩大前发现。
我习惯在配置中心给每个解析规则加一个version字段,站点改版时升级版本号,方便回溯历史数据对应哪个版本的解析逻辑。
4.6 其他高发问题速查
| 现象 | 首要怀疑点 | 快速验证 |
|---|---|---|
| 无消费日志 | 消费组不存在 | XINFO GROUPS queue:exec |
| 队列积压增长 | 并发不足 | XLEN queue:exec |
| 重复入库 | 缺少幂等键 | 查Mongo唯一索引 |
| 内存上涨 | socket泄漏 | lsof -i 查看连接数 |
| 目标站429 | 请求频率太高 | 看响应头 Retry-After |
| Redis连接被拒 | maxmemory满或连接数超限 | INFO memory 和 INFO clients |
这些问题的共性是:只要任务分层清楚、消息确认机制完整、幂等键覆盖到位,大部分都能快速定位。怕的是出问题时连日志都没有,那就只能靠盲猜了。
5. 性能调优与扩展经验
5.1 先定吞吐目标再谈优化
MeteorSeed设计目标很实际,单worker支撑50到100QPS就够了,不追求百万级并发。真要百万级任务量,应该考虑更重的消息系统,而不是一个自建轻量框架。
性能优化的前提是有可量化的目标。我习惯在压测环境先用1000个种子任务压一轮,记录P50、P95响应时间和QPS,任何改动后跑同一套压测,对比数据说话。没有基准数据的优化都是耍流氓。
5.2 水平扩展的边界
系统里四个模块的扩展边界完全不同:
- worker可以无脑扩展,瓶颈通常出在目标站点而不是自己系统。
- dispatcher单实例足够,因为它的计算量很小,只做展开和投递。
- sink瓶颈在数据库写入,可以改成批量插入,或者引入写入缓冲区。
- Redis在积压量大时会成为瓶颈,需要监控内存和连接数。
我实际运行中的配置是dispatcher 1个,worker按目标站点的数量2到5个,sink 1个。这套配置顶住了日均百万级别的种子任务展开。
5.3 从单机到多机的几个坑
从单机迁移到多机时,有四个坑值得注意。
第一,消费组名称必须全局唯一,消费者名要加上hostname或PID,避免不同机器上的consumer重名导致消息分配混乱。
第二,docker compose部署的多个worker副本要用同一个消费组名,这样Redis Stream才能把消息均衡分给不同机器。
第三,日志要输出成JSON行,方便集中收集和分析。后续排查问题全靠它。
第四,环境变量统一管理,Redis地址、Mongo地址、并发数不要写死在代码里,都走配置中心。
5.4 关于“可控”这件事
采集工程的目标从来不是“跑得飞快”,而是“可控、可预期”。MeteorSeed真正给我省时间的不是代码量减少,而是把三件事做对了:把任务分级、把规则外置、把确认机制做好。
任务分级让调度和执行彻底解耦,规则外置让站点改版不用发版本,确认机制让崩溃恢复变得确定。这三件事做好之后,我才发现以前用重型框架时的大量工作量,其实都花在了和核心业务无关的调度细节上。
最后分享一个当年踩过的大坑,也是现在MeteorSeed设计里一直保留的底线:任何时候都不要把“抓取目标站”当成“无限抓取”。我们做数据采集,同时也在消耗目标站点的资源。MeteorSeed从第一版开始就把robots解析、限速、并发上限、单站点黑名单这几项写死在框架中,宁可自己慢,不给目标站点造成压力。后来有一次因为某站点改版导致选择器失效,我们临时关掉限速做试探性抓取,结果换来一个IP被封的教训。从那以后,所有配置改动都走配置中心加版本记录,回滚也只回滚配置,不改代码。
这个项目后续我还在往两个方向迭代:一是增加多数据源的插件化适配层,让新增站点时不用写任何代码;二是把调度中心的管理界面做成可视化操作台,让运营人员也能直接提交种子任务。如果你们也有大量“小而散”的采集场景,MeteorSeed这套“种子分发加流星执行”的思路可以直接拿去改,有问题欢迎一起交流。
