
連續(xù)運(yùn)行 48 小時(shí)后,學(xué)術(shù)文獻(xiàn)抓取進(jìn)程被 OOM Killer 終止,內(nèi)存從 200MB 漲到 4.2GB。與此同時(shí),代理 IP 切換后 Cookie 會(huì)話(huà)失效,學(xué)術(shù)數(shù)據(jù)庫(kù)返回大量 403 Forbidden,有效抓取率從正常運(yùn)行時(shí)的 85% 跌至 30%。
根因是兩條:Python requests Session 在代理切換路徑下未釋放 TCP 連接,文件描述符和內(nèi)存持續(xù)增長(zhǎng);學(xué)術(shù)數(shù)據(jù)庫(kù)(CNKI、IEEE Xplore)將 Cookie 與 IP 地址綁定,代理 IP 輪換后舊 Cookie 直接失效。
修復(fù)方案是用 Rust + Reqwest 重寫(xiě)爬蟲(chóng)核心模塊,利用所有權(quán)機(jī)制強(qiáng)制管理連接生命周期,按 Proxy-Tunnel 分組隔離 Cookie Jar。修復(fù)后 72 小時(shí)運(yùn)行內(nèi)存穩(wěn)定在 50MB 以?xún)?nèi),有效抓取率恢復(fù)至 92%,P99 延遲從 2.3s 降至 800ms。
事故時(shí)間線(xiàn)
時(shí)間
現(xiàn)象
T+0h
啟動(dòng)學(xué)術(shù)文獻(xiàn)抓取任務(wù),目標(biāo) CNKI、IEEE Xplore、PubMed、arXiv,抓取論文元數(shù)據(jù)、引用關(guān)系、摘要文本
T+6h
內(nèi)存從初始 200MB 增長(zhǎng)到 600MB,未觸發(fā)告警閾值
T+18h
內(nèi)存達(dá)到 1.8GB,開(kāi)始出現(xiàn) 403 響應(yīng),日志中 Cookie 失效警告頻率上升
T+36h
內(nèi)存突破 3GB,403 比例超過(guò) 50%,有效抓取率跌至 30%
T+48h
內(nèi)存達(dá)到 4.2GB,進(jìn)程被 Linux OOM Killer 終止
根因分析
連接泄漏:requests Session 在代理切換路徑下未釋放
Python 版本的核心代碼使用 requests.Session() 管理 HTTP 連接。每次代理 IP 切換時(shí),代碼創(chuàng)建了一個(gè)新的 Session 實(shí)例,但舊 Session 的底層 TCP 連接沒(méi)有顯式關(guān)閉。
# 問(wèn)題代碼片段
def rotate_proxy(self, new_proxy):
? ? # 創(chuàng)建新 Session,但舊 Session 未關(guān)閉
? ? # 舊的 self.session 被覆蓋,但底層 urllib3 連接池
? ? # 中的 TCP 連接仍保持 ESTABLISHED 狀態(tài)
requests.Session 底層使用 urllib3 的 HTTPConnectionPool。每個(gè) Pool 默認(rèn)維護(hù) pool_connections=10 和 pool_maxsize=10 的連接。當(dāng) Session 被覆蓋時(shí),urllib3 的連接池持有對(duì) socket 的強(qiáng)引用,直到 Pool 被顯式 close() 或進(jìn)程退出。
每次代理切換泄漏約 10 個(gè) TCP 連接。代理每 30 秒輪換一次,48 小時(shí)約 5760 次切換,泄漏約 57600 個(gè)連接。每個(gè)連接關(guān)聯(lián)的 socket 緩沖區(qū)、SSL 上下文、請(qǐng)求/響應(yīng)對(duì)象累積到 4.2GB。
Cookie 與代理 IP 綁定失效
CNKI 和 IEEE Xplore 的反爬策略將 Cookie 會(huì)話(huà)與客戶(hù)端 IP 地址綁定。當(dāng)代理 IP 切換后,攜帶舊 IP 簽名的 Cookie 被服務(wù)端識(shí)別為異常請(qǐng)求,返回 403 Forbidden。
原始代碼使用全局 Cookie Jar,所有代理共享同一份 Cookie:
# 問(wèn)題代碼:全局 Cookie Jar 不區(qū)分代理 IP
self.session.cookies.update(login_cookies)
# 代理 IP 從 1.2.3.4 切換到 5.6.7.8 后
# Cookie 中的 IP 簽名仍然指向 1.2.3.4
# 服務(wù)端校驗(yàn)失敗 → 403
學(xué)術(shù)數(shù)據(jù)庫(kù)的典型 Cookie 結(jié)構(gòu)包含 IP 指紋字段(如 client_ip_hash、session_token 中嵌入的 IP 信息)。IP 切換后,Cookie 中的 IP 指紋與服務(wù)端記錄的當(dāng)前請(qǐng)求 IP 不匹配,觸發(fā)安全策略。
修復(fù)方案:Rust + Reqwest 重寫(xiě)
選擇 Rust 重寫(xiě)核心模塊,不是因?yàn)?Rust 更快",而是因?yàn)樗袡?quán)模型在編譯期就能發(fā)現(xiàn)連接生命周期問(wèn)題——你不可能忘記關(guān)閉一個(gè)已經(jīng)被 drop 的連接。
核心設(shè)計(jì)
1. Client 生命周期顯式管理:每次代理切換創(chuàng)建新 reqwest::Client,舊 Client 離開(kāi)作用域自動(dòng) drop,底層連接池隨之關(guān)閉
2. Cookie Jar 按 Proxy-Tunnel 分組隔離:每個(gè)代理通道維護(hù)獨(dú)立的 Cookie Store,Cookie 不會(huì)跨 IP 泄漏
3. UA 輪換與代理切換同步:User-Agent 隨代理 IP 一起輪換,降低指紋關(guān)聯(lián)風(fēng)險(xiǎn)
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};
/// 代理配置:億牛云爬蟲(chóng)代理
/// 實(shí)際使用時(shí)替換 username 和 password 為真實(shí)值
const PROXY_DOMAIN: &str = "16YUN";代理域名
const PROXY_PORT: &str = "31111";
const PROXY_USER: &str = "username"; // 替換為實(shí)際用戶(hù)名
const PROXY_PASS: &str = "password"; // 替換為實(shí)際密碼
/// User-Agent 池:覆蓋常見(jiàn)瀏覽器和學(xué)術(shù)工具
關(guān)鍵設(shè)計(jì)說(shuō)明
Client 生命周期由所有權(quán)保證
ProxyTunnel 持有 reqwest::Client。當(dāng) rotate_tunnel() 用新隧道替換舊隧道時(shí),舊 ProxyTunnel 被 drop,其內(nèi)部的 Client 隨之 drop,底層連接池關(guān)閉,所有 TCP 連接釋放。這不是 GC 的"最終會(huì)回收",而是編譯期保證的確定性釋放。
Cookie Jar 按通道隔離
每個(gè) ProxyTunnel 有獨(dú)立的 Arc<Jar>。代理 IP 切換 = 創(chuàng)建新通道 = 新 Cookie Jar。舊 Cookie 不會(huì)泄漏到新通道,新通道也不會(huì)攜帶舊 IP 的 Cookie 去請(qǐng)求。CNKI 和 IEEE Xplore 的 IP-Cookie 綁定校驗(yàn)自然通過(guò)。
連接池參數(shù)調(diào)優(yōu)
pool_max_idle_per_host(5) 限制每個(gè)主機(jī)最多 5 個(gè)空閑連接,避免連接池膨脹。tcp_keepalive(30s) 確保死連接被及時(shí)清理。
驗(yàn)證結(jié)果
內(nèi)存穩(wěn)定性
指標(biāo)
Python 版本
Rust 版本
初始內(nèi)存
200MB
18MB
24h 內(nèi)存
1.2GB
32MB
48h 內(nèi)存
4.2GB(OOM)
45MB
72h 內(nèi)存
—
48MB
Rust 版本 72 小時(shí)運(yùn)行內(nèi)存穩(wěn)定在 50MB 以?xún)?nèi),無(wú)增長(zhǎng)趨勢(shì)。
抓取成功率
指標(biāo)
Python 版本
Rust 版本
正常期有效率
85%
92%
48h 有效率
30%
91%
403 比例(48h)
55%
6%
Cookie 按通道隔離后,代理切換不再觸發(fā) 403。
延遲
指標(biāo)
Python 版本
Rust 版本
P50 延遲
1.1s
350ms
P99 延遲
2.3s
800ms
延遲下降來(lái)自?xún)煞矫妫哼B接池參數(shù)調(diào)優(yōu)減少了空閑連接競(jìng)爭(zhēng);Rust 的異步運(yùn)行時(shí)在并發(fā)請(qǐng)求調(diào)度上開(kāi)銷(xiāo)更低。
如何確認(rèn)修復(fù)生效
1. 內(nèi)存監(jiān)控:部署后觀察 RSS 內(nèi)存曲線(xiàn),72 小時(shí)內(nèi)應(yīng)無(wú)明顯上升趨勢(shì)。如果持續(xù)增長(zhǎng),檢查是否有未 drop 的 Client 實(shí)例
2. 403 比例:監(jiān)控 403 響應(yīng)占總請(qǐng)求的比例,應(yīng)低于 10%。如果高于此值,檢查 Cookie Jar 是否正確隔離
3. 連接數(shù):通過(guò) ss -tnp | grep crawler 檢查 ESTABLISHED 連接數(shù),應(yīng)穩(wěn)定在合理范圍內(nèi)(通常 < 100)
4. 文件描述符:ls /proc/<pid>/fd | wc -l 確認(rèn) fd 數(shù)量不持續(xù)增長(zhǎng)
適用場(chǎng)景
* 需要長(zhǎng)時(shí)間運(yùn)行(> 24h)的爬蟲(chóng)任務(wù)
* 目標(biāo)站點(diǎn)有 IP-Cookie 綁定反爬策略
* 需要頻繁切換代理 IP 的場(chǎng)景
* 對(duì)內(nèi)存占用有嚴(yán)格限制的環(huán)境
不適用場(chǎng)景
* 一次性短時(shí)抓取(Python requests 足夠,無(wú)需引入 Rust 工具鏈)
* 目標(biāo)站點(diǎn)無(wú) Cookie 校驗(yàn)(Cookie 隔離的收益不明顯)
* 代理 IP 固定不切換(連接泄漏問(wèn)題不突出)
環(huán)境前提
* Rust 1.75+(需要 2021 edition)
* 億牛云爬蟲(chóng)代理賬號(hào)(t.16yun.cn:31111)
* 目標(biāo)學(xué)術(shù)站點(diǎn)的登錄憑證(如需抓取受限內(nèi)容)
常見(jiàn)錯(cuò)誤
1. 忘記在 rotate_tunnel 中替換整個(gè)隧道:只換代理 URL 不換 Cookie Jar,Cookie 仍然跨 IP 泄漏
2. Cookie 注入時(shí)機(jī)錯(cuò)誤:在請(qǐng)求發(fā)出后才注入 Cookie,導(dǎo)致首次請(qǐng)求不帶登錄態(tài)。應(yīng)在 rotate_tunnel 后立即注入
3. 連接池參數(shù)過(guò)大:pool_max_idle_per_host 設(shè)置過(guò)高會(huì)抵消內(nèi)存優(yōu)化效果,建議 5-10
4. UA 與代理不同步:User-Agent 固定不變而 IP 頻繁切換,會(huì)觸發(fā)行為異常檢測(cè)
取舍與副作用
* Rust 工具鏈成本:團(tuán)隊(duì)需要熟悉 Rust 生態(tài),編譯時(shí)間比 Python 長(zhǎng)
* 開(kāi)發(fā)效率下降:Rust 的借用檢查器在初期會(huì)增加開(kāi)發(fā)時(shí)間,但換來(lái)的是運(yùn)行期的確定性
* Cookie 隔離的代價(jià):每次代理切換需要重新建立會(huì)話(huà)(登錄),增加了首次請(qǐng)求延遲。對(duì)于需要登錄的站點(diǎn),可在通道創(chuàng)建時(shí)預(yù)登錄
* 單通道串行:當(dāng)前實(shí)現(xiàn)每個(gè)通道串行請(qǐng)求,如需并發(fā)需為每個(gè)目標(biāo)站點(diǎn)分配獨(dú)立通道,內(nèi)存占用會(huì)線(xiàn)性增長(zhǎng)