单机爬虫一天爬 1 万条,分布式爬虫一小时爬 100 万条。这就是架构的力量。
🎬 开场:一个让我加班到凌晨的需求
产品经理说:"我们需要爬取全网 500 万条商品数据,明天要。"
我看了看我的单机爬虫,算了一下:
- 每条数据平均耗时 2 秒(包括请求、解析、存储)
- 500 万 × 2 秒 = 1000 万秒 ≈ 115 天
"明天要?你在逗我?"
然后我开始研究分布式爬虫。
一周后,我搭建了一个 10 台机器的爬虫集群,每台机器跑 10 个 Worker。
100 个 Worker 并行工作,500 万条数据,12 小时搞定。
这就是分布式的魅力。
🏗️ 分布式爬虫架构
整体架构图
┌─────────────────────────────────────────────────────────────┐
│ Master 节点 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ 任务调度器 │ │ URL 管理器 │ │ 监控面板 │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Redis 消息队列 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ 待爬取队列 │ │ 已爬取集合 │ │ 失败队列 │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────┼─────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ Worker 1 │ │ Worker 2 │ │ Worker N │
│ ┌─────────┐ │ │ ┌─────────┐ │ │ ┌─────────┐ │
│ │ 爬取器 │ │ │ │ 爬取器 │ │ │ │ 爬取器 │ │
│ │ 解析器 │ │ │ │ 解析器 │ │ │ │ 解析器 │ │
│ │ 存储器 │ │ │ │ 存储器 │ │ │ │ 存储器 │ │
│ └─────────┘ │ │ └─────────┘ │ │ └─────────┘ │
└───────────────┘ └───────────────┘ └───────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ MongoDB 数据存储 │
└─────────────────────────────────────────────────────────────┘
核心组件
- Master 节点:负责任务调度、URL 管理、监控
- Redis 队列:任务分发、去重、失败重试
- Worker 节点:实际执行爬取任务
- MongoDB:存储爬取的数据
🛠️ 环境准备
安装依赖
bash
npm install bull # 基于 Redis 的任务队列
npm install ioredis # Redis 客户端
npm install mongoose # MongoDB ODM
npm install puppeteer # 无头浏览器
npm install p-limit # 并发控制
npm install chalk # 终端彩色输出
npm install express # 监控 API
Docker 启动 Redis 和 MongoDB
yaml
# docker-compose.yml
version: "3.8"
services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
mongodb:
image: mongo:6
ports:
- "27017:27017"
volumes:
- mongo_data:/data/db
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: password
volumes:
redis_data:
mongo_data:
bash
docker-compose up -d
📬 任务队列实现
使用 Bull 创建队列
javascript
// src/queue/crawlerQueue.js
const Queue = require("bull")
const chalk = require("chalk")
// 创建爬虫任务队列
const crawlerQueue = new Queue("crawler", {
redis: {
host: process.env.REDIS_HOST || "localhost",
port: process.env.REDIS_PORT || 6379,
},
defaultJobOptions: {
attempts: 3, // 失败重试 3 次
backoff: {
type: "exponential", // 指数退避
delay: 2000, // 初始延迟 2 秒
},
removeOnComplete: 100, // 保留最近 100 个完成的任务
removeOnFail: 1000, // 保留最近 1000 个失败的任务
},
})
// 队列事件监听
crawlerQueue.on("completed", (job, result) => {
console.log(chalk.green(`✅ 任务完成: ${job.id} - ${job.data.url}`))
})
crawlerQueue.on("failed", (job, err) => {
console.log(chalk.red(`❌ 任务失败: ${job.id} - ${err.message}`))
})
crawlerQueue.on("stalled", (job) => {
console.log(chalk.yellow(`⚠️ 任务卡住: ${job.id}`))
})
module.exports = { crawlerQueue }
添加任务到队列
javascript
// src/queue/producer.js
const { crawlerQueue } = require("./crawlerQueue")
const chalk = require("chalk")
/**
* 批量添加爬取任务
* @param {string[]} urls - URL 列表
* @param {object} options - 任务选项
*/
async function addCrawlTasks(urls, options = {}) {
console.log(chalk.blue(`📤 添加 ${urls.length} 个任务到队列...`))
const jobs = urls.map((url) => ({
name: "crawl",
data: {
url,
...options,
},
opts: {
priority: options.priority || 0,
delay: options.delay || 0,
},
}))
await crawlerQueue.addBulk(jobs)
console.log(chalk.green(`✅ 任务添加完成`))
}
/**
* 添加单个任务
*/
async function addCrawlTask(url, options = {}) {
const job = await crawlerQueue.add("crawl", {
url,
...options,
})
return job
}
/**
* 获取队列状态
*/
async function getQueueStats() {
const [waiting, active, completed, failed, delayed] = await Promise.all([
crawlerQueue.getWaitingCount(),
crawlerQueue.getActiveCount(),
crawlerQueue.getCompletedCount(),
crawlerQueue.getFailedCount(),
crawlerQueue.getDelayedCount(),
])
return { waiting, active, completed, failed, delayed }
}
module.exports = {
addCrawlTasks,
addCrawlTask,
getQueueStats,
}
👷 Worker 实现
爬虫 Worker
javascript
// src/worker/crawlerWorker.js
const { crawlerQueue } = require("../queue/crawlerQueue")
const puppeteer = require("puppeteer")
const { saveToMongo } = require("../storage/mongodb")
const { checkDuplicate, markAsCrawled } = require("../utils/dedup")
const chalk = require("chalk")
let browser = null
/**
* 初始化浏览器
*/
async function initBrowser() {
if (!browser) {
browser = await puppeteer.launch({
headless: "new",
args: [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
],
})
console.log(chalk.green("🌐 浏览器已启动"))
}
return browser
}
/**
* 处理爬取任务
*/
async function processCrawlJob(job) {
const { url, type = "default" } = job.data
// 1. 检查是否已爬取(去重)
const isDuplicate = await checkDuplicate(url)
if (isDuplicate) {
console.log(chalk.yellow(`⏭️ 跳过已爬取: ${url}`))
return { skipped: true, url }
}
// 2. 初始化浏览器
const browser = await initBrowser()
const page = await browser.newPage()
try {
// 3. 设置页面
await page.setViewport({ width: 1920, height: 1080 })
await page.setUserAgent(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
)
// 4. 访问页面
await page.goto(url, {
waitUntil: "networkidle2",
timeout: 30000,
})
// 5. 根据类型选择解析器
let data
switch (type) {
case "product":
data = await parseProductPage(page)
break
case "article":
data = await parseArticlePage(page)
break
default:
data = await parseDefaultPage(page)
}
// 6. 保存数据
await saveToMongo(type, {
...data,
url,
crawledAt: new Date(),
})
// 7. 标记为已爬取
await markAsCrawled(url)
// 8. 更新进度
await job.progress(100)
return { success: true, url, data }
} finally {
await page.close()
}
}
/**
* 解析商品页面
*/
async function parseProductPage(page) {
return page.evaluate(() => {
return {
title: document.querySelector(".product-title")?.textContent?.trim(),
price: document.querySelector(".product-price")?.textContent?.trim(),
description: document.querySelector(".product-desc")?.textContent?.trim(),
images: Array.from(document.querySelectorAll(".product-image img")).map(
(img) => img.src
),
specs: Array.from(document.querySelectorAll(".spec-item")).map(
(item) => ({
name: item.querySelector(".spec-name")?.textContent?.trim(),
value: item.querySelector(".spec-value")?.textContent?.trim(),
})
),
}
})
}
/**
* 解析文章页面
*/
async function parseArticlePage(page) {
return page.evaluate(() => {
return {
title: document.querySelector("h1")?.textContent?.trim(),
author: document.querySelector(".author")?.textContent?.trim(),
content: document.querySelector(".article-content")?.innerHTML,
publishDate: document.querySelector(".publish-date")?.textContent?.trim(),
tags: Array.from(document.querySelectorAll(".tag")).map((tag) =>
tag.textContent?.trim()
),
}
})
}
/**
* 默认解析器
*/
async function parseDefaultPage(page) {
return page.evaluate(() => {
return {
title: document.title,
content: document.body.innerText.slice(0, 5000),
}
})
}
/**
* 启动 Worker
*/
function startWorker(concurrency = 5) {
console.log(chalk.blue(`🚀 启动 Worker,并发数: ${concurrency}`))
crawlerQueue.process("crawl", concurrency, async (job) => {
return processCrawlJob(job)
})
}
// 优雅退出
process.on("SIGTERM", async () => {
console.log(chalk.yellow("⚠️ 收到退出信号,正在关闭..."))
await crawlerQueue.close()
if (browser) await browser.close()
process.exit(0)
})
module.exports = { startWorker }
🔄 URL 去重
使用 Redis 实现去重
javascript
// src/utils/dedup.js
const Redis = require("ioredis")
const crypto = require("crypto")
const redis = new Redis({
host: process.env.REDIS_HOST || "localhost",
port: process.env.REDIS_PORT || 6379,
})
const CRAWLED_SET = "crawler:crawled"
const PENDING_SET = "crawler:pending"
/**
* 生成 URL 的哈希值
*/
function hashUrl(url) {
return crypto.createHash("md5").update(url).digest("hex")
}
/**
* 检查 URL 是否已爬取
*/
async function checkDuplicate(url) {
const hash = hashUrl(url)
return redis.sismember(CRAWLED_SET, hash)
}
/**
* 标记 URL 为已爬取
*/
async function markAsCrawled(url) {
const hash = hashUrl(url)
await redis.sadd(CRAWLED_SET, hash)
}
/**
* 批量检查去重
*/
async function filterDuplicates(urls) {
const pipeline = redis.pipeline()
urls.forEach((url) => {
pipeline.sismember(CRAWLED_SET, hashUrl(url))
})
const results = await pipeline.exec()
return urls.filter((url, index) => {
const [err, isMember] = results[index]
return !isMember
})
}
/**
* 使用布隆过滤器(更省内存,适合海量数据)
*/
async function checkWithBloomFilter(url) {
const hash = hashUrl(url)
// Redis 4.0+ 支持 BF.EXISTS 命令
// 需要安装 RedisBloom 模块
return redis.call("BF.EXISTS", "crawler:bloom", hash)
}
async function addToBloomFilter(url) {
const hash = hashUrl(url)
return redis.call("BF.ADD", "crawler:bloom", hash)
}
module.exports = {
checkDuplicate,
markAsCrawled,
filterDuplicates,
checkWithBloomFilter,
addToBloomFilter,
}
💾 数据存储
MongoDB 存储
javascript
// src/storage/mongodb.js
const mongoose = require("mongoose")
const chalk = require("chalk")
// 连接 MongoDB
mongoose.connect(process.env.MONGO_URI || "mongodb://localhost:27017/crawler", {
useNewUrlParser: true,
useUnifiedTopology: true,
})
mongoose.connection.on("connected", () => {
console.log(chalk.green("📦 MongoDB 已连接"))
})
// 定义 Schema
const productSchema = new mongoose.Schema(
{
url: { type: String, required: true, unique: true },
title: String,
price: String,
description: String,
images: [String],
specs: [
{
name: String,
value: String,
},
],
crawledAt: { type: Date, default: Date.now },
},
{ timestamps: true }
)
const articleSchema = new mongoose.Schema(
{
url: { type: String, required: true, unique: true },
title: String,
author: String,
content: String,
publishDate: String,
tags: [String],
crawledAt: { type: Date, default: Date.now },
},
{ timestamps: true }
)
// 创建索引
productSchema.index({ crawledAt: -1 })
articleSchema.index({ crawledAt: -1 })
const Product = mongoose.model("Product", productSchema)
const Article = mongoose.model("Article", articleSchema)
/**
* 保存数据到 MongoDB
*/
async function saveToMongo(type, data) {
const Model = type === "product" ? Product : Article
try {
await Model.findOneAndUpdate({ url: data.url }, data, {
upsert: true,
new: true,
})
} catch (error) {
if (error.code !== 11000) {
// 忽略重复键错误
throw error
}
}
}
/**
* 批量保存
*/
async function bulkSave(type, dataList) {
const Model = type === "product" ? Product : Article
const operations = dataList.map((data) => ({
updateOne: {
filter: { url: data.url },
update: { $set: data },
upsert: true,
},
}))
await Model.bulkWrite(operations)
}
module.exports = {
Product,
Article,
saveToMongo,
bulkSave,
}
📊 监控面板
简单的监控 API
javascript
// src/monitor/server.js
const express = require("express")
const { getQueueStats } = require("../queue/producer")
const { crawlerQueue } = require("../queue/crawlerQueue")
const chalk = require("chalk")
const app = express()
// 队列状态
app.get("/api/stats", async (req, res) => {
const stats = await getQueueStats()
res.json(stats)
})
// 最近的任务
app.get("/api/jobs/recent", async (req, res) => {
const [completed, failed] = await Promise.all([
crawlerQueue.getCompleted(0, 10),
crawlerQueue.getFailed(0, 10),
])
res.json({
completed: completed.map((job) => ({
id: job.id,
url: job.data.url,
finishedOn: job.finishedOn,
})),
failed: failed.map((job) => ({
id: job.id,
url: job.data.url,
failedReason: job.failedReason,
})),
})
})
// 暂停/恢复队列
app.post("/api/queue/pause", async (req, res) => {
await crawlerQueue.pause()
res.json({ status: "paused" })
})
app.post("/api/queue/resume", async (req, res) => {
await crawlerQueue.resume()
res.json({ status: "resumed" })
})
// 清空队列
app.post("/api/queue/clean", async (req, res) => {
await crawlerQueue.clean(0, "completed")
await crawlerQueue.clean(0, "failed")
res.json({ status: "cleaned" })
})
// 启动服务
const PORT = process.env.MONITOR_PORT || 3000
app.listen(PORT, () => {
console.log(chalk.blue(`📊 监控面板运行在 http://localhost:${PORT}`))
})
🚀 启动脚本
Master 节点
javascript
// src/master.js
const { addCrawlTasks, getQueueStats } = require("./queue/producer")
const chalk = require("chalk")
async function main() {
console.log(chalk.blue("\n🎯 Master 节点启动\n"))
// 示例:添加一批 URL
const urls = [
"https://example.com/product/1",
"https://example.com/product/2",
"https://example.com/product/3",
// ... 更多 URL
]
await addCrawlTasks(urls, { type: "product" })
// 定时打印队列状态
setInterval(async () => {
const stats = await getQueueStats()
console.log(
chalk.cyan(`
📊 队列状态:
等待中: ${stats.waiting}
处理中: ${stats.active}
已完成: ${stats.completed}
已失败: ${stats.failed}
`)
)
}, 5000)
}
main().catch(console.error)
Worker 节点
javascript
// src/worker.js
const { startWorker } = require("./worker/crawlerWorker")
// 从环境变量获取并发数
const concurrency = parseInt(process.env.CONCURRENCY) || 5
startWorker(concurrency)
启动命令
bash
# 启动 Master(添加任务)
node src/master.js
# 启动 Worker(可以启动多个)
CONCURRENCY=10 node src/worker.js
# 启动监控
node src/monitor/server.js
🎯 性能优化技巧
1. 连接池复用
javascript
// 复用浏览器实例
const browserPool = []
const MAX_BROWSERS = 5
async function getBrowser() {
if (browserPool.length < MAX_BROWSERS) {
const browser = await puppeteer.launch({ headless: "new" })
browserPool.push(browser)
return browser
}
// 轮询使用
return browserPool[Math.floor(Math.random() * browserPool.length)]
}
2. 批量操作
javascript
// 批量写入数据库
const buffer = []
const BATCH_SIZE = 100
async function addToBuffer(data) {
buffer.push(data)
if (buffer.length >= BATCH_SIZE) {
await bulkSave("product", buffer)
buffer.length = 0
}
}
3. 内存管理
javascript
// 定期清理浏览器内存
setInterval(async () => {
for (const browser of browserPool) {
const pages = await browser.pages()
for (const page of pages) {
if (page.url() === "about:blank") continue
await page.close()
}
}
}, 60000)
🔮 下集预告
这篇文章讲了分布式爬虫的架构设计和实现。
下一篇,我们将探讨:
《Node.js 爬虫实战指南(四):反反爬策略大全,和网站斗智斗勇》
内容包括:
- 常见反爬措施分析
- IP 代理池搭建
- 验证码识别方案
- 浏览器指纹伪装
- 请求签名破解思路
💬 互动时间:你搭建过分布式爬虫吗?用的什么架构?评论区分享一下!
觉得有用的话,点赞 + 在看 + 转发,让更多人学会分布式爬虫~