學(xué)術(shù)文獻(xiàn)爬蟲(chóng) OOM 崩潰與 403 風(fēng)暴

爬蟲(chóng)代理

連續(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)

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容