:用asyncio和aiohttp讓千頁抓取從40分鐘縮至12秒)
上個月我把一個跑了幾百個頁面的同步爬蟲腳本扔到服務器上等了大概四十多分鐘才出結果當時我一度以為程序死了。后來拆開日志去看發(fā)現(xiàn)百分之九十幾的時間都卡在等待網絡響應上CPU幾乎在摸魚。翻了很多資料試了各種寫法最終從單線程改到基于asyncio的異步方案同樣這批頁面從四十多分鐘直接跑進十幾秒穩(wěn)定到我自己都不敢信。這篇就把這套寫法和場景下的完整代碼、優(yōu)化思路、遇到的坑一次講清楚希望幫你少走彎路。如果你現(xiàn)在的爬蟲還是老老實實一個請求等到返回再去發(fā)下一個請求那這篇文章大概率能幫你省下大把時間。無論是批量采集公開數(shù)據、做接口巡檢還是處理大量帶網絡請求的重復任務異步方案都有機會帶來數(shù)量級的提升。內容會從異步的原理、環(huán)境準備、完整代碼一直到反爬應對和誤用陷阱盡量做到看完就能照著改。1. 為什么異步爬蟲能把速度拉高幾十倍事件循環(huán)才是核心1.1 爬蟲其實是一門等待的藝術做過爬蟲的都知道真正拖慢速度的不是解析數(shù)據不是寫文件而是網絡請求期間那段時間發(fā)一個請求出去然后干等著服務器響應。這個等待時間里你的程序基本什么正事都沒干線程空轉CPU占用率低得可憐。我用單線程跑某個公開數(shù)據頁面時平均一個請求耗時大約 0.35 到 0.6 秒。你算一下1000 個頁面按照每個 0.5 秒算那就是 500 秒將近九分鐘。如果中間加個重試、加個限速時間直接翻倍。這也是很多人感覺同步爬蟲慢到窒息的根本原因——不是機器不如別人是等待網絡響應的方式不對。很多人第一反應是上多線程這個思路沒錯但線程本身是有成本的。線程的創(chuàng)建、切換、銷毀都要消耗資源而且在 Python 里還有全局解釋器鎖GIL的問題真正同時執(zhí)行的字節(jié)碼指令并沒有那么多。雖然爬蟲場景里網絡等待會讓出 GIL但大量線程的管理開銷依然不少。線程數(shù)百個之后調度切換的成本會讓你明顯感到力不從心。異步就不一樣了。它讓你在發(fā)起一個請求后立刻轉頭去發(fā)起下一個請求等哪個請求的響應回來了再回頭繼續(xù)處理那個請求。這樣?個進程就能同時維護幾百上千個進行中的請求時間的利用率自然翻倍。1.2 事件循環(huán)一個人干一堆活的秘訣異步的核心是事件循環(huán)event loop。你可以把它想象成一個非常忙碌的餐廳服務員同時給好幾桌客人下單、傳菜、結賬靠的不是叫很多服務員一起上而是這一位服務員記住每張桌子的狀態(tài)哪個桌的菜好了就趕緊送過去哪個桌還要等就先去招呼別的桌。asyncio就是 Python 官方提供的事件循環(huán)實現(xiàn)。你在代碼里寫的async def函數(shù)會生成一個協(xié)程對象把協(xié)程提交給事件循環(huán)后事件循環(huán)會負責調度它們遇到await關鍵字就代表這里要等某個 I/O 操作完成我可以先去做別的事。網絡響應回來后事件循環(huán)再回到這個協(xié)程繼續(xù)往下執(zhí)行。這樣一來單進程單線程就能同時并發(fā)處理非常多的請求。對于爬蟲這種典型的 I/O 密集型任務異步幾乎就是為它量身定做的方案。1.3 不是簡單貼個 async/await 就有用很多人看過異步教程但自己動手寫爬蟲時發(fā)現(xiàn)也沒快多少原因是只學了語法沒理解模式。我見過有人寫這種代碼async def fetch_page(session, url): async with session.get(url) as resp: return await resp.text() for url in urls: fetch_page(session, url) # 這樣根本無法并發(fā)在 for 循環(huán)里逐個調用協(xié)程協(xié)程根本沒有交付給事件循環(huán)去調度效果跟同步差不多。正確的是要把所有協(xié)程包裝成 task 列表用asyncio.gather或asyncio.as_completed統(tǒng)一交給事件循環(huán)。理解了這一點后面給出的代碼才看得懂。一句話記住使用姿勢協(xié)程只是生成器交給事件循環(huán)調度才是異步。2. 性能實測數(shù)據單線程、多線程與異步的真實差距2.1 測試場景與條件說明你說1000 個頁面 12 秒極速抓完但不是隨口一句口號得有可復現(xiàn)的條件。這次實測我選取的是某個目標站點的公開詳情頁每次并發(fā)請求之間設置了合理的間隔目標頁面響應時間約 150 到 300 毫秒測試機器是本機開發(fā)環(huán)境網絡帶寬穩(wěn)定在 100MPython 版本 3.10aiohttp版本 3.9。需要說明的是不同目標站點、不同網絡環(huán)境、不同服務器負載下成績必然不同。如果你的目標站點本身要 2 秒才能返回那異步再厲害也變不出 12 秒。異步提升的是并發(fā)度和等待時間利用效率不是讓某個請求的響應時間變莫測。2.2 三種方案跑同一批 1000 個頁面的對比我在同樣的 1000 個頁面上分別用同步、多線程、異步三種方案跑過數(shù)據和體感如下方案總耗時秒說明同步 requests487一個接一個幾乎完全取決于單請求延遲多線程50 線程21有提升但線程切換和資源消耗明顯asyncio aiohttp200 并發(fā)12.6事件循環(huán)調度單進程資源占用極低這個 12.6 秒是在默認不加額外限速策略的情況下跑出來的。如果你把并發(fā)數(shù)再降低一點或者加上更保守的限速與重試退避耗時會長一些比如 200 并發(fā)左右跑到 15 到 20 秒都正常。實際項目里我不建議一上來就把并發(fā)拉滿先測出目標站點的承受能力再決定并發(fā)值。2.3 12 秒背后的限制因素很多人會問既然異步這么強為什么不是一秒跑完其實關鍵限制不在客戶端而在幾個方面TCP 連接建立與 TLS 握手即使復用連接池初次建連和加密握手依然有固定開銷。目標服務器處理能力和帶寬服務器響應我們單個請求也需要時間和資源并發(fā)太高會觸發(fā)限流甚至封禁。客戶端的本地資源比如句柄數(shù)、內存、DNS 解析速度。合理的采集頻率限制實踐里要尊重目標站點正常服務能力不能一味追求極限。所以說1000 個頁面 12 秒是一個在特定條件下的理論最好成績而不是一個到處適用的承諾。這套代碼的價值在于它讓并發(fā)能力掌握在你手里你可以按目標情況調并發(fā)數(shù)在速度和穩(wěn)定性之間找平衡。3. 環(huán)境準備與工具選型aiohttp 比重寫整個網絡層靠譜3.1 最小依賴清單跑這套異步方案不需要裝一大堆重型框架我用到的核心庫就這幾個Python 3.9推薦 3.10 或以上aiohttp提供異步 HTTP 客戶端相關能力asyncioPython 標準庫直接內置aiodns異步 DNS 解析減少解析等待lxml或BeautifulSoup4解析頁面用按需安裝安裝命令很簡單pip install aiohttp aiodns lxml beautifulsoup4如果你用 Windowsaiodns偶爾有兼容問題裝不上也不用慌aiohttp 會退回普通的線程 DNS 解析性能略降但功能正常。3.2 為什么不用 requests concurrent.futures之前我就是用requests配合concurrent.futures.ThreadPoolExecutor做多線程爬蟲的跑起來體感也不錯。但問題在于requests是同步庫一個請求在等待期間雖然會讓出 GIL但每個請求還是占一個線程線程多了內存占用和調度切換都不好看。換成aiohttp之后一個進程里跑幾百個并發(fā)請求都還游刃有余。它不是把requests換成異步就完事了而是通過事件循環(huán)統(tǒng)一調度連接池復用也更順手。因此實際項目里我更傾向于 aiohttp。3.3 第一次跑通的最小骨架在給完整代碼前先寫一個最樸素的異步請求骨架感受一下事件循環(huán)的流程import asyncio import aiohttp async def fetch_one(session, url): async with session.get(url) as resp: return await resp.text() async def main(): async with aiohttp.ClientSession() as session: html await fetch_one(session, https://httpbin.org/) print(len(html)) asyncio.run(main())這串代碼其實已經包含了事件循環(huán)、協(xié)程、請求會話三個關鍵概念。asyncio.run(main())創(chuàng)建并運行事件循環(huán)session.get是真正的異步請求方法await resp.text()表示等待響應體讀取完。如果這串在你的環(huán)境運行正常說明 aiohttp 和 asyncio 都健在接下來就可以直接上并發(fā)邏輯了。4. 核心代碼實戰(zhàn)1000 個頁面并發(fā)抓取的完整實現(xiàn)4.1 先設計架構再寫代碼寫異步爬蟲不是簡單把所有函數(shù)都改成async def而是要梳理任務流程用asyncio.Semaphore控制最大并發(fā)數(shù)防止把目標站點打掛。用同一個ClientSession復用連接池和請求頭。把每個 URL 封裝成一個抓取任務。通過asyncio.gather提交所有任務。每個任務內部具備重試、異常處理等兜底邏輯。下面這份代碼就是按照這個思路寫的我在自己項目里多次使用過直接復制后替換 URL 列表即可運行。4.2 完整代碼import asyncio import logging import random import time from urllib.parse import urlparse import aiohttp logging.basicConfig(levellogging.INFO, format%(asctime)s - %(levelname)s - %(message)s) logger logging.getLogger(__name__) # 目標頁面列表按實際需求替換 URLS [ https://httpbin.org/html for _ in range(1000) ] HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, } SEMAPHORE_COUNT 200 # 并發(fā)控制數(shù) RETRY_TIMES 3 # 請求失敗重試次數(shù) BASE_DELAY 1 # 重試基礎退避時間秒 async def fetch_with_retry(session, url, semaphore, retry_timesRETRY_TIMES): 帶信號量控制和重試機制的抓取任務。 async with semaphore: for attempt in range(1, retry_times 1): try: async with session.get(url, timeout10) as response: if response.status 200: return await response.text() elif response.status in (429, 503): retry_after response.headers.get(Retry-After) wait_time int(retry_after) if retry_after else BASE_DELAY * attempt logger.warning(收到 %s等待 %.1f 秒后重試, response.status, wait_time) await asyncio.sleep(wait_time) else: logger.warning(請求 %s 返回狀態(tài) %s, url, response.status) except (aiohttp.ClientError, asyncio.TimeoutError) as exc: logger.error(第 %s 次請求 %s 失敗%s, attempt, url, exc) # 指數(shù)退避 隨機抖動避免同時重試風暴 sleep_time BASE_DELAY * (2 ** (attempt - 1)) random.uniform(0, 0.5) if attempt retry_times: await asyncio.sleep(sleep_time) return async def batch_fetch(urls, headersNone, semaphore_countSEMAPHORE_COUNT): 批量抓取入口返回 url - 頁面內容的字典。 semaphore asyncio.Semaphore(semaphore_count) results {} async with aiohttp.ClientSession(headersheaders or HEADERS) as session: tasks { url: asyncio.create_task(fetch_with_retry(session, url, semaphore)) for url in urls } for url, task in tasks.items(): results[url] await task return results async def main(): start time.perf_counter() page_data await batch_fetch(URLS) elapsed time.perf_counter() - start success_count len([v for v in page_data.values() if v]) failed_count len(page_data) - success_count logger.info(總請求數(shù)%d成功%d失敗%d總耗時%.2f 秒, len(page_data), success_count, failed_count, elapsed) if __name__ __main__: asyncio.run(main())4.3 代碼里每個關鍵選擇的理由并發(fā)控制用的是asyncio.Semaphore(200)。信號量像一個門禁限制最多同時有 200 個協(xié)程在真正發(fā)請求。如果你不加信號量一口氣把 1000 個任務全丟給事件循環(huán)短時間內會同時建立大量連接目標服務器很容易返回 429 或直接斷連。ClientSession必須放在async with里。session 內部維護 TCP 連接池和 cookie 信息用同一個 session 發(fā)請求能復用連接減少建連開銷。如果你每個請求都新建 session異步優(yōu)勢會大打折扣TCP 握手和 TLS 握手都會重復執(zhí)行。我的 fetch 函數(shù)里用了退避重試機制參考了很多優(yōu)化實踐中的思路第一次失敗后等 1 秒多第二次等 2 秒多第三次等 4 秒多并加入隨機抖動避免所有任務在同一個時間點重試導致服務器雪崩。重試策略不是無限重試三次到頭就放棄防止某個已失效的頁面拖慢整體進度。4.4 如何運行與看結果代碼默認 URL 是 1000 個重復的 httpbin 頁面這是為了跑通而準備的。真實項目中你把URLS替換成實際采集地址列表即可。運行python async_spider.py日志會輸出總請求數(shù)、成功數(shù)、失敗數(shù)和總耗時。如果你想看到每個頁面的正文可以把results[url]寫進文件或數(shù)據庫。跑完這個流程你就擁有一個最小可用的異步爬蟲底座后面加 URL 去重、頁面解析、入庫都是水到渠成的事。5. 反反爬實戰(zhàn)從 UA 池到限速策略不靠對抗靠規(guī)范5.1 反爬機制到底在防什么反爬聞起來像技術對抗本質上其實是一套風險控制系統(tǒng)防止機器行為影響服務器穩(wěn)定、防止頻繁訪問拖垮數(shù)據庫、防止惡意采集和重復注冊。理解了目標是在保護資源之后你就會明白為什么有些做法屢屢被封而有些做法反而耐用。典型的反爬維度有三個請求特征User-Agent、Accept、Header 順序的一致性。行為特征單位時間內的請求頻率、請求間隔的規(guī)律性。身份鏈路Cookie、登錄態(tài)、瀏覽器指紋等。應對的思路從來不是攻破某個反爬體系而是模擬真實用戶的合理請求。這樣既保護了目標站點也讓自己的采集行為更規(guī)范、更持久。5.2 UA 輪換與請求頭規(guī)范我自己使用的方式是維護一個 UA 池每次請求隨機取一個USER_AGENTS [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0 Safari/537.36, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/17.4 Safari/605.1.15, Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0, ] def random_headers(): return { User-Agent: random.choice(USER_AGENTS), Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Connection: keep-alive, }然后在創(chuàng)建ClientSession時就不傳固定 headers 了改成在每次請求時傳入當前 headers。不過要注意單個 session 會保存 cookie如果站點用 cookie 做訪問用戶計數(shù)隨機 UA 但固定 cookie 同樣可能暴露規(guī)律。所以更精細的場景下你需要按需處理 cookie 的生命周期。5.3 限速策略讓采集行為更像函數(shù)調用這里說的限速不是刻意拖慢而是指動態(tài)調整并發(fā)和間隔讓請求速率處于目標服務器的可接受范圍。我在實踐中發(fā)現(xiàn)直接在代碼里加一個固定 sleep 是最簡單粗暴的方式但不夠靈活。更好的方式是把限速融合到信號量和退避策略里平時設置較低的并發(fā)數(shù)比如 30 到 80穩(wěn)為主。一旦發(fā)現(xiàn)響應碼出現(xiàn) 429 或 503立即降低并發(fā)并通過Retry-After頭等待。對同一個域名的請求保持一定間隔避免突發(fā)。這也解釋了為什么前文代碼里SEMAPHORE_COUNT是我刻意放在常量里的——它不是炫技參數(shù)而是穩(wěn)定性參數(shù)。跑陌生站點時我建議先 30 并發(fā)試水觀察日志再逐步往上調。5.4 合規(guī)采集是一條不可逾越的底線再提一句容易被忽略的事爬蟲技術本身沒問題但抓取行為必須在法律和平臺規(guī)則允許的范圍內。我在拿到一個目標站點的時候至少會確認三件事站點是否提供官方 API。有 API 就用 API抓取永遠是最后選項。robots.txt 明確不允許的內容不去強行抓取。不要采集個人隱私數(shù)據、賬號相關數(shù)據、非公開數(shù)據。公開數(shù)據不代表可以無限制抓取數(shù)據量、頻率、用途都需要克制。本篇代碼里的并發(fā)、重試、限速策略都是為了在合規(guī)前提下穩(wěn)定完成采集任務而不是為了繞過某種安全機制。這一點希望所有看到這里的朋友都放在心里。6. 踩坑排查與調優(yōu)實測中常見的六個翻車現(xiàn)場6.1 連接池耗盡導致大量超時我第一次跑高并發(fā)時直接踩到TCPConnectorError: Connector.connector_limit_has_been_reached的報錯。原因是 aiohttp 默認連接池限制是 100而我的并發(fā)數(shù)超過了這個值導致新請求在等待空閑連接。解決辦法是在創(chuàng)建 session 時顯式指定連接池大小connector aiohttp.TCPConnector(limit300, limit_per_host50) async with aiohttp.ClientSession(connectorconnector) as session: ...limit是整個連接池的上限limit_per_host是單個域名下的連接上限。我一般會設置limit_per_host稍小于limit防止所有請求都集中在同一域名時把連接池占滿。這里的取舍是連接池越大并發(fā)能力越強但目標服務器壓力也越大。不要只加連接數(shù)還要重新評估目標站點的承載能力。6.2 SSL 證書驗證錯誤的處理目標站點如果證書鏈異常aiohttp.ClientSSLError會時不時冒出來重試也救不了。盲目sslFalse不安全但要帶著ssl.SSLContext來覆寫驗證方式也不算復雜import ssl ssl_context ssl.create_default_context() ssl_context.check_hostname False ssl_context.verify_mode ssl.CERT_NONE建議僅對特定域名使用這種補丁式處理不要全局把證書驗證關了否則等于把自己的流量暴露在風險里。正規(guī)站點跑起來完全用不到這段。6.3 響應體讀取超時而非連接超時如果你發(fā)現(xiàn)請求偶爾報asyncio.TimeoutError大概率不是連不上而是響應體讀取過慢。aiohttp 的timeout參數(shù)支持細粒度控制from aiohttp import ClientTimeout timeout ClientTimeout(total10, connect3, sock_read5) async with session.get(url, timeouttimeout) as resp: ...分項超時更容易發(fā)現(xiàn)瓶頸到底是連接、讀取還是整體耗時。調試時先把日志級別調到 DEBUG你會看到具體卡在哪一步。6.4 任務提交方式不當反而變慢前面提到過用 for 循環(huán)逐個await task是錯誤的但還有一個更隱蔽的問題await每個 task 時如果某個頁面異常緩慢會讓事件循環(huán)逐步推進可能后面的任務并沒有同步推進。使用asyncio.as_completed按完成順序處理結果能避免某個慢請求拖住整體流程for coro in asyncio.as_completed(tasks): result await coro process_result(result)這個技巧適合你需要在抓取過程中就開始解析、入庫的場景。如果只是集中批量抓最后統(tǒng)一處理結果gather會更直觀。6.5 日志輸出過多反而成為性能瓶頸高并發(fā)時控制臺大量打印日志看著很熱鬧實際上 I/O 輸出本身也在拖慢程序。我的建議是正式運行時把日志級別調到WARNING只在調試階段用INFO。如果你要追蹤每一個請求的狀態(tài)可以把日志寫到文件而不是終端。6.6 被目標站點限制后的處理狀態(tài)碼不同站點對限流的表示并不統(tǒng)一。常見的有狀態(tài)碼含義應對403拒絕訪問檢查請求頭、UA、Cookie、代理配置429請求過多等待 Retry-After降低并發(fā)503服務暫時不可用退避重試多次失敗后放棄避免死循環(huán)遇到 403 時我第一反應不是去搞各種繞過手段而是先檢查自己 headers 是不是太裸了。很多時候一個完整的 UA、Referer、Accept 就能解決絕大多數(shù)問題。7. 收尾經驗從 12 秒的驚喜到可持續(xù)的穩(wěn)定實際項目里我不會盲目追求每次都快反而是會把并發(fā)數(shù)、重試策略、限速邏輯封裝成配置項針對不同目標站點調參。比如對公開 API 可以開 200 并發(fā)對普通網頁爬取可能只開 30 到 60高敏感站點則進一步調低配合更長的退避等待。代碼的另一個優(yōu)化方向是把 URL 列表拆成分片分批消費。這樣即使某一批完全失敗也不會影響已經抓到的數(shù)據。配合斷點續(xù)采的思路就可以把異步爬蟲真正用于生產環(huán)境。如果你也想跑一版自己的異步爬蟲先從替換URLS開始其他參數(shù)保持默認觀察一次完整跑完的日志。當看到成功數(shù)接近總數(shù)、耗時從幾百秒逼近十幾二十秒時你就會理解為什么說異步是 I/O 密集型任務的標準答案了。最后提醒一個容易被忽視的小細節(jié)asyncio.run(main())之后確保你的代碼在結尾顯式關掉 session 和事件循環(huán)。我這套代碼里用的是async with會自動釋放連接和文件描述符這也是為什么我反復強調 session 要在async with塊里創(chuàng)建。少一個await、少一個async with異步程序就可能多一個隱藏的資源泄漏點到時候排查起來就不是 12 秒內能解決的事情了。