连续运行 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 连接没有显式关闭。
# 问题代码片段
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=10 和 pool_maxsize=10 的连接。当 Session 被覆盖时,urllib3 的连接池持有对 socket 的强引用,直到 Pool 被显式 close() 或进程退出。
每次代理切换泄漏约 10 个 TCP 连接。代理每 30 秒轮换一次,48 小时约 5760 次切换,泄漏约 57600 个连接。每个连接关联的 socket 缓冲区、SSL 上下文、请求/响应对象累积到 4.2GB。
Cookie 与代理 IP 绑定失效
CNKI 和 IEEE Xplore 的反爬策略将 Cookie 会话与客户端 IP 地址绑定。当代理 IP 切换后,携带旧 IP 签名的 Cookie 被服务端识别为异常请求,返回 403 Forbidden。
原始代码使用全局 Cookie Jar,所有代理共享同一份 Cookie:
# 问题代码:全局 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_hash、session_token 中嵌入的 IP 信息)。IP 切换后,Cookie 中的 IP 指纹与服务端记录的当前请求 IP 不匹配,触发安全策略。
修复方案:Rust + Reqwest 重写
选择 Rust 重写核心模块,不是因为"Rust 更快",而是因为所有权模型在编译期就能发现连接生命周期问题——你不可能忘记关闭一个已经被 drop 的连接。
核心设计
- Client 生命周期显式管理:每次代理切换创建新
reqwest::Client,旧 Client 离开作用域自动drop,底层连接池随之关闭 - Cookie Jar 按 Proxy-Tunnel 分组隔离:每个代理通道维护独立的 Cookie Store,Cookie 不会跨 IP 泄漏
- UA 轮换与代理切换同步:User-Agent 随代理 IP 一起轮换,降低指纹关联风险
Cargo.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
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() 用新隧道替换旧隧道时,旧 ProxyTunnel 被 drop,其内部的 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 的异步运行时在并发请求调度上开销更低。
如何确认修复生效
- 内存监控:部署后观察 RSS 内存曲线,72 小时内应无明显上升趋势。如果持续增长,检查是否有未 drop 的 Client 实例
- 403 比例:监控 403 响应占总请求的比例,应低于 10%。如果高于此值,检查 Cookie Jar 是否正确隔离
- 连接数:通过
ss -tnp | grep crawler检查 ESTABLISHED 连接数,应稳定在合理范围内(通常 < 100) - 文件描述符:
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)
- 目标学术站点的登录凭证(如需抓取受限内容)
常见错误
- 忘记在
rotate_tunnel中替换整个隧道:只换代理 URL 不换 Cookie Jar,Cookie 仍然跨 IP 泄漏 - Cookie 注入时机错误:在请求发出后才注入 Cookie,导致首次请求不带登录态。应在
rotate_tunnel后立即注入 - 连接池参数过大:
pool_max_idle_per_host设置过高会抵消内存优化效果,建议 5-10 - UA 与代理不同步:User-Agent 固定不变而 IP 频繁切换,会触发行为异常检测
取舍与副作用
- Rust 工具链成本:团队需要熟悉 Rust 生态,编译时间比 Python 长
- 开发效率下降:Rust 的借用检查器在初期会增加开发时间,但换来的是运行期的确定性
- Cookie 隔离的代价:每次代理切换需要重新建立会话(登录),增加了首次请求延迟。对于需要登录的站点,可在通道创建时预登录
- 单通道串行:当前实现每个通道串行请求,如需并发需为每个目标站点分配独立通道,内存占用会线性增长