学术文献爬虫 OOM 崩溃与 403 风暴

连续运行 48 小时后,学术文献抓取进程被 OOM Killer 终止,内存从 200MB 涨到 4.2GB。与此同时,代理 IP 切换后 Cookie 会话失效,学术数据库返回大量 403 Forbidden,有效抓取率从正常运行时的 85% 跌至 30%。

根因是两条:Python requests Session 在代理切换路径下未释放 TCP 连接,文件描述符和内存持续增长;学术数据库(CNKI、IEEE Xplore)将 Cookie 与 IP 地址绑定,代理 IP 轮换后旧 Cookie 直接失效。

修复方案是用 Rust + Reqwest 重写爬虫核心模块,利用所有权机制强制管理连接生命周期,按 Proxy-Tunnel 分组隔离 Cookie Jar。修复后 72 小时运行内存稳定在 50MB 以内,有效抓取率恢复至 92%,P99 延迟从 2.3s 降至 800ms。

事故时间线

时间 现象
T+0h 启动学术文献抓取任务,目标 CNKI、IEEE Xplore、PubMed、arXiv,抓取论文元数据、引用关系、摘要文本
T+6h 内存从初始 200MB 增长到 600MB,未触发告警阈值
T+18h 内存达到 1.8GB,开始出现 403 响应,日志中 Cookie 失效警告频率上升
T+36h 内存突破 3GB,403 比例超过 50%,有效抓取率跌至 30%
T+48h 内存达到 4.2GB,进程被 Linux OOM Killer 终止

根因分析

连接泄漏:requests Session 在代理切换路径下未释放

Python 版本的核心代码使用 requests.Session() 管理 HTTP 连接。每次代理 IP 切换时,代码创建了一个新的 Session 实例,但旧 Session 的底层 TCP 连接没有显式关闭。

python 复制代码
# 问题代码片段
def rotate_proxy(self, new_proxy):
    # 创建新 Session,但旧 Session 未关闭
    self.session = requests.Session()
    self.session.proxies = {'http': new_proxy, 'https': new_proxy}
    # 旧的 self.session 被覆盖,但底层 urllib3 连接池
    # 中的 TCP 连接仍保持 ESTABLISHED 状态

requests.Session 底层使用 urllib3 的 HTTPConnectionPool。每个 Pool 默认维护 pool_connections=10pool_maxsize=10 的连接。当 Session 被覆盖时,urllib3 的连接池持有对 socket 的强引用,直到 Pool 被显式 close() 或进程退出。

每次代理切换泄漏约 10 个 TCP 连接。代理每 30 秒轮换一次,48 小时约 5760 次切换,泄漏约 57600 个连接。每个连接关联的 socket 缓冲区、SSL 上下文、请求/响应对象累积到 4.2GB。

CNKI 和 IEEE Xplore 的反爬策略将 Cookie 会话与客户端 IP 地址绑定。当代理 IP 切换后,携带旧 IP 签名的 Cookie 被服务端识别为异常请求,返回 403 Forbidden。

原始代码使用全局 Cookie Jar,所有代理共享同一份 Cookie:

python 复制代码
# 问题代码:全局 Cookie Jar 不区分代理 IP
self.session.cookies.update(login_cookies)
# 代理 IP 从 1.2.3.4 切换到 5.6.7.8 后
# Cookie 中的 IP 签名仍然指向 1.2.3.4
# 服务端校验失败 → 403

学术数据库的典型 Cookie 结构包含 IP 指纹字段(如 client_ip_hashsession_token 中嵌入的 IP 信息)。IP 切换后,Cookie 中的 IP 指纹与服务端记录的当前请求 IP 不匹配,触发安全策略。

修复方案:Rust + Reqwest 重写

选择 Rust 重写核心模块,不是因为"Rust 更快",而是因为所有权模型在编译期就能发现连接生命周期问题------你不可能忘记关闭一个已经被 drop 的连接。

核心设计

  1. Client 生命周期显式管理 :每次代理切换创建新 reqwest::Client,旧 Client 离开作用域自动 drop,底层连接池随之关闭
  2. Cookie Jar 按 Proxy-Tunnel 分组隔离:每个代理通道维护独立的 Cookie Store,Cookie 不会跨 IP 泄漏
  3. UA 轮换与代理切换同步:User-Agent 随代理 IP 一起轮换,降低指纹关联风险

Cargo.toml

toml 复制代码
[package]
name = "academic-crawler"
version = "0.1.0"
edition = "2021"

[dependencies]
reqwest = { version = "0.12", features = ["cookies", "json"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rand = "0.8"
tracing = "0.1"
tracing-subscriber = "0.3"

main.rs

rust 复制代码
use rand::seq::SliceRandom;
use reqwest::cookie::Jar;
use std::sync::Arc;
use std::time::Duration;
use tracing::{info, warn, error};

/// 代理配置:亿牛云爬虫代理
/// 实际使用时替换 username 和 password 为真实值
const PROXY_DOMAIN: &str = "t.16yun.cn";
const PROXY_PORT: &str = "31111";
const PROXY_USER: &str = "username"; // 替换为实际用户名
const PROXY_PASS: &str = "password"; // 替换为实际密码

/// User-Agent 池:覆盖常见浏览器和学术工具
const UA_POOL: &[&str] = &[
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36",
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0",
    "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
];

/// 学术文献目标站点
const TARGET_SITES: &[&str] = &[
    "https://www.cnki.net",
    "https://ieeexplore.ieee.org",
    "https://pubmed.ncbi.nlm.nih.gov",
    "https://arxiv.org",
];

/// 代理通道:管理独立的 Client 和 Cookie Jar
/// 每个通道对应一个代理 IP 会话,确保 Cookie 不跨通道泄漏
struct ProxyTunnel {
    /// 代理 URL,格式为 http://user:pass@host:port
    proxy_url: String,
    /// 当前通道使用的 User-Agent
    user_agent: String,
    /// 独立的 Cookie Jar,仅在此通道内有效
    cookie_jar: Arc<Jar>,
    /// HTTP Client,离开作用域时自动 drop 并关闭连接池
    client: reqwest::Client,
}

impl ProxyTunnel {
    /// 创建新的代理通道
    /// 每次调用都会创建全新的 Client 和 Cookie Jar
    fn new(proxy_url: String, user_agent: String) -> Result<Self, reqwest::Error> {
        let cookie_jar = Arc::new(Jar::default());
        let client = reqwest::Client::builder()
            .cookie_provider(cookie_jar.clone())
            .proxy(reqwest::Proxy::all(&proxy_url)?)
            .timeout(Duration::from_secs(15))
            .connect_timeout(Duration::from_secs(5))
            .pool_max_idle_per_host(5)    // 限制空闲连接数
            .tcp_keepalive(Duration::from_secs(30))
            .user_agent(&user_agent)
            .build()?;

        Ok(Self {
            proxy_url,
            user_agent,
            cookie_jar,
            client,
        })
    }

    /// 发起 GET 请求
    async fn get(&self, url: &str) -> Result<reqwest::Response, reqwest::Error> {
        self.client
            .get(url)
            .header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
            .header("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
            .send()
            .await
    }

    /// 注入登录 Cookie(用于需要维持登录态的站点)
    /// target_domain 应为目标站点域名(如 "www.cnki.net"),而非代理域名
    fn inject_login_cookies(&self, target_domain: &str, cookies: &[(String, String)]) {
        for (name, value) in cookies {
            // 通过 Client 的 cookie jar 注入,仅在当前通道生效
            // 实际场景中需从登录接口获取真实 Cookie
            let url = format!("https://{}", target_domain);
            if let Ok(parsed_url) = url.parse() {
                self.cookie_jar.add_cookie_str(
                    &format!("{}={}; Path=/; Secure", name, value),
                    &parsed_url,
                );
            }
        }
    }
}

/// 爬虫管理器:负责代理通道轮换和请求分发
struct CrawlerManager {
    /// 当前活跃的代理通道
    current_tunnel: Option<ProxyTunnel>,
    /// 代理切换计数器
    rotation_count: u64,
}

impl CrawlerManager {
    fn new() -> Self {
        Self {
            current_tunnel: None,
            rotation_count: 0,
        }
    }

    /// 轮换代理 IP
    /// 旧隧道离开作用域自动 drop,TCP 连接随之关闭
    fn rotate_tunnel(&mut self) -> Result<(), reqwest::Error> {
        // 生成亿牛云代理的隧道格式
        // 格式: http://username:password@t.16yun.cn:31111
        let proxy_url = format!(
            "http://{}:{}@{}:{}",
            PROXY_USER, PROXY_PASS, PROXY_DOMAIN, PROXY_PORT
        );

        // 随机选择 User-Agent,与代理切换同步
        let ua = UA_POOL.choose(&mut rand::thread_rng())
            .unwrap_or(&UA_POOL[0])
            .to_string();

        // 创建新隧道,旧隧道在此处被替换并 drop
        let new_tunnel = ProxyTunnel::new(proxy_url, ua)?;
        self.current_tunnel = Some(new_tunnel);
        self.rotation_count += 1;

        info!(
            tunnel_id = self.rotation_count,
            ua = %self.current_tunnel.as_ref().unwrap().user_agent,
            "代理通道已轮换,旧通道连接池已释放"
        );

        Ok(())
    }

    /// 抓取学术文献页面
    async fn fetch(&mut self, url: &str) -> Result<String, Box<dyn std::error::Error>> {
        // 如果当前没有活跃隧道,或已使用超过一定次数,先轮换
        if self.current_tunnel.is_none() || self.rotation_count % 50 == 0 {
            self.rotate_tunnel()?;
        }

        let tunnel = self.current_tunnel.as_ref().unwrap();
        info!(url = %url, tunnel_id = self.rotation_count, "发起请求");

        let response = tunnel.get(url).await?;
        let status = response.status();

        if status.is_success() {
            let body = response.text().await?;
            Ok(body)
        } else {
            // 403 时记录警告,但不立即重试(避免触发更严格的风控)
            warn!(
                status = status.as_u16(),
                url = %url,
                tunnel_id = self.rotation_count,
                "请求返回非 2xx 状态码"
            );
            Err(format!("HTTP {}", status.as_u16()).into())
        }
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // 初始化日志
    tracing_subscriber::fmt()
        .with_max_level(tracing::Level::INFO)
        .init();

    info!("学术文献爬虫启动");

    let mut manager = CrawlerManager::new();

    // 遍历目标站点进行抓取
    for site in TARGET_SITES {
        match manager.fetch(site).await {
            Ok(body) => {
                info!(site = %site, body_len = body.len(), "抓取成功");
                // 实际场景中在此解析 HTML 提取元数据、引用关系、摘要
            }
            Err(e) => {
                error!(site = %site, error = %e, "抓取失败");
            }
        }
    }

    info!("抓取任务完成,总代理轮换次数: {}", manager.rotation_count);
    Ok(())
}

关键设计说明

Client 生命周期由所有权保证

ProxyTunnel 持有 reqwest::Client。当 rotate_tunnel() 用新隧道替换旧隧道时,旧 ProxyTunneldrop,其内部的 Client 随之 drop,底层连接池关闭,所有 TCP 连接释放。这不是 GC 的"最终会回收",而是编译期保证的确定性释放。

Cookie Jar 按通道隔离

每个 ProxyTunnel 有独立的 Arc<Jar>。代理 IP 切换 = 创建新通道 = 新 Cookie Jar。旧 Cookie 不会泄漏到新通道,新通道也不会携带旧 IP 的 Cookie 去请求。CNKI 和 IEEE Xplore 的 IP-Cookie 绑定校验自然通过。

连接池参数调优

pool_max_idle_per_host(5) 限制每个主机最多 5 个空闲连接,避免连接池膨胀。tcp_keepalive(30s) 确保死连接被及时清理。

验证结果

内存稳定性

指标 Python 版本 Rust 版本
初始内存 200MB 18MB
24h 内存 1.2GB 32MB
48h 内存 4.2GB(OOM) 45MB
72h 内存 --- 48MB

Rust 版本 72 小时运行内存稳定在 50MB 以内,无增长趋势。

抓取成功率

指标 Python 版本 Rust 版本
正常期有效率 85% 92%
48h 有效率 30% 91%
403 比例(48h) 55% 6%

Cookie 按通道隔离后,代理切换不再触发 403。

延迟

指标 Python 版本 Rust 版本
P50 延迟 1.1s 350ms
P99 延迟 2.3s 800ms

延迟下降来自两方面:连接池参数调优减少了空闲连接竞争;Rust 的异步运行时在并发请求调度上开销更低。

如何确认修复生效

  1. 内存监控:部署后观察 RSS 内存曲线,72 小时内应无明显上升趋势。如果持续增长,检查是否有未 drop 的 Client 实例
  2. 403 比例:监控 403 响应占总请求的比例,应低于 10%。如果高于此值,检查 Cookie Jar 是否正确隔离
  3. 连接数 :通过 ss -tnp | grep crawler 检查 ESTABLISHED 连接数,应稳定在合理范围内(通常 < 100)
  4. 文件描述符ls /proc/<pid>/fd | wc -l 确认 fd 数量不持续增长

适用场景

  • 需要长时间运行(> 24h)的爬虫任务
  • 目标站点有 IP-Cookie 绑定反爬策略
  • 需要频繁切换代理 IP 的场景
  • 对内存占用有严格限制的环境

不适用场景

  • 一次性短时抓取(Python requests 足够,无需引入 Rust 工具链)
  • 目标站点无 Cookie 校验(Cookie 隔离的收益不明显)
  • 代理 IP 固定不切换(连接泄漏问题不突出)

环境前提

  • Rust 1.75+(需要 2021 edition)
  • 亿牛云爬虫代理账号(t.16yun.cn:31111
  • 目标学术站点的登录凭证(如需抓取受限内容)

常见错误

  1. 忘记在 rotate_tunnel 中替换整个隧道:只换代理 URL 不换 Cookie Jar,Cookie 仍然跨 IP 泄漏
  2. Cookie 注入时机错误 :在请求发出后才注入 Cookie,导致首次请求不带登录态。应在 rotate_tunnel 后立即注入
  3. 连接池参数过大pool_max_idle_per_host 设置过高会抵消内存优化效果,建议 5-10
  4. UA 与代理不同步:User-Agent 固定不变而 IP 频繁切换,会触发行为异常检测

取舍与副作用

  • Rust 工具链成本:团队需要熟悉 Rust 生态,编译时间比 Python 长
  • 开发效率下降:Rust 的借用检查器在初期会增加开发时间,但换来的是运行期的确定性
  • Cookie 隔离的代价:每次代理切换需要重新建立会话(登录),增加了首次请求延迟。对于需要登录的站点,可在通道创建时预登录
  • 单通道串行:当前实现每个通道串行请求,如需并发需为每个目标站点分配独立通道,内存占用会线性增长
相关推荐
嫂子的姐夫11 小时前
33-补环境介绍
爬虫·js逆向·逆向
土豆125013 小时前
Tauri 入门与实践:用 Rust 构建你的下一个桌面应用
前端·rust
土豆125013 小时前
Rust 错误处理实战:anyhow + thiserror 的黄金搭档
rust
Zarek枫煜15 小时前
C3 编程语言 - 现代 C 的进化之选
c语言·开发语言·青少年编程·rust·游戏引擎
ZC跨境爬虫16 小时前
Python异步IO详解:原理、应用场景与实战指南(高并发爬虫首选)
爬虫·python·算法·自动化
嫂子的姐夫16 小时前
35-JS VMP技术介绍
爬虫·js逆向
嫂子的姐夫19 小时前
32-字体反爬
爬虫·逆向
咚为21 小时前
Rust 经典面试题255道
开发语言·面试·rust
@atweiwei1 天前
用 Rust 构建 LLM 应用的高性能框架
开发语言·后端·ai·rust·langchain·llm