化:線程池、Session與超時重試)
1. 瓶頸在哪別急著加線程先看清多線程爬蟲的真相很多剛開始寫爬蟲的朋友一遇到采集速度慢第一反應(yīng)就是“上多線程”。這個思路本身沒錯但問題在于如果不搞清楚多線程到底優(yōu)化了什么、沒優(yōu)化什么那很可能開了一堆線程速度不升反降甚至被目標(biāo)網(wǎng)站封了IP整個采集任務(wù)直接報廢。我先說結(jié)論Python多線程爬蟲的性能優(yōu)化核心不在于“多用幾個線程”而在于讓線程真正忙起來。你需要先想清楚一個問題——你的爬蟲當(dāng)前的時間到底花在哪里。如果是花在等待網(wǎng)絡(luò)響應(yīng)上多線程確實有效如果是花在CPU計算上那多線程不僅沒幫助還會因為GIL鎖拖慢速度。這一步判斷錯后面所有優(yōu)化方案都是空中樓閣。這里就要提到Python里一個繞不開的概念GILGlobal Interpreter Lock全局解釋器鎖。通俗點講GIL保證了同一個進程里同一時刻只有一個線程在執(zhí)行Python字節(jié)碼。所以如果你的爬蟲主要是在做CPU密集型的計算比如對每一篇抓下來的HTML做復(fù)雜的正則匹配、用大量的邏輯判斷去解析字段那么多線程并不會讓這些計算并行執(zhí)行。真正要加速這種場景應(yīng)該考慮多進程或者直接換工具。那多線程爬蟲到底優(yōu)化了什么呢答案是IO等待時間。網(wǎng)絡(luò)請求發(fā)出后CPU一直在等待響應(yīng)返回。這個等待過程不占用GIL——在真正的IO等待期間Python會釋放GIL讓其他線程有機會執(zhí)行。也就是說多線程爬蟲的價值在于當(dāng)某一個請求正在等待網(wǎng)絡(luò)的毫秒級延遲時另外的線程可以去發(fā)起新的請求、處理已經(jīng)返回的數(shù)據(jù)。這樣整體上就把看似零散的等待時間拼了起來單位時間內(nèi)能發(fā)出去的請求自然變多了。明白了這層原理再看網(wǎng)上那些“線程數(shù)拉到50”“并發(fā)開200”的配置就知道有多危險了。開多少線程合適要看你的目標(biāo)網(wǎng)站響應(yīng)速度、你本機的性能、以及爬蟲單次請求的業(yè)務(wù)邏輯復(fù)雜度。盲目開太多線程CPU切換線程的成本會陡增目標(biāo)服務(wù)器也可能直接把你拉黑得不償失。2. 基礎(chǔ)優(yōu)化線程池、任務(wù)隊列與請求會話的正確姿勢2.1 線程數(shù)量到底開多少合適先給一個參考公式。假設(shè)目標(biāo)網(wǎng)站單次響應(yīng)耗時是 (T_r)本地解析和寫數(shù)據(jù)耗時是 (T_p)那么單個線程完成一個請求的周期大約是 (T_r T_p)。如果我們希望本機每秒發(fā)出 (N) 個請求那么理論上需要的線程數(shù)大約是[ 線程數(shù) \approx N \times (T_r T_p) ]舉個例子目標(biāo)接口平均響應(yīng)200ms本地解析加存儲大概需要50ms一個請求完整體驗是250ms。如果你希望每秒發(fā)出20個請求那差不多需要 (20 \times 0.25 5) 個線程。當(dāng)然這只是理論值還要考慮網(wǎng)絡(luò)抖動、目標(biāo)服務(wù)器限流、本機上下文切換的開銷實際使用中我會在這個理論值基礎(chǔ)上乘以1.2到1.5的安全系數(shù)。不過說實話公式只是起步參考真正靠譜的辦法是從低到高逐步壓測。先把線程數(shù)設(shè)成2穩(wěn)定跑一分鐘觀察每秒請求數(shù)和錯誤率再逐步升到4、8、16直到出現(xiàn)響應(yīng)變慢或錯誤增多的拐點那個拐點附近就是這臺機器、這個目標(biāo)站點的合理線程數(shù)。我見過太多人一上來就開50個線程抓一個小網(wǎng)站結(jié)果目標(biāo)服務(wù)器響應(yīng)從200ms被打到2秒錯誤率飆升。爬蟲不是拉滿就是好細(xì)水長流才是長久之計。2.2 線程池比手動創(chuàng)建線程靠譜得多多線程爬蟲最忌諱的做法是每個請求都手動創(chuàng)建新線程。假設(shè)你要采集10萬條數(shù)據(jù)每條都threading.Thread(...).start()那就意味著系統(tǒng)需要創(chuàng)建和銷毀10萬個線程。每次創(chuàng)建線程都要申請內(nèi)存、初始化運行時環(huán)境銷毀時還要回收資源這些開銷加起來非??捎^而且線程數(shù)量一旦失控系統(tǒng)調(diào)度壓力陡增爬蟲性能反而會直線下降。正確的方案是用線程池把線程的生命周期統(tǒng)一管理起來。Python自帶的concurrent.futures.ThreadPoolExecutor就夠用了簡單、沒有額外依賴適合絕大多數(shù)采集場景。from concurrent.futures import ThreadPoolExecutor, as_completed def fetch_one(url): # 實際的請求和解析邏輯 return url, data urls [https://example.com/item/1, https://example.com/item/2, ...] # 合理設(shè)置線程數(shù)這里以8為例 with ThreadPoolExecutor(max_workers8) as executor: future_map {executor.submit(fetch_one, url): url for url in urls} for future in as_completed(future_map): url future_map[future] try: result future.result() # 處理結(jié)果比如寫庫、寫文件 except Exception as e: print(f請求失敗: {url}, 錯誤: {e})with語句會在代碼塊結(jié)束時自動調(diào)用shutdown(waitTrue)優(yōu)雅地等待所有線程完成任務(wù)。這個細(xì)節(jié)很重要它避免了主線程提前退出導(dǎo)致子線程任務(wù)被強殺的問題。寫爬蟲跑批任務(wù)時我踩過好幾次這種坑主線程里的任務(wù)提交完了直接退出結(jié)果半數(shù)的請求還沒回來程序就結(jié)束了。2.3 任務(wù)隊列緩沖區(qū)的藝術(shù)線程池解決了線程生命周期管理問題但還需要考慮任務(wù)分發(fā)。如果你的采集任務(wù)是一個動態(tài)列表——比如先抓列表頁拿到一批詳情頁URL之后再去抓詳情頁——那光靠固定列表就不夠用了。這時需要引入生產(chǎn)者消費者模式。queue.Queue是標(biāo)準(zhǔn)庫自帶的任務(wù)隊列特點是線程安全多線程同時往里塞數(shù)據(jù)、取數(shù)據(jù)都不會出問題。整體結(jié)構(gòu)上可以分成三層生產(chǎn)者負(fù)責(zé)把待抓取的URL放進隊列工作線程從隊列里取URL去請求和解析消費者負(fù)責(zé)把解析結(jié)果落庫或?qū)懳募mport queue import threading task_queue queue.Queue(maxsize200) # 生產(chǎn)者不斷往隊列里放URL def producer(url_list): for url in url_list: task_queue.put(url) # 消費者工作線程從隊列取URL并抓取 def worker(): while True: url task_queue.get() if url is None: # 哨兵值用于退出循環(huán) break try: fetch_url(url) finally: task_queue.task_done()有幾個細(xì)節(jié)值得注意。一是隊列長度要限制用maxsize設(shè)置一個合理上限。如果隊列無窮大生產(chǎn)者速度遠快于消費者內(nèi)存會被待處理URL擠爆。二是要設(shè)置哨兵值比如None來通知工作線程退出不給的話所有線程會永遠卡在task_queue.get()上主程序退不干凈。三是task_done()與join()配合只有所有任務(wù)都標(biāo)記完成queue.join()才會返回這能確保退出前所有任務(wù)都真正處理完了。2.4 請求會話復(fù)用每個線程一個專屬連接爬蟲一旦上了多線程很多人都會遇到一個奇怪的現(xiàn)象單線程時請求很正常一開多線程頻繁出現(xiàn)連接被重置、socket超時。排查到最后發(fā)現(xiàn)問題往往出在Session的使用方式上。requests.Session內(nèi)部維護了TCP連接池可以復(fù)用底層連接減少每次請求時三次握手和TLS握手的開銷。但如果多個線程共享同一個Session連接池的并發(fā)競爭會導(dǎo)致連接狀態(tài)混亂反而觸發(fā)各種奇怪的網(wǎng)絡(luò)錯誤。正確的姿勢是每個線程一個Session或者至少保證Session的創(chuàng)建和線程綁定。在ThreadPoolExecutor場景下可以用threading.local()為每個線程保存獨立的Session實例import threading import requests thread_local threading.local() def get_session(): if not hasattr(thread_local, session): thread_local.session requests.Session() thread_local.session.headers.update({ User-Agent: Mozilla/5.0 (compatible; MySpider/1.0) }) return thread_local.session這樣每個線程只會創(chuàng)建一次Session后續(xù)所有請求都復(fù)用線程內(nèi)的TCP連接既避免了共享Session的線程安全問題又讓連接池真正發(fā)揮作用。從實測效果來看無論是目標(biāo)響應(yīng)速度還是本地資源占用這個改動帶來的提升都相當(dāng)明顯。2.5 超時與重試不給僵尸線程留機會多線程爬蟲最怕的一個場景某個請求永遠不返回線程在那里干等什么活也不干。如果一個工作線程被一個超時未響應(yīng)的請求卡住幾分鐘那這個線程的吞吐貢獻基本就歸零了。更麻煩的是如果一個進程里有多個線程同時被不同請求卡住整個任務(wù)會越跑越慢看著像是卡死了實際上是在等超時。標(biāo)準(zhǔn)做法是在請求層設(shè)置明確的超時時間并且區(qū)分連接超時和讀取超時session.get(url, timeout(3.05, 10))這個元組里第一個值是連接超時連接建立超過3.05秒就放棄第二個值是讀取超時數(shù)據(jù)包間隔超過10秒就放棄。設(shè)置超時之后還要配合重試機制。重試不是簡單地重來一遍而是要有退避策略——第一次失敗后等1秒第二次等2秒逐步放大間隔避免在目標(biāo)服務(wù)器還沒恢復(fù)時用高并發(fā)反復(fù)沖擊把自己送進封禁名單。requests庫本身不支持自動重試需要借助requests.adapters.HTTPAdapter來注入urllib3的Retry策略from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry retry_strategy Retry( total3, # 最多重試3次 backoff_factor1, # 退避時間1s, 2s, 4s status_forcelist[429, 500, 502, 503, 504], allowed_methods[GET, POST], ) adapter HTTPAdapter(max_retriesretry_strategy, pool_connections10, pool_maxsize10) session get_session() session.mount(http://, adapter) session.mount(https://, adapter)需要注意status_forcelist里加429是指定HTTP 429請求過多也納入重試范圍。目標(biāo)服務(wù)器返回429就是在告訴你“慢一點”此時退避重試是最正確的應(yīng)對方式大部分情況下能夠平穩(wěn)渡過限流窗口。3. 性能黑洞與瓶頸排查從日志到數(shù)據(jù)每一分鐘都花在刀刃上3.1 數(shù)據(jù)解析優(yōu)化容易被忽略的大頭不少爬蟲在優(yōu)化時只顧著調(diào)線程數(shù)忘了看數(shù)據(jù)解析環(huán)節(jié)的耗時。這里有個真實的模擬場景某個采集任務(wù)需要抓取商品列表每個列表頁20個商品。最初所有解析邏輯都用BeautifulSoup的find_all配合CSS選擇器把整個HTML先轉(zhuǎn)成復(fù)雜的對象樹再反復(fù)查詢。單個頁面解析耗時接近120ms對于每秒要處理幾十個頁面的爬蟲這部分CPU計算量就很可觀了。而且要強調(diào)一點解析是純CPU計算前面講過GIL的限制這部分計算在多線程下不會并行加速。也就是說線程開得越多每個線程分到的CPU時間片反而越少整體吞吐甚至可能下降。所以數(shù)據(jù)解析優(yōu)化往往是爬蟲性能提升空間最大的環(huán)節(jié)。常見的優(yōu)化路線是“從重到輕”如果有明確規(guī)律的數(shù)據(jù)優(yōu)先用正則表達式或字符串切片處理如果需要處理復(fù)雜嵌套的HTML用lxml的etree.HTML配合XPath性能比BeautifulSoup高一個數(shù)量級只有確實需要容錯性很強的解析時才保留BeautifulSoup并且盡量用lxml作為底層解析器即在BeautifulSoup(markup, lxml)中指定解析引擎。from lxml import etree def parse_item(html): tree etree.HTML(html) # XPath提取標(biāo)題 title tree.xpath(//h1[classitem-title]/text()) # XPath提取價格 price tree.xpath(//span[classprice]/text()) return title[0].strip() if title else , price[0].strip() if price else 拿同一個模擬頁面做對比測試BeautifulSoup方案耗時約120ms換etree.HTML加XPath之后單頁解析大概25ms提升接近5倍。這還是小頁面如果遇到幾千行的大HTML差距會更夸張。這個優(yōu)化本質(zhì)上不改變線程數(shù)量但每個線程能處理的頁面數(shù)大幅上升整體吞吐自然就上去了。3.2 序列化與存儲把“寫”變成異步操作解析完成之后的數(shù)據(jù)要去哪里如果每次解析完都直接寫數(shù)據(jù)庫或?qū)懳募@個寫操作會阻塞當(dāng)前線程。尤其是寫入數(shù)據(jù)庫的場景如果使用同步連接一次插入就算只要幾毫秒在成千上萬次請求的積累下阻塞時間也相當(dāng)可觀。更合理的設(shè)計是把數(shù)據(jù)寫入做成異步??梢杂靡粋€單獨的消費者線程把解析結(jié)果放進另一個隊列由專門的存儲線程批量處理。批量插入比逐條插入的效率高得多還能減少數(shù)據(jù)庫連接建立和事務(wù)提交的開銷。import sqlite3 import queue store_queue queue.Queue(maxsize500) store_thread_stop threading.Event() def store_worker(): conn sqlite3.connect(data.db) cur conn.cursor() batch_size 50 while not store_thread_stop.is_set(): batch [] # 批量取出最多50條 for _ in range(batch_size): try: item store_queue.get_nowait() batch.append(item) except queue.Empty: break if batch: cur.executemany( INSERT INTO items(url, title, price) VALUES(?, ?, ?), batch ) conn.commit() else: store_thread_stop.wait(0.5) # 隊列空時短暫休眠 store_thread threading.Thread(targetstore_worker) store_thread.start()這里把寫庫放到了獨立線程中抓取線程只需要把數(shù)據(jù)塞進隊列就能立刻返回繼續(xù)抓取下一個頁面。雖然看起來只是把阻塞從抓取線程轉(zhuǎn)移到了存儲線程但效果完全不同抓取線程不再被磁盤或數(shù)據(jù)庫拖慢系統(tǒng)整體吞吐量由生產(chǎn)和消費之間的平衡決定而不受最慢環(huán)節(jié)的制約。3.3 請求耗時與并發(fā)收益的可視化優(yōu)化過程中有一個操作我建議所有爬蟲開發(fā)者都做打印一份請求耗時分布統(tǒng)計。不用多復(fù)雜的工具最簡單的就是按耗時區(qū)間分組記錄請求數(shù)量比如0-100ms、100-200ms、200-500ms、500ms以上各有多少請求。跑一批樣本請求后你會看到耗時分布呈現(xiàn)什么形態(tài)這比猜要準(zhǔn)得多。舉個實際例子某個模擬抓取任務(wù)目標(biāo)接口平均耗時180ms但高耗時區(qū)間有一些尖峰達到800ms甚至1秒。如果按平均耗時180ms計算5個線程一秒鐘大約能完成25個請求。但實際跑下來每秒只有12個左右因為那些800ms的尖峰請求拖慢了整條流水線。這類情況下先減少單次請求的傳輸量、優(yōu)化請求頭、啟用壓縮傳輸往往比單純加線程更有效。3.4 常見性能問題速查表現(xiàn)象可能原因排查思路解決方案線程增多但吞吐不升解析邏輯密集GIL限制用profile工具統(tǒng)計CPU時間占比優(yōu)化解析代碼考慮多進程頻繁connection reset多個線程共享一個Session查看是否有人為共享Session用threading.local實現(xiàn)每線程獨立Session請求大量超時線程數(shù)超過目標(biāo)服務(wù)器承載壓測找到拐點降低線程數(shù)增加退避重試任務(wù)跑著跑著卡住某請求長期無響應(yīng)檢查是否有請求沒設(shè)超時設(shè)置連接超時和讀取超時增加重試內(nèi)存持續(xù)上漲隊列中堆積大量未處理數(shù)