深入内存优化:如何防止分布式爬虫在长时运行中导致的内存暴涨?

0 阅读15分钟

上周三凌晨两点,手机弹出告警:线上爬虫集群的某个节点 OOM 了。

重启,跑了一个小时,又炸了。看 Grafana 曲线,内存像楼梯一样一格格往上走,中间偶尔 GC 回落一点,但总体趋势是不回头地涨。我盯着那条曲线看了十分钟,心想:又来了。

分布式爬虫跑久了内存暴涨,这事我遇到不下五次了。每次的原因都不完全一样,但根子上就那几个问题。这篇就把这些年踩过的坑、用的方案整理出来,希望能帮你少熬两个夜。

内存为什么会一直涨?

先说结论:绝大多数爬虫内存暴涨,不是内存泄漏,而是"该释放的东西没释放"。

区别在哪?内存泄漏是你丢了引用、GC 找不到了,但对象还活着,这种 bug 藏得深、难查。而爬虫场景里更常见的是:你以为某个变量用完了会自动回收,但它其实还挂在某个全局容器、缓存字典或者队列里,引用还在,GC 当然不回收。

具体来说,常见的"内存黑洞"有这几个:

  1. URL 去重集合无限膨胀
  2. HTTP 响应体整个加载到内存
  3. 请求队列/重试队列堆积
  4. 中间件或管道里缓存了 Item 但忘了清
  5. 日志和调试信息在内存里越攒越多

下面逐个拆开聊,每个都给代码。

一、URL 去重:别用 set 了,用 Bloom Filter

很多人写爬虫的去重逻辑是这样的:

# ❌ 反面教材:用 set 存所有已访问 URL
visited_urls = set()

def should_visit(url):
    if url in visited_urls:
        return False
    visited_urls.add(url)
    return True

跑一天可能没事。跑一周呢?假设你爬了 500 万个 URL,每个 URL 平均 80 字节,光这个 set 就要吃掉 400MB 左右。而且 Python 的 set 底层是哈希表,实际开销比理论值大不少,加上字符串对象本身的开销,500 万 URL 轻松吃掉 1GB 以上。

换成布隆过滤器(Bloom Filter)就好了:

import mmh3  # murmurhash3,需要 pip install mmh3
import math
from bitarray import bitarray  # 需要 pip install bitarray


class BloomFilter:
    """
    可持久化的布隆过滤器,用于 URL 去重。
    相比 set,内存占用降低 90% 以上。
    """

    def __init__(self, capacity=10_000_000, error_rate=0.001):
        """
        :param capacity: 预计要存的最大元素数量
        :param error_rate: 可接受的误判率(0.001 = 千分之一)
        """
        # 根据容量和误判率计算需要的位数和哈希函数数量
        self.capacity = capacity
        self.error_rate = error_rate
        self.bit_size = self._calc_bit_size(capacity, error_rate)
        self.hash_count = self._calc_hash_count(self.bit_size, capacity)
        self.bit_array = bitarray(self.bit_size)
        self.bit_array.setall(False)

    def _calc_bit_size(self, n, p):
        """计算需要的比特位数: m = -(n * ln(p)) / (ln(2)^2)"""
        return int(-n * math.log(p) / (math.log(2) ** 2))

    def _calc_hash_count(self, m, n):
        """计算需要的哈希函数个数: k = (m/n) * ln(2)"""
        return int((m / n) * math.log(2))

    def add(self, url):
        """将 URL 加入过滤器"""
        for seed in range(self.hash_count):
            # 用不同的种子生成多个哈希值,映射到比特数组的不同位置
            idx = mmh3.hash(url, seed) % self.bit_size
            self.bit_array[idx] = True

    def __contains__(self, url):
        """判断 URL 是否可能已存在(有误判概率)"""
        for seed in range(self.hash_count):
            idx = mmh3.hash(url, seed) % self.bit_size
            if not self.bit_array[idx]:
                return False  # 只要有一位是 0,说明一定不存在
        return True  # 所有位都是 1,可能存在(也可能是误判)

同样的 500 万 URL,布隆过滤器在千分之一误判率下大概只需要 7MB。7MB 和 1GB,差了两个数量级。

我在实际项目里用的是 pybloom_live 这个库,自带磁盘持久化,重启不丢数据。如果不想自己实现,直接用现成的就行。

二、响应体别整个吃进去,用流式读取

这是第二个大坑。requests 默认把整个响应体读进内存,对于小页面无所谓,但如果你在爬 PDF、图片或者大 JSON 接口,一个响应几十 MB,并发一高内存直接爆。

import requests

# 亿牛云代理配置 —— 隧道代理,每次请求自动切换出口 IP
PROXY_CONFIG = {
    "http": "http://16XXXXXX:password@t.16yun.cn:31111",
    "https": "http://16XXXXXX:password@t.16yun.cn:31111",
}

# ❌ 反面教材:整个响应体加载到内存
def fetch_big_file_bad(url):
    resp = requests.get(url, proxies=PROXY_CONFIG, timeout=30)
    return resp.content  # 如果文件是 200MB,这里就吃 200MB


# ✅ 正确做法:流式读取,分块写入文件
def fetch_big_file_good(url, save_path):
    """
    流式下载大文件,避免响应体全部驻留内存。
    搭配爬虫代理使用,每次请求自动换 IP。
    """
    resp = requests.get(
        url,
        proxies=PROXY_CONFIG,
        timeout=30,
        stream=True  # 关键:开启流式模式,不立即下载响应体
    )
    resp.raise_for_status()

    with open(save_path, "wb") as f:
        # 每次只读 8KB,写完就丢,内存占用恒定
        for chunk in resp.iter_content(chunk_size=8192):
            f.write(chunk)

    # 用完显式关闭,释放连接
    resp.close()

stream=True 是关键。开了之后 requests 不会立即读响应体,只有你调 iter_content 时才按块读。内存占用从"整个文件大小"降到"一个 chunk 的大小"。

如果是 aiohttp(异步爬虫常用),也有对应的流式 API:

import aiohttp
import asyncio

# 亿牛云代理配置(aiohttp 格式)
PROXY_URL = "http://16XXXXXX:password@t.16yun.cn:31111"


async def fetch_stream(url, save_path):
    """
    异步流式下载,单连接内存占用恒定。
    爬虫代理在 aiohttp 中通过 proxy 参数传入。
    """
    async with aiohttp.ClientSession() as session:
        async with session.get(url, proxy=PROXY_URL, timeout=30) as resp:
            resp.raise_for_status()
            with open(save_path, "wb") as f:
                # 异步分块读取,每块 8KB
                async for chunk in resp.content.iter_chunked(8192):
                    f.write(chunk)
    # session 和 response 在 async with 退出时自动关闭

三、请求队列要有界,别让积压吃光内存

分布式爬虫通常用 Redis 做任务队列。很多人往队列里塞任务的时候不做限制,种子 URL 一多,Redis 内存先爆了,爬虫节点拉取任务时本地队列也跟着爆。

我吃过这个亏。有一次往 Redis 里推了 800 万个 URL,Redis 直接吃了 12GB 内存,把同机器上的 MySQL 挤得 OOM 了。

解决方案是给队列设上限,超过就阻塞或丢弃:

import redis
import json
import time

# 亿牛云代理配置
PROXY_CONFIG = {
    "http": "http://16XXXXXX:password@t.16yun.cn:31111",
    "https": "http://16XXXXXX:password@t.16yun.cn:31111",
}


class BoundedTaskQueue:
    """
    有界任务队列,基于 Redis List 实现。
    超过容量上限时阻塞入队,防止内存被任务积压吃光。
    """

    def __init__(self, redis_client, queue_key, max_size=50000):
        self.redis = redis_client
        self.queue_key = queue_key
        self.max_size = max_size

    def push(self, task_dict, timeout=30):
        """
        入队任务,队列满时等待 timeout 秒。
        :return: True 成功入队,False 队列满且超时
        """
        deadline = time.time() + timeout
        while time.time() < deadline:
            # 用 llen 检查队列长度,超过上限就不推
            current_size = self.redis.llen(self.queue_key)
            if current_size < self.max_size:
                self.redis.rpush(self.queue_key, json.dumps(task_dict))
                return True
            time.sleep(0.5)  # 队列满了,等一会再试
        return False

    def pop(self, timeout=10):
        """
        阻塞式出队,timeout 秒内拿不到任务返回 None。
        使用 BLPOP 避免空轮询浪费 CPU。
        """
        result = self.redis.blpop(self.queue_key, timeout=timeout)
        if result is None:
            return None
        return json.loads(result[1])


# 使用示例
r = redis.Redis(host="localhost", port=6379, db=0)
queue = BoundedTaskQueue(r, "crawl_tasks", max_size=50000)

# 生产者:往队列塞任务时不会无限堆积
queue.push({"url": "https://example.com/page/1"})

# 消费者:爬虫节点从队列拉任务
task = queue.pop()
if task:
    import requests
    resp = requests.get(task["url"], proxies=PROXY_CONFIG, timeout=30)
    # ... 解析、处理 ...

有界队列的本质是背压(backpressure):下游消费不动了,上游就别硬塞。这在分布式系统里是基本操作,但很多人写爬虫的时候会忘。

四、Item Pipeline 要及时清缓存

Scrapy 的 Item Pipeline 是另一个容易漏的地方。有些人在 Pipeline 里做数据清洗,把所有 Item 先攒到一个列表里,等攒够 1000 条再批量写库。如果爬虫消费速度大于写库速度,这个列表就会无限增长。

# ❌ 反面教材:Pipeline 里无限制缓存 Item
class BadPipeline:
    def __init__(self):
        self.buffer = []  # 这个列表会越来越大,永远不会被 GC

    def process_item(self, item, spider):
        self.buffer.append(dict(item))
        if len(self.buffer) >= 1000:
            self.save_to_db(self.buffer)
            # 忘了清空!buffer 一直在涨
        return item


# ✅ 正确做法:批量写完后立即清空,且设最大缓冲
class GoodPipeline:
    def __init__(self):
        self.buffer = []
        self.max_buffer = 500  # 缩小批量,降低单次内存峰值

    def process_item(self, item, spider):
        self.buffer.append(dict(item))
        if len(self.buffer) >= self.max_buffer:
            self.save_to_db(self.buffer)
            self.buffer.clear()  # 关键:写完立刻清空
        return item

    def close_spider(self, spider):
        # 爬虫结束时把剩余数据冲刷到数据库
        if self.buffer:
            self.save_to_db(self.buffer)
            self.buffer.clear()

buffer.clear()buffer = [] 不是一回事。clear() 是原地清空,原来的列表对象被复用;buffer = [] 是创建新列表,老列表如果还有引用就不会被回收。虽然在这个例子里差别不大,但在复杂场景下值得注意。

五、用弱引用做缓存,让 GC 能自动回收

有时候你确实需要缓存一些中间结果(比如解析过的页面 DOM),但又不想缓存把内存吃满。这时候弱引用(WeakReference)就派上用场了。

import weakref
from bs4 import BeautifulSoup
import requests

# 亿牛云代理配置
PROXY_CONFIG = {
    "http": "http://16XXXXXX:password@t.16yun.cn:31111",
    "https": "http://16XXXXXX:password@t.16yun.cn:31111",
}


class WeakRefCache:
    """
    弱引用缓存:当没有强引用指向某个缓存项时,
    GC 可以自动回收它,不会造成内存堆积。
    适合缓存"有最好、没有也能重新算"的数据。
    """

    def __init__(self):
        # WeakValueDictionary:值的弱引用版本
        # 当外部不再持有某 key 对应的对象时,该条目会被自动清除
        self._cache = weakref.WeakValueDictionary()

    def get_or_parse(self, url):
        """获取缓存的 DOM 解析结果,缓存未命中则重新抓取并解析"""
        cached = self._cache.get(url)
        if cached is not None:
            return cached

        # 缓存未命中,走代理抓取页面
        resp = requests.get(url, proxies=PROXY_CONFIG, timeout=30)
        soup = BeautifulSoup(resp.text, "html.parser")
        resp.close()  # 显式关闭响应,释放连接

        # 存入弱引用缓存
        # 注意:soup 对象必须被外部变量持有,否则存进去就被 GC 回收了
        self._cache[url] = soup
        return soup


cache = WeakRefCache()

# 第一次调用:抓取 + 解析
soup1 = cache.get_or_parse("https://example.com")

# 第二次调用同一 URL:命中缓存(只要 soup1 还在作用域里)
soup2 = cache.get_or_parse("https://example.com")

# 当 soup1 和 soup2 都离开作用域后,缓存条目会被 GC 自动清除

弱引用缓存的缺点是命中率不稳定,因为 GC 随时可能把缓存项回收掉。所以它只适合"丢了能重新算"的场景。如果缓存重新计算的成本很高,还是老老实实用 LRU 缓存加上限。

六、定时 GC 和内存监控

上面说了这么多具体优化,但你怎么知道有没有效果?得监控。

Python 的 gc 模块可以手动触发垃圾回收,tracemalloc 可以追踪内存分配。在实际项目里,我会在爬虫启动时跑一个后台线程,定期检查内存使用情况:

import gc
import psutil
import threading
import logging
import time

logger = logging.getLogger(__name__)


class MemoryMonitor:
    """
    内存监控器:后台线程定期检查进程内存,
    超过阈值时触发 GC 并记录日志。
    """

    def __init__(self, warn_mb=2048, critical_mb=4096, interval=60):
        """
        :param warn_mb: 警告阈值(MB)
        :param critical_mb: 临界阈值(MB),超过会强制 GC
        :param interval: 检查间隔(秒)
        """
        self.warn_mb = warn_mb
        self.critical_mb = critical_mb
        self.interval = interval
        self._running = False
        self._thread = None

    def start(self):
        """启动监控线程"""
        self._running = True
        self._thread = threading.Thread(target=self._run, daemon=True)
        self._thread.start()
        logger.info("内存监控已启动,警告阈值 %dMB,临界阈值 %dMB",
                     self.warn_mb, self.critical_mb)

    def stop(self):
        """停止监控"""
        self._running = False
        if self._thread:
            self._thread.join(timeout=5)

    def _run(self):
        """监控主循环"""
        process = psutil.Process()
        while self._running:
            mem_info = process.memory_info()
            rss_mb = mem_info.rss / 1024 / 1024  # 实际物理内存占用

            if rss_mb > self.critical_mb:
                # 内存超过临界值,强制完整 GC
                logger.warning(
                    "内存达到临界值 %.1fMB,触发完整 GC", rss_mb
                )
                collected = gc.collect()  # 强制回收所有代
                logger.warning("GC 回收了 %d 个对象", collected)

            elif rss_mb > self.warn_mb:
                logger.info("内存偏高 %.1fMB,建议检查", rss_mb)

            time.sleep(self.interval)


# 在爬虫启动时挂载监控
monitor = MemoryMonitor(warn_mb=2048, critical_mb=4096, interval=60)
monitor.start()

# ... 爬虫主逻辑 ...

# 爬虫结束时停止监控
monitor.stop()

这套监控在线上跑了大半年,挺好用的。有几次是 GC 之后内存没降,说明是真的有引用没释放,这时候就该上 tracemalloc 查具体是哪段代码在泄漏了。

七、完整的内存优化爬虫示例

把上面的方案组合起来,写一个相对完整的示例。这个例子用 Scrapy 风格的组织方式,但核心逻辑在任何框架里都通用:

"""
分布式爬虫内存优化完整示例
依赖:pip install requests redis mmh3 bitarray psutil
"""

import gc
import json
import logging
import time
import threading
from queue import Queue, Full, Empty

import requests
import redis
import psutil
from bitarray import bitarray
import mmh3
import math

# ========== 日志配置 ==========
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
)
logger = logging.getLogger("crawler")

# ========== 亿牛云代理配置 ==========
# 爬虫代理:每次请求自动切换出口 IP,无需手动维护 IP 池
PROXY_USER = "16XXXXXX"       # 代理用户名(替换为实际值)
PROXY_PASS = "your_password"  # 代理密码(替换为实际值)
PROXY_HOST = "t.16yun.cn"     # 爬虫代理地址
PROXY_PORT = "31111"          # 爬虫代理端口

PROXY_CONFIG = {
    "http": f"http://{PROXY_USER}:{PROXY_PASS}@{PROXY_HOST}:{PROXY_PORT}",
    "https": f"http://{PROXY_USER}:{PROXY_PASS}@{PROXY_HOST}:{PROXY_PORT}",
}


# ========== 布隆过滤器(URL 去重) ==========
class BloomFilter:
    """布隆过滤器,替代 set 做 URL 去重,内存占用降低 95%+"""

    def __init__(self, capacity=10_000_000, error_rate=0.001):
        self.bit_size = int(-capacity * math.log(error_rate) / (math.log(2) ** 2))
        self.hash_count = int((self.bit_size / capacity) * math.log(2))
        self.bit_array = bitarray(self.bit_size)
        self.bit_array.setall(False)
        self.count = 0

    def add(self, url):
        for seed in range(self.hash_count):
            idx = mmh3.hash(url, seed) % self.bit_size
            self.bit_array[idx] = True
        self.count += 1

    def __contains__(self, url):
        for seed in range(self.hash_count):
            idx = mmh3.hash(url, seed) % self.bit_size
            if not self.bit_array[idx]:
                return False
        return True


# ========== 有界任务队列 ==========
class BoundedTaskQueue:
    """基于 Redis 的有界队列,防止任务积压撑爆内存"""

    def __init__(self, redis_client, key="crawl_tasks", max_size=50000):
        self.redis = redis_client
        self.key = key
        self.max_size = max_size

    def push(self, url, timeout=30):
        """入队,队列满时阻塞等待"""
        deadline = time.time() + timeout
        while time.time() < deadline:
            if self.redis.llen(self.key) < self.max_size:
                self.redis.rpush(self.key, json.dumps({"url": url}))
                return True
            time.sleep(0.5)
        return False

    def pop(self, timeout=10):
        """阻塞出队"""
        result = self.redis.blpop(self.key, timeout=timeout)
        if result is None:
            return None
        return json.loads(result[1])["url"]


# ========== 内存监控 ==========
class MemoryMonitor:
    """后台内存监控,超阈值自动触发 GC"""

    def __init__(self, warn_mb=2048, critical_mb=4096, interval=60):
        self.warn_mb = warn_mb
        self.critical_mb = critical_mb
        self.interval = interval
        self._running = False

    def start(self):
        self._running = True
        t = threading.Thread(target=self._run, daemon=True)
        t.start()

    def _run(self):
        proc = psutil.Process()
        while self._running:
            rss = proc.memory_info().rss / 1024 / 1024
            if rss > self.critical_mb:
                logger.warning("内存 %.0fMB 超临界值,触发 GC", rss)
                gc.collect()
            elif rss > self.warn_mb:
                logger.info("内存偏高:%.0fMB", rss)
            time.sleep(self.interval)


# ========== 爬虫主逻辑 ==========
class MemoryAwareCrawler:
    """
    带内存优化的爬虫:
    1. 布隆过滤器去重(省内存)
    2. 有界队列防积压
    3. 流式下载大文件
    4. 后台内存监控 + 自动 GC
    5. 爬虫代理防封 IP
    """

    def __init__(self, redis_host="localhost", redis_port=6379):
        self.redis_client = redis.Redis(host=redis_host, port=redis_port, db=0)
        self.bloom = BloomFilter(capacity=10_000_000, error_rate=0.001)
        self.queue = BoundedTaskQueue(self.redis_client, max_size=50000)
        self.monitor = MemoryMonitor(warn_mb=2048, critical_mb=4096)
        self.session = requests.Session()
        # 设置连接池大小,避免连接堆积
        adapter = requests.adapters.HTTPAdapter(
            pool_connections=10,    # 连接池大小
            pool_maxsize=10,        # 最大连接数
            max_retries=3           # 失败重试 3 次
        )
        self.session.mount("http://", adapter)
        self.session.mount("https://", adapter)

    def crawl(self, seed_urls):
        """启动爬虫"""
        self.monitor.start()
        logger.info("爬虫启动,共 %d 个种子 URL", len(seed_urls))

        # 种子 URL 入队
        for url in seed_urls:
            if url not in self.bloom:
                self.bloom.add(url)
                self.queue.push(url)

        # 主循环:从队列拉任务,抓取,解析
        try:
            while True:
                url = self.queue.pop(timeout=10)
                if url is None:
                    logger.info("队列为空,爬虫退出")
                    break
                self._fetch_and_parse(url)
        finally:
            self.monitor.stop()
            self.session.close()
            logger.info("爬虫结束,共处理 %d 个 URL", self.bloom.count)

    def _fetch_and_parse(self, url):
        """抓取单个 URL 并解析新链接"""
        try:
            # 使用爬虫代理发起请求
            resp = self.session.get(
                url, proxies=PROXY_CONFIG, timeout=30, stream=True
            )
            resp.raise_for_status()

            # 流式读取,只取前 1MB 内容(限制单页内存占用)
            content = b""
            for chunk in resp.iter_content(chunk_size=8192):
                content += chunk
                if len(content) > 1024 * 1024:  # 超过 1MB 截断
                    logger.warning("页面过大,截断: %s", url)
                    break
            resp.close()

            # 解析新链接(这里用简单的正则,实际用 BeautifulSoup 更好)
            import re
            new_urls = re.findall(r'href=["\'](https?://[^"\']+)', content.decode("utf-8", errors="ignore"))

            for new_url in new_urls:
                if new_url not in self.bloom:
                    self.bloom.add(new_url)
                    self.queue.push(new_url)

        except requests.RequestException as e:
            logger.error("请求失败 %s: %s", url, e)
        except Exception as e:
            logger.error("处理失败 %s: %s", url, e)


# ========== 启动 ==========
if __name__ == "__main__":
    crawler = MemoryAwareCrawler()
    seeds = [
        "https://example.com",
        "https://example.org",
    ]
    crawler.crawl(seeds)

一些容易忽略的细节

写完上面这些,再补充几个容易被忽略的小点。

连接池大小要设上限。 requests 默认的连接池只有 10 个连接,但如果你高并发跑,连接会在池外创建。设了 pool_maxsize 又不控制并发,连接数就会一直涨。连接池大小要和你的并发数匹配。

aiohttp 的 ClientSession 要复用。 每次请求 new 一个 Session 会导致连接池失效,连接泄漏。正确做法是整个爬虫生命周期共用一个 Session。这点 Scrapy 默认做对了,但自己写异步爬虫时容易犯。

别在 Item 里塞原始 HTML。 我见过有人把整个页面源码放在 Item 里传给 Pipeline,Pipeline 再存到内存里做二次解析。一个页面 200KB,1000 个 Item 就是 200MB。需要在 Pipeline 用的数据就在 Spider 里提取好,只传结构化字段。

tracemalloc 是最后的武器。 上面所有方法都试了内存还在涨,就用 tracemalloc 抓快照,对比两个时间点的内存分配差异,看看到底是哪行代码在分配内存不释放。Python 3.4+ 自带,不用装额外包。

import tracemalloc

# 启动内存追踪
tracemalloc.start()

# ... 跑一段爬虫逻辑 ...

# 取第一次快照
snapshot1 = tracemalloc.take_snapshot()

# ... 再跑一段 ...

# 取第二次快照
snapshot2 = tracemalloc.take_snapshot()

# 对比两次快照,找出内存增长最多的代码位置
top_stats = snapshot2.compare_to(snapshot1, "lineno")
for stat in top_stats[:10]:
    print(stat)

总结

分布式爬虫的内存问题,说到底就是"进来的多、出去的少"。URL 集合只进不出、响应体整个读进来不释放、队列无上限地堆积、Pipeline 缓存不清空。每一个都是"该释放的没释放"。

核心就这几条:

URL 去重用布隆过滤器,别用 set。大文件用流式读取,别整个 content 吃进去。队列要有上限,生产者要能背压。Pipeline 的缓冲区写完就清。挂个内存监控,超阈值自动 GC。

这些方案在我们的集群上跑了半年多,之前每周至少一次 OOM 重启,现在基本没有了。偶尔内存涨上去,监控线程触发一次 GC 就回来了。如果你也在被爬虫内存问题困扰,不妨照着试一下。