
连续运行 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 被覆盖,但底层 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 的连接。
核心设计
1. Client 生命周期显式管理:每次代理切换创建新 reqwest::Client,旧 Client 离开作用域自动 drop,底层连接池随之关闭
2. Cookie Jar 按 Proxy-Tunnel 分组隔离:每个代理通道维护独立的 Cookie Store,Cookie 不会跨 IP 泄漏
3. 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 = "16YUN";代理域名
const PROXY_PORT: &str = "31111";
const PROXY_USER: &str = "username"; // 替换为实际用户名
const PROXY_PASS: &str = "password"; // 替换为实际密码
/// User-Agent 池:覆盖常见浏览器和学术工具
关键设计说明
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 的异步运行时在并发请求调度上开销更低。
如何确认修复生效
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 隔离的代价:每次代理切换需要重新建立会话(登录),增加了首次请求延迟。对于需要登录的站点,可在通道创建时预登录
* 单通道串行:当前实现每个通道串行请求,如需并发需为每个目标站点分配独立通道,内存占用会线性增长