Node.js 爬虫实战指南(三):分布式爬虫架构,让你的爬虫飞起来

单机爬虫一天爬 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 数据存储                        │
└─────────────────────────────────────────────────────────────┘

核心组件

  1. Master 节点:负责任务调度、URL 管理、监控
  2. Redis 队列:任务分发、去重、失败重试
  3. Worker 节点:实际执行爬取任务
  4. 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 代理池搭建
  • 验证码识别方案
  • 浏览器指纹伪装
  • 请求签名破解思路

💬 互动时间:你搭建过分布式爬虫吗?用的什么架构?评论区分享一下!

觉得有用的话,点赞 + 在看 + 转发,让更多人学会分布式爬虫~


相关推荐
鲨莎分不晴2 小时前
PM2 是什么?一篇讲清 Node.js 进程管理器的文章
node.js
上海云盾-高防顾问3 小时前
防CC攻击不止限速:智能指纹识别如何精准抵御恶意爬虫
爬虫·安全·web安全
特行独立的猫3 小时前
python+Proxifier+mitmproxy实现监听本地网路所有的http请求
开发语言·爬虫·python·http
深蓝电商API3 小时前
Scrapy Spider 参数化:动态传入 start_urls 和自定义设置
爬虫·python·scrapy
CCPC不拿奖不改名3 小时前
基于FastAPI的API开发(爬虫的工作原理):从设计到部署详解+面试习题
爬虫·python·网络协议·tcp/ip·http·postman·fastapi
小白学大数据3 小时前
某程旅行小程序爬虫技术解析与实战案例
爬虫·小程序
回家路上绕了弯3 小时前
Spring Boot多数据源配置实战指南:从选型到落地优化
分布式·后端
程序员agions3 小时前
Node.js 爬虫实战指南(四):反反爬策略大全,和网站斗智斗勇
爬虫·node.js
程序员爱钓鱼4 小时前
Node.js 编程实战:博客系统 —— 数据库设计
前端·后端·node.js