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 代理池搭建
  • 验证码识别方案
  • 浏览器指纹伪装
  • 请求签名破解思路

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

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


相关推荐
giaz14n9X8 小时前
Redis 分布式锁进阶第六十三篇
分布式
没事别瞎琢磨9 小时前
十一、审计与 Run Session——每一步操作都被记录
人工智能·node.js
没事别瞎琢磨9 小时前
十六、AgentSandbox——把所有模块串起来的编排类
人工智能·node.js
没事别瞎琢磨9 小时前
十二、网络代理与白名单规则引擎
人工智能·node.js
没事别瞎琢磨9 小时前
十四、Git Worktree 隔离执行
人工智能·node.js
没事别瞎琢磨10 小时前
十、统一 Runner 入口——能力检测与模式回退
人工智能·node.js
ha_lydms10 小时前
AnalyticDB分区、分布键性能优化
android·大数据·分布式·性能优化·分布式计算·分区·analyticdb
没事别瞎琢磨10 小时前
八、环境隔离——构建安全的子进程环境
人工智能·node.js
pqk6V6Vep10 小时前
Redis 分布式锁进阶第一篇讲解
数据库·redis·分布式
giaz14n9X11 小时前
Redis 分布式锁进阶第六十一篇
数据库·redis·分布式