據(jù)采集管道工程化實踐:會話保持與自適應限速)
電商數(shù)據(jù)采集這件事做過的人都清楚最難的從來不是把網(wǎng)頁抓下來這第一步而是讓整條管道在目標站點的各種反制策略下持續(xù)、穩(wěn)定、低噪音地跑下去。我前后在三個不同規(guī)模的電商數(shù)據(jù)項目里負責過采集鏈路的搭建從最初用單文件腳本硬懟到后來拆成模塊化的采集管道中間踩的坑足夠?qū)懸槐拘宰?。這次想聊的是我在最近一個項目里圍繞請求模塊和會話保持這兩個核心環(huán)節(jié)做的一整套工程化實踐——它不是什么高深的技術(shù)但每一個細節(jié)都直接決定了你的采集管道是跑三天就崩還是能安安穩(wěn)穩(wěn)跑上幾個月。先把話說在前面這篇文章討論的所有內(nèi)容都建立在合法合規(guī)采集公開數(shù)據(jù)的前提下。電商平臺的服務條款、robots協(xié)議、數(shù)據(jù)使用邊界這些是動手之前就必須搞清楚的事。我下面講的所有技術(shù)手段目的都是降低對目標站點的無效請求壓力、提高自身采集效率而不是去對抗什么。這個前提想明白了后面的內(nèi)容才有意義。1. 為什么單文件腳本撐不過第一周1.1 從能跑到能持續(xù)跑之間的鴻溝我見過太多人寫采集腳本的方式打開編輯器寫一個requests.get解析頁面存數(shù)據(jù)跑通了收工。第一天一切正常第二天開始偶爾報錯第三天大量超時第四天直接被限流封IP。然后就開始到處找為什么被封的答案卻很少有人回頭想一個問題——你的腳本從一開始就不是為持續(xù)運行設(shè)計的。單文件腳本的問題不在于代碼寫得爛而在于它缺少工程意義上的分層。請求發(fā)起、會話管理、錯誤處理、重試策略、頻率控制、數(shù)據(jù)落庫這些東西全部揉在一個函數(shù)里任何一個環(huán)節(jié)出問題都會導致整個流程崩潰而且你很難定位到底是哪一層出了毛病。更關(guān)鍵的是單文件腳本通常沒有狀態(tài)管理——它不知道上一次請求是什么時候發(fā)的不知道當前會話是否還有效不知道某個商品頁是不是已經(jīng)抓過了。這種無記憶的運行方式在目標站點看來就是一臺毫無規(guī)律的請求機器被識別和限制只是時間問題。1.2 采集管道的分層思路我后來把整條鏈路拆成了四個相對獨立的層每一層只關(guān)心自己的事層級職責關(guān)鍵設(shè)計點請求層發(fā)起HTTP請求、處理響應會話復用、超時控制、請求頭管理調(diào)度層決定什么時候發(fā)下一個請求頻率控制、任務隊列、優(yōu)先級解析層從響應中提取結(jié)構(gòu)化數(shù)據(jù)容錯解析、字段校驗、異常標記存儲層數(shù)據(jù)落庫與去重冪等寫入、增量更新、失敗重試這么拆的好處是當采集出問題時你可以快速判斷是請求層被限了、調(diào)度層節(jié)奏亂了、還是解析層遇到頁面改版了。每一層都可以單獨測試、單獨優(yōu)化、單獨替換而不會牽一發(fā)動全身。這篇文章的重點會放在請求層和調(diào)度層的交界處——也就是會話保持和請求節(jié)奏控制因為這兩個地方是絕大多數(shù)采集管道猝死的直接原因。2. 請求模塊的會話保持到底在保持什么2.1 會話保持不是加個Cookie那么簡單很多人對會話保持的理解停留在把登錄后的Cookie帶上。但在電商采集場景里會話保持要復雜得多。一個完整的會話狀態(tài)至少包含這幾樣東西Cookie包括服務端下發(fā)的會話標識和各類追蹤標識、連接復用TCP層面的keep-alive、請求頭的一致性User-Agent、Accept-Language等不能這次一個樣下次另一個樣、以及請求之間的時間間隔特征。我舉個實際例子。早期我用requests.Session()做會話保持以為只要復用Session對象就萬事大吉。結(jié)果發(fā)現(xiàn)跑了一段時間后請求成功率開始下降。排查后發(fā)現(xiàn)兩個問題一是Session默認的連接池大小有限并發(fā)一高就開始排隊甚至丟棄連接二是Session里的Cookie會隨著服務端下發(fā)不斷累積某些追蹤Cookie過期后沒有及時清理導致請求頭越來越臃腫反而增加了被識別的概率。2.2 連接池與會話對象的正確配置requests的Session底層用的是urllib3的連接池默認pool_connections是10pool_maxsize也是10。這個默認值在低并發(fā)場景夠用但如果你要同時維持幾十個會話就必須顯式調(diào)整。我的做法是根據(jù)并發(fā)規(guī)模來定import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry def build_session(pool_size20, retry_times3): session requests.Session() retry Retry( totalretry_times, backoff_factor0.5, status_forcelist[429, 500, 502, 503, 504], allowed_methods[GET, HEAD] ) adapter HTTPAdapter( pool_connectionspool_size, pool_maxsizepool_size, max_retriesretry ) session.mount(http://, adapter) session.mount(https://, adapter) return session這里有幾個參數(shù)值得展開說。backoff_factor0.5意味著重試間隔會按0.5、1、1.5秒這樣遞增給目標站點留出喘息空間也避免自己在短時間內(nèi)反復沖擊。status_forcelist里我特意加了429這是請求過多的標準狀態(tài)碼遇到它必須退讓而不是硬剛。allowed_methods只允許GET和HEAD重試因為POST重試可能造成重復提交這個坑我在另一個項目里踩過代價是數(shù)據(jù)庫里多了一批重復訂單記錄。2.3 Cookie的生命周期管理Cookie管理是會話保持里最容易被忽視的一環(huán)。我的經(jīng)驗是不要無腦保留所有Cookie而是要有選擇地維護。具體做法是給Session掛一個響應鉤子每次收到響應后檢查Set-Cookie對關(guān)鍵會話Cookie做更新對追蹤類Cookie做定期清理。def prune_cookies(session, keep_keys, max_age3600): now time.time() for cookie in list(session.cookies): if cookie.name in keep_keys: continue if cookie.expires and cookie.expires now: session.cookies.clear(cookie.domain, cookie.path, cookie.name)keep_keys里放的是真正維持會話所必需的字段其余的一律按過期時間清理。這個操作看起來不起眼但實測下來能讓單個會話的穩(wěn)定運行時長提升不少因為請求頭不會隨著時間推移越來越臃腫。提示Cookie的清理策略要根據(jù)目標站點的實際行為來定。有些站點的會話標識藏在非標準字段里清理前一定要先抓包確認哪些字段是真正必需的別把關(guān)鍵會話Cookie誤刪了。3. 請求節(jié)奏控制讓管道跑得久的核心3.1 固定間隔為什么是錯的新手最常用的頻率控制是time.sleep(1)每個請求之間固定睡一秒。這個做法的問題在于它假設(shè)目標站點的負載和你的請求節(jié)奏是無關(guān)的但現(xiàn)實恰恰相反。固定間隔在目標站點看來是一種極其規(guī)律的信號——每秒一次精確得像機器而正常用戶的訪問節(jié)奏是隨機的、有停頓的、有突發(fā)的。我做過一個對比測試同樣抓1000個商品頁固定1秒間隔的方案在跑到大約600個請求時開始出現(xiàn)明顯的響應變慢和偶發(fā)429而改成隨機間隔在0.8到2.5秒之間波動后1000個請求全部完成沒有觸發(fā)限流。這個差異不是偶然的隨機化讓請求模式更接近真實用戶行為。3.2 自適應限速的實現(xiàn)思路比隨機間隔更進一步的是自適應限速——根據(jù)目標站點的響應狀態(tài)動態(tài)調(diào)整請求頻率。核心邏輯很簡單響應正常就維持或略微加快遇到429或響應時間明顯變長就主動降速。class AdaptiveRateLimiter: def __init__(self, base_interval1.0, min_interval0.5, max_interval10.0): self.base base_interval self.min min_interval self.max max_interval self.current base_interval self.last_request 0 def wait(self): elapsed time.time() - self.last_request if elapsed self.current: time.sleep(self.current - elapsed) self.last_request time.time() def on_success(self): self.current max(self.min, self.current * 0.95) def on_throttle(self): self.current min(self.max, self.current * 2.0)這個類的用法是每次請求前調(diào)wait()請求成功后調(diào)on_success()讓間隔緩慢收縮遇到限流信號調(diào)on_throttle()讓間隔快速翻倍。收縮慢、擴張快這個不對稱設(shè)計是關(guān)鍵——降速要果斷提速要謹慎這樣才能在穩(wěn)定和效率之間找到平衡點。3.3 并發(fā)與串行的取舍很多人一上來就想上高并發(fā)覺得并發(fā)越高采集越快。但在電商采集場景里并發(fā)是一把雙刃劍。并發(fā)確實能提高吞吐但也會讓你的請求特征更明顯而且一旦某個會話出問題并發(fā)下的錯誤傳播會更快。我的建議是先串行跑通再逐步加并發(fā)。具體做法是先用單會話串行采集觀察目標站點的響應特征確定一個安全的請求頻率然后在這個頻率基礎(chǔ)上用多個獨立會話做并發(fā)每個會話維護自己的節(jié)奏。這樣即使某個會話被限其他會話不受影響整體管道不會崩。并發(fā)策略適用場景風險單會話串行目標站點限制嚴格、數(shù)據(jù)量小速度慢但最穩(wěn)多會話并發(fā)數(shù)據(jù)量大、站點容忍度較高需要精細的會話隔離分布式采集超大規(guī)模、多節(jié)點調(diào)度復雜成本高4. 采集管道里那些看起來沒事其實要命的細節(jié)4.1 超時設(shè)置不設(shè)超時等于埋雷requests默認沒有超時這意味著如果目標站點不響應你的請求會一直掛著直到操作系統(tǒng)層面的TCP超時可能是幾分鐘甚至更久。在采集管道里一個掛死的請求會占住一個連接連接池很快就被耗盡整條管道就卡死了。我的做法是給每個請求設(shè)置分層的超時連接超時短一些比如5秒讀取超時長一些比如15秒。連接超時短是因為如果連TCP握手都完不成說明網(wǎng)絡或目標站點有問題沒必要等讀取超時長是因為有些頁面確實需要更長的傳輸時間。response session.get(url, timeout(5, 15))這個元組的第一個值是連接超時第二個是讀取超時。別小看這個設(shè)置我在一個項目里就是因為漏了超時導致管道在某個深夜卡死第二天早上才發(fā)現(xiàn)白白浪費了八個小時的采集窗口。4.2 請求頭的人味請求頭是目標站點識別請求來源的重要依據(jù)。默認的requests請求頭里User-Agent是python-requests/x.x.x這等于直接告訴對方我是腳本。所以必須替換成真實的瀏覽器UA而且要注意UA和其他請求頭的一致性。我通常會維護一組請求頭模板每個會話固定使用其中一套不要這次用Chrome的UA下次用Firefox的那樣反而不自然。一套完整的請求頭至少包括headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ..., Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Accept-Encoding: gzip, deflate, br, Connection: keep-alive, }注意Accept-Encoding里帶上brBrotli因為現(xiàn)代瀏覽器都支持不帶反而不正常。requests本身不自動解壓br需要額外裝brotli庫這個細節(jié)很多人會漏。4.3 錯誤分類處理不是所有錯誤都值得重試采集過程中會遇到各種各樣的錯誤但它們的性質(zhì)完全不同。我把錯誤分成三類可重試錯誤超時、連接重置、5xx服務端錯誤、429限流。這些是暫時性的退避后重試通常能成功。不可重試錯誤404頁面不存在、403禁止訪問、401未授權(quán)。這些重試多少次都沒用應該直接標記并跳過。需要人工介入的錯誤頁面結(jié)構(gòu)變化導致的解析失敗、驗證碼攔截。這些不是代碼能自動解決的需要報警通知。把錯誤分類處理的好處是重試邏輯不會浪費在注定失敗的請求上同時真正需要關(guān)注的問題能及時暴露出來。我在管道里加了一個錯誤計數(shù)器當某類錯誤在短時間內(nèi)超過閾值時自動暫停采集并發(fā)出通知避免在目標站點已經(jīng)明確拒絕的情況下繼續(xù)硬沖。5. 從采集到落庫管道末端的穩(wěn)定性設(shè)計5.1 冪等寫入重復采集不可怕重復入庫才可怕采集管道在重試機制下同一個商品頁被采集兩次是很正常的事。如果存儲層沒有做冪等處理數(shù)據(jù)庫里就會出現(xiàn)重復記錄。我的做法是給每條數(shù)據(jù)加一個基于業(yè)務字段的唯一標識比如商品ID加采集日期寫入時用upsert而不是insert。INSERT INTO products (product_id, title, price, updated_at) VALUES (%s, %s, %s, %s) ON DUPLICATE KEY UPDATE title VALUES(title), price VALUES(price), updated_at VALUES(updated_at);這樣即使同一個商品被采集多次數(shù)據(jù)庫里也只有一條記錄且保留的是最新數(shù)據(jù)。這個設(shè)計讓采集管道可以放心地重試不用擔心數(shù)據(jù)污染。5.2 斷點續(xù)采管道重啟后不用從頭再來采集管道難免會因為各種原因中斷——網(wǎng)絡故障、機器重啟、代碼更新。如果沒有斷點續(xù)采能力每次中斷都要從頭開始效率極低。實現(xiàn)斷點續(xù)采的核心是維護一個已完成任務的記錄管道啟動時先讀取這個記錄跳過已完成的部分。我通常用Redis的Set來存已完成的URL哈希每完成一個請求就SADD進去。管道重啟時先用SMEMBERS拿到已完成集合然后在任務隊列里過濾掉這些URL。這個方案的好處是讀寫快、支持去重、可以設(shè)置過期時間自動清理歷史記錄。注意斷點續(xù)采的記錄要和實際落庫狀態(tài)保持一致。我遇到過一次因為落庫失敗但斷點記錄已寫入導致那批數(shù)據(jù)永久丟失的情況。后來改成先落庫成功、再寫斷點記錄雖然多了一步但數(shù)據(jù)一致性有了保障。6. 實測中的幾個反直覺發(fā)現(xiàn)6.1 慢一點反而更快這個結(jié)論聽起來矛盾但實測數(shù)據(jù)支持它。在一個抓取5萬個商品頁的項目里我把請求間隔從0.5秒放寬到1.5秒總耗時反而從預計的7小時縮短到了5.5小時。原因是間隔太短時大量請求觸發(fā)限流進入重試隊列重試的退避等待加上失敗請求的浪費總時間反而更長。放寬間隔后首次請求成功率大幅提升重試少了整體效率就上來了。6.2 會話不是越多越好我一度以為多開幾個會話就能線性提升吞吐結(jié)果發(fā)現(xiàn)會話數(shù)超過某個閾值后成功率開始下降。原因是每個會話都需要獨立的連接和Cookie維護會話太多時單個會話的請求間隔被拉長反而失去了活躍會話的特征更容易被識別為異常。后來我把會話數(shù)控制在8到12個之間配合自適應限速整體表現(xiàn)最穩(wěn)定。6.3 日志比你想的更重要采集管道的日志不是用來記錄發(fā)生了什么的而是用來發(fā)現(xiàn)將要發(fā)生什么的。我在管道里記錄了每個請求的響應時間、狀態(tài)碼、重試次數(shù)然后用簡單的滑動窗口統(tǒng)計來監(jiān)控這些指標。當平均響應時間開始上升、或429出現(xiàn)頻率增加時說明目標站點正在收緊限制這時候主動降速比等到被封再處理要明智得多。class MetricsCollector: def __init__(self, window100): self.window window self.latencies deque(maxlenwindow) self.throttle_count 0 def record(self, latency, status_code): self.latencies.append(latency) if status_code 429: self.throttle_count 1 def avg_latency(self): return sum(self.latencies) / len(self.latencies) if self.latencies else 0 def should_slow_down(self): return self.avg_latency() 3.0 or self.throttle_count 5這個監(jiān)控邏輯很簡單但它在實際項目里幫我提前發(fā)現(xiàn)了好幾次目標站點的策略調(diào)整讓我能在管道被限之前主動調(diào)整節(jié)奏。7. 關(guān)于合規(guī)與邊界的一點個人體會技術(shù)手段再高明也不能越過合規(guī)的底線。我在每個采集項目開始前都會做三件事確認目標數(shù)據(jù)是公開可訪問的、閱讀并遵守目標站點的robots協(xié)議和服務條款、控制采集頻率不對目標站點造成實質(zhì)性負擔。這三件事不是走形式而是讓項目能長期做下去的前提。采集頻率的控制不只是技術(shù)問題也是態(tài)度問題。我始終認為一個負責任的采集管道應該是輕手輕腳的——它獲取自己需要的數(shù)據(jù)但不給目標站點添麻煩。自適應限速、錯誤退避、會話管理這些技術(shù)手段本質(zhì)上都是在實現(xiàn)這個目標。當你把不給對方添麻煩作為設(shè)計原則時很多技術(shù)決策會變得清晰該慢就慢該退就退該停就停。這套管道我在最近的項目里跑了將近四個月中間只因為目標站點頁面改版調(diào)整過一次解析邏輯請求層和調(diào)度層幾乎沒有出過問題?;剡^頭看真正讓管道穩(wěn)定的不是什么黑科技而是把每一個細節(jié)都想清楚、做到位——超時設(shè)了、重試分了類、會話管了生命周期、頻率做了自適應、錯誤有了監(jiān)控。這些東西單拎出來都很普通但組合在一起就是一條能持續(xù)跑的采集管道。