據(jù)采集精進(jìn)指南:從爬蟲(chóng)腳本到工程化系統(tǒng))
最近幫某大宗商品研究團(tuán)隊(duì)重構(gòu)了煤炭數(shù)據(jù)采集流程爬取金投網(wǎng)上的煤炭?jī)r(jià)格、庫(kù)存、運(yùn)價(jià)指數(shù)等內(nèi)容。這個(gè)項(xiàng)目的難點(diǎn)不在“能不能爬到”而在“怎么長(zhǎng)期穩(wěn)定地爬到”以及“爬下來(lái)的數(shù)據(jù)能不能直接用”。如果你只是臨時(shí)抓一次數(shù)據(jù)做分析那隨便寫(xiě)個(gè)腳本就夠了但如果你的下游是日?qǐng)?bào)、周報(bào)、月度復(fù)盤(pán)甚至是自動(dòng)化的數(shù)據(jù)推送服務(wù)那這套邏輯就要重新設(shè)計(jì)。這個(gè)“精進(jìn)版”項(xiàng)目就是想解決三件事一是把單次爬蟲(chóng)變成可持續(xù)運(yùn)行的采集任務(wù)二是讓采集到的臟數(shù)據(jù)在入庫(kù)前就被清洗干凈三是在數(shù)據(jù)源反爬策略調(diào)整、字段結(jié)構(gòu)變化時(shí)系統(tǒng)能快速適應(yīng)而不是原地崩潰。下面我把整個(gè)項(xiàng)目的設(shè)計(jì)思路、關(guān)鍵代碼和踩坑經(jīng)歷都拆開(kāi)來(lái)講適合已經(jīng)寫(xiě)過(guò)簡(jiǎn)單爬蟲(chóng)、想往工程化方向走的開(kāi)發(fā)者參考。1. 精進(jìn)版要解決的核心問(wèn)題從單頁(yè)抓取到全鏈路采集1.1 為什么是煤炭行情數(shù)據(jù)煤炭行業(yè)的數(shù)據(jù)有一個(gè)特點(diǎn)公開(kāi)渠道多、口徑不統(tǒng)一、更新節(jié)奏差異大。金投網(wǎng)這類行情站點(diǎn)把動(dòng)力煤、焦煤、焦炭等品種的現(xiàn)貨報(bào)價(jià)、期貨價(jià)格、港口庫(kù)存、海運(yùn)運(yùn)價(jià)指數(shù)聚合到一起頁(yè)面結(jié)構(gòu)清晰很適合做數(shù)據(jù)源。但這些數(shù)據(jù)的價(jià)值在于“連續(xù)觀測(cè)”斷一天數(shù)據(jù)趨勢(shì)分析就出現(xiàn)缺口。團(tuán)隊(duì)需要的不是某一天的靜態(tài)快照而是每天定時(shí)、自動(dòng)、完整地采集并和前一天的數(shù)據(jù)做增量對(duì)比。這決定了項(xiàng)目不能是一個(gè)一次性的requests腳本而是一個(gè)帶有調(diào)度、存儲(chǔ)、校驗(yàn)、告警的小型系統(tǒng)。1.2 精進(jìn)版與基礎(chǔ)版的差異很多人寫(xiě)爬蟲(chóng)時(shí)習(xí)慣把“下載頁(yè)面、解析字段、打印結(jié)果”三步寫(xiě)在同一個(gè)文件里跑完就算結(jié)束?;A(chǔ)版能應(yīng)付單次任務(wù)但一旦涉及長(zhǎng)期運(yùn)行會(huì)暴露出一堆問(wèn)題沒(méi)有重試機(jī)制網(wǎng)絡(luò)抖動(dòng)一次當(dāng)天數(shù)據(jù)就缺失沒(méi)有去重邏輯重復(fù)抓取導(dǎo)致庫(kù)里出現(xiàn)大量重復(fù)記錄沒(méi)有頻率控制短時(shí)間高頻請(qǐng)求容易被數(shù)據(jù)源限制訪問(wèn)沒(méi)有字段校驗(yàn)解析到空值、錯(cuò)位數(shù)據(jù)時(shí)代碼不報(bào)錯(cuò)但數(shù)據(jù)已經(jīng)污染了沒(méi)有運(yùn)行日志出了問(wèn)題都不知道是哪個(gè)環(huán)節(jié)出的問(wèn)題精進(jìn)版的核心工作就是把這些“基礎(chǔ)版沒(méi)考慮的事”全部補(bǔ)上。整體架構(gòu)可以拆成四層調(diào)度層、下載層、解析層、存儲(chǔ)層。每層只干自己那一件事層與層之間通過(guò)任務(wù)隊(duì)列解耦這樣任何一層出問(wèn)題時(shí)都可以單獨(dú)替換不至于牽一發(fā)動(dòng)全身。2. 數(shù)據(jù)源分析與請(qǐng)求策略先搞清對(duì)手再動(dòng)手2.1 頁(yè)面結(jié)構(gòu)與數(shù)據(jù)加載方式動(dòng)手寫(xiě)代碼之前先花一天時(shí)間把數(shù)據(jù)源的頁(yè)面結(jié)構(gòu)摸清楚。煤炭行情頁(yè)面一般分兩類一類是服務(wù)端直接渲染的靜態(tài)表格HTML里就帶全部數(shù)據(jù)另一類是前端通過(guò)接口異步加載頁(yè)面上的數(shù)據(jù)是JavaScript跑出來(lái)的。兩種方式的采集策略完全不同。金投網(wǎng)的煤炭板塊大部分是服務(wù)端渲染一個(gè)列表頁(yè)對(duì)應(yīng)一個(gè)URL參數(shù)里帶品種、日期、頁(yè)碼。這種結(jié)構(gòu)的優(yōu)點(diǎn)是不需要模擬瀏覽器一個(gè)帶正確請(qǐng)求頭的requests就能拿到完整HTML缺點(diǎn)是一旦網(wǎng)站改版表格列的順序變了解析邏輯就要跟著改。我的建議是第一次接觸一個(gè)新數(shù)據(jù)源時(shí)先手動(dòng)用瀏覽器打開(kāi)頁(yè)面查看源代碼確認(rèn)數(shù)據(jù)是直接寫(xiě)在HTML里還是通過(guò)接口返回的。如果是接口返回的去Network面板里找真正的數(shù)據(jù)接口解析JSON比解析HTML穩(wěn)得多。這一步?jīng)Q定了后續(xù)所有代碼的寫(xiě)法值得多花時(shí)間。2.2 請(qǐng)求頭與訪問(wèn)頻率設(shè)計(jì)爬蟲(chóng)被限制訪問(wèn)多數(shù)時(shí)候不是因?yàn)槟恪芭懒恕倍且驗(yàn)槟恪芭赖锰癯绦蛄恕?。默認(rèn)的Python-requests UA一眼就會(huì)被識(shí)別所以請(qǐng)求頭至少需要偽裝成常用瀏覽器的樣子。我維護(hù)了一個(gè)UA池每次請(qǐng)求隨機(jī)取一個(gè)比固定UA更自然import random USER_AGENTS [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36, Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Firefox/121.0 Firefox/121.0, ] def random_headers(): return { User-Agent: random.choice(USER_AGENTS), Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Referer: https://www.gold678.com/, Connection: keep-alive, }再說(shuō)頻率控制。很多新手踩過(guò)的坑是用線程池一口氣發(fā)50個(gè)請(qǐng)求數(shù)據(jù)源還沒(méi)反應(yīng)過(guò)來(lái)就觸發(fā)了風(fēng)控。精進(jìn)版的下載器引入了一個(gè)信號(hào)量把并發(fā)數(shù)控制在合理范圍內(nèi)同時(shí)每次請(qǐng)求之間加一個(gè)隨機(jī)小延遲import threading import time SEMAPHORE threading.Semaphore(5) # 最多5個(gè)并發(fā) def throttled_request(session, url, delay_range(1, 3)): with SEMAPHORE: time.sleep(random.uniform(*delay_range)) resp session.get(url, headersrandom_headers(), timeout15) resp.raise_for_status() return resp這里有個(gè)細(xì)節(jié)信號(hào)量放在請(qǐng)求之前而不是請(qǐng)求之后是為了防止并發(fā)請(qǐng)求同時(shí)卡在timeout等待上時(shí)新請(qǐng)求仍然涌入。實(shí)測(cè)下來(lái)5并發(fā)、1到3秒隨機(jī)延遲的組合對(duì)于數(shù)據(jù)量不大的行情頁(yè)來(lái)說(shuō)足夠穩(wěn)定既不會(huì)觸發(fā)風(fēng)控也完全能滿足每日采集的時(shí)效要求。2.3 代理策略的取舍關(guān)于代理很多人一上來(lái)就想上代理池覺(jué)得這樣才“高級(jí)”。實(shí)際做行情數(shù)據(jù)采集時(shí)代理不是必需品。因?yàn)檫@種公開(kāi)行情頁(yè)面本身價(jià)值密度不算高數(shù)據(jù)源對(duì)單IP的請(qǐng)求頻率限制通常不會(huì)特別苛刻只要頻率控制得當(dāng)一個(gè)干凈的住宅IP就夠了。但有兩類情況確實(shí)需要代理一是數(shù)據(jù)源對(duì)單IP的日請(qǐng)求次數(shù)做了硬上限比如一天只放行1000次請(qǐng)求你的采集任務(wù)超過(guò)這個(gè)量就必須換IP二是你采集的目標(biāo)數(shù)據(jù)覆蓋多個(gè)站點(diǎn)主IP因?yàn)槠渌军c(diǎn)的原因被連坐限制。我現(xiàn)在這套系統(tǒng)預(yù)留了代理接口但默認(rèn)走直連只有出現(xiàn)HTTP 403或429時(shí)才動(dòng)態(tài)切換到備用代理。這個(gè)“按需啟用”的思路可以避免大部分時(shí)候都背著代理的額外延遲和失敗率。3. 抓取流程的分層設(shè)計(jì)調(diào)度、下載、解析、存儲(chǔ)3.1 任務(wù)調(diào)度層增量更新與全量補(bǔ)采調(diào)度層是整個(gè)系統(tǒng)的“指揮中心”負(fù)責(zé)決定“什么時(shí)間抓什么頁(yè)面”。煤炭數(shù)據(jù)的更新節(jié)奏基本是工作日每日更新個(gè)別品種甚至是一周更新一次。設(shè)計(jì)調(diào)度時(shí)不能只寫(xiě)死一個(gè)cron表達(dá)式因?yàn)橹苣?、?jié)假日數(shù)據(jù)源可能不更新而每天需要抓的頁(yè)面數(shù)量又會(huì)隨品種數(shù)量變化。我采用一個(gè)最簡(jiǎn)單的方案維護(hù)一份抓取任務(wù)清單每條任務(wù)記錄包含任務(wù)類型、URL模板、參數(shù)列表。調(diào)度器每天定時(shí)掃描任務(wù)清單哪些任務(wù)該在今天執(zhí)行就動(dòng)態(tài)生成具體的抓取URL去下載層排隊(duì)。這樣加一個(gè)新品種只需要往任務(wù)清單里加一行記錄不用改代碼。增量更新的邏輯是默認(rèn)每天抓取當(dāng)日數(shù)據(jù)如果發(fā)現(xiàn)當(dāng)日數(shù)據(jù)為空或者數(shù)據(jù)源返回了“暫無(wú)數(shù)據(jù)”的提示就自動(dòng)回退到前一個(gè)工作日進(jìn)行補(bǔ)采。補(bǔ)采的深度最多回退5個(gè)工作日超過(guò)這個(gè)深度就觸發(fā)告警讓負(fù)責(zé)人決定是否要手動(dòng)核查數(shù)據(jù)源是不是改版了。3.2 下載層請(qǐng)求重試、超時(shí)控制下載層負(fù)責(zé)“把頁(yè)面拿到手”但它真正的難點(diǎn)不是請(qǐng)求本身而是怎么處理異常。網(wǎng)絡(luò)抖動(dòng)、數(shù)據(jù)源臨時(shí)故障、偶發(fā)的429限流這些都是正?,F(xiàn)象。我的下載器對(duì)所有請(qǐng)求做兩層重試第一層是網(wǎng)絡(luò)異常重試比如連接超時(shí)、ReadTimeout、ConnectionError這類錯(cuò)誤通常過(guò)幾秒就能恢復(fù)第二層是HTTP狀態(tài)碼重試遇到502、503、504這類服務(wù)器臨時(shí)錯(cuò)誤時(shí)退避一段時(shí)間后再試。重試必須配合退避策略否則就是變相給數(shù)據(jù)源制造壓力。我用的是一種指數(shù)退避加隨機(jī)抖動(dòng)的方式def fetch_with_retry(session, url, retries3): for attempt in range(retries): try: resp throttled_request(session, url) if resp.status_code 200: return resp.text elif resp.status_code in (403, 429): raise RateLimitError(fHTTP {resp.status_code}) else: raise HTTPError(fUnexpected status: {resp.status_code}) except (RateLimitError, HTTPError, requests.RequestException) as e: wait_time (2 ** attempt) random.uniform(0, 1) time.sleep(wait_time) return None這里有個(gè)容易被忽略的點(diǎn)重試時(shí)不要直接復(fù)用同一個(gè)Session因?yàn)橛行┻B接已經(jīng)被數(shù)據(jù)源標(biāo)記了異常狀態(tài)。我在重試邏輯里每次都會(huì)新建Session連接雖然多了一點(diǎn)握手開(kāi)銷(xiāo)但成功率明顯提升。對(duì)于每日幾百個(gè)頁(yè)面的采集量這點(diǎn)開(kāi)銷(xiāo)完全可以接受。3.3 解析層表格解析與字段校驗(yàn)解析層是把HTML轉(zhuǎn)成結(jié)構(gòu)化數(shù)據(jù)的關(guān)鍵環(huán)節(jié)。煤炭行情頁(yè)面大量使用表格而表格解析最怕的是“列錯(cuò)位”。數(shù)據(jù)源偶爾會(huì)把某些列合并、隱藏或者在表頭新增一列直接按固定索引取值就會(huì)出錯(cuò)。我用Python的BeautifulSoup加lxml解析器來(lái)處理同時(shí)維護(hù)一個(gè)“表頭到字段名”的映射表。解析時(shí)先確認(rèn)頁(yè)面的表頭順序再按表頭映射到內(nèi)部字段而不是寫(xiě)死第幾列代表價(jià)格from bs4 import BeautifulSoup FIELD_MAP { 日期: date, 品種: variety, 價(jià)格: price, 漲跌: change, 漲跌幅: change_pct, 單位: unit, } def parse_price_table(html): soup BeautifulSoup(html, lxml) table soup.find(table) header_row table.find(tr) headers [th.get_text(stripTrue) for th in header_row.find_all([th, td])] rows [] for tr in table.find_all(tr)[1:]: cells [td.get_text(stripTrue) for td in tr.find_all(td)] row {} for idx, header in enumerate(headers): if header in FIELD_MAP and idx len(cells): row[FIELD_MAP[header]] cells[idx] rows.append(row) return rows解析之后不能直接入庫(kù)要先做字段校驗(yàn)。我加了三個(gè)維度的檢查空值檢查核心字段日期、品種、價(jià)格為空時(shí)該行標(biāo)記為異常不寫(xiě)入正式表類型檢查價(jià)格字段必須是數(shù)值型遇到“暫無(wú)”“--”這類非數(shù)值內(nèi)容轉(zhuǎn)換為空值并記錄日志范圍檢查煤炭?jī)r(jià)格不可能一夜之間從500漲到5000所以每個(gè)品種都配置了合理的波動(dòng)范圍超出范圍的數(shù)值會(huì)被標(biāo)記為可疑數(shù)據(jù)進(jìn)入人工復(fù)核隊(duì)列這三個(gè)檢查的價(jià)格在于它們不是“防止爬蟲(chóng)出錯(cuò)”而是“防止臟數(shù)據(jù)污染下游”。下游分析師拿到的數(shù)據(jù)必須是干凈的否則一個(gè)小數(shù)點(diǎn)錯(cuò)誤就可能讓一份研報(bào)的結(jié)論全錯(cuò)。3.4 存儲(chǔ)層去重策略與庫(kù)表設(shè)計(jì)存儲(chǔ)層我選擇MySQL主要是為了后續(xù)對(duì)接BI工具和定時(shí)報(bào)表方便。表結(jié)構(gòu)的設(shè)計(jì)要考慮到“同一品種同一日期只能有一條記錄”所以建表時(shí)直接加唯一索引CREATE TABLE coal_daily_price ( id INT AUTO_INCREMENT PRIMARY KEY, variety VARCHAR(32) NOT NULL, trade_date DATE NOT NULL, price DECIMAL(10,2), change_value DECIMAL(10,2), change_pct DECIMAL(5,2), unit VARCHAR(16), source VARCHAR(64), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_variety_date (variety, trade_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;入庫(kù)時(shí)用INSERT ... ON DUPLICATE KEY UPDATE而不是先查再插這樣既保證冪等性又省掉一次查詢def upsert_rows(cursor, rows): sql INSERT INTO coal_daily_price (variety, trade_date, price, change_value, change_pct, unit, source) VALUES (%(variety)s, %(trade_date)s, %(price)s, %(change_value)s, %(change_pct)s, %(unit)s, %(source)s) ON DUPLICATE KEY UPDATE price VALUES(price), change_value VALUES(change_value), change_pct VALUES(change_pct), unit VALUES(unit), source VALUES(source) for row in rows: cursor.execute(sql, row)其實(shí)這里有一個(gè)設(shè)計(jì)上的取舍值得說(shuō)說(shuō)為什么用唯一索引做冪等而不是先DELETE再I(mǎi)NSERT因?yàn)橄葎h后插在數(shù)據(jù)源當(dāng)天數(shù)據(jù)延遲更新時(shí)會(huì)導(dǎo)致歷史記錄被清空而我們希望保留“數(shù)據(jù)源最后一次更新”的狀態(tài)。用唯一索引加覆蓋寫(xiě)的方式可以保證每天都只保留最新?tīng)顟B(tài)同時(shí)磁盤(pán)上不會(huì)堆積大量重復(fù)記錄。4. 核心代碼實(shí)現(xiàn)調(diào)度、解析與入庫(kù)的關(guān)鍵片段4.1 定時(shí)調(diào)度與任務(wù)清單調(diào)度器用APScheduler實(shí)現(xiàn)每天上午9點(diǎn)半和下午5點(diǎn)各觸發(fā)一次任務(wù)掃描。為什么要兩個(gè)時(shí)間點(diǎn)因?yàn)槊禾楷F(xiàn)貨報(bào)價(jià)有的品種上午出有的品種下午才更新分兩個(gè)批次可以減少“抓不到數(shù)據(jù)”的概率。任務(wù)清單我直接放在了數(shù)據(jù)庫(kù)里這樣加任務(wù)不用改代碼SELECT task_name, url_template, params_json, schedule_type FROM crawl_task_config WHERE is_active 1 AND next_run_time NOW() OR next_run_time IS NULL每次調(diào)度觸發(fā)時(shí)系統(tǒng)掃描所有需要執(zhí)行的任務(wù)生成具體的URL列表然后交給下載層去異步抓取。抓取完成后無(wú)論成功還是失敗每條任務(wù)都會(huì)記錄下一次執(zhí)行時(shí)間同時(shí)更新成功次數(shù)和失敗次數(shù)的統(tǒng)計(jì)。如果某個(gè)任務(wù)連續(xù)失敗超過(guò)3天調(diào)度器會(huì)把它標(biāo)記為“異?!睍和:罄m(xù)自動(dòng)執(zhí)行并把異常信息推送到告警群。4.2 字段清洗與多表關(guān)聯(lián)解析出來(lái)的原始字段經(jīng)常不能直接用比如“價(jià)格”字段在頁(yè)面上顯示成“現(xiàn)價(jià) 780 元/噸”或者日期顯示成“01-20”而不是完整的“2025-01-20”。我寫(xiě)了一個(gè)清洗模塊專門(mén)做三件事提取數(shù)值、標(biāo)準(zhǔn)化日期、統(tǒng)一單位。數(shù)值提取用正則表達(dá)式解決import re def extract_number(text): if not text: return None match re.search(r-?\d(\.\d)?, text.replace(,, )) if match: return float(match.group()) return None日期標(biāo)準(zhǔn)化要考慮兩種格式一種是帶年份的完整日期另一種是只有月和日的簡(jiǎn)稱。對(duì)于后者需要根據(jù)當(dāng)前日期推斷年份同時(shí)處理“跨年”邊界問(wèn)題比如12月31日的簡(jiǎn)稱日期到了1月1日年份應(yīng)該還是上一年from datetime import datetime, timedelta def normalize_date(text, todayNone): today today or datetime.now().date() cleaned text.strip() try: return datetime.strptime(cleaned, %Y-%m-%d).date() except ValueError: pass try: m, d cleaned.split(-) candidate datetime(today.year, int(m), int(d)).date() if candidate - today timedelta(days90): candidate datetime(today.year - 1, int(m), int(d)).date() return candidate except ValueError: return None這個(gè)“跨年推斷”的邏輯其實(shí)是個(gè)很容易踩的坑。如果你不考慮跨年每年1月初你會(huì)發(fā)現(xiàn)系統(tǒng)里突然出現(xiàn)一批日期錯(cuò)誤的數(shù)據(jù)而且這種錯(cuò)很難被普通校驗(yàn)發(fā)現(xiàn)因?yàn)槿掌诟袷奖旧硎呛戏ǖ摹?.3 多線程下載與入庫(kù)性能下載和解析可以做到并行但入庫(kù)必須串行因?yàn)镸ySQL不支持多線程同時(shí)寫(xiě)同一張表還能保證唯一索引不出沖突。我的做法是下載和解析用ThreadPoolExecutor并行處理解析結(jié)果放進(jìn)一個(gè)內(nèi)存隊(duì)列再單獨(dú)開(kāi)一個(gè)線程從隊(duì)列里批量取數(shù)據(jù)、批量入庫(kù)。批量入庫(kù)的批次大小我調(diào)到500條一批實(shí)測(cè)比一條一條插快了近10倍。對(duì)于煤炭數(shù)據(jù)這個(gè)量級(jí)500條一批完全不會(huì)給數(shù)據(jù)庫(kù)帶來(lái)壓力。5. 踩坑實(shí)錄與排查鏈路從403到臟數(shù)據(jù)的完整復(fù)盤(pán)5.1 偶發(fā)403從User-Agent到IP限流系統(tǒng)上線第三周的某天運(yùn)維反饋當(dāng)天的采集任務(wù)成功率為0所有請(qǐng)求都返回403。我第一反應(yīng)是UA被識(shí)別了于是趕緊換了新的UA池結(jié)果仍然403。接著查了代理IP的可用性也正常。最后打開(kāi)數(shù)據(jù)源頁(yè)面才發(fā)現(xiàn)是數(shù)據(jù)源整體升級(jí)了防護(hù)策略對(duì)所有非瀏覽器請(qǐng)求都返回403連正常訪問(wèn)偶爾也會(huì)要求驗(yàn)證。這個(gè)問(wèn)題的排查鏈路是這樣的先看HTTP狀態(tài)碼分布確認(rèn)是全部失敗還是部分失敗如果是全部失敗考慮是不是IP被全局封禁排除IP因素后再看是不是UA、Cookie這些請(qǐng)求頭出了問(wèn)題。最終解決方式是增加了Cookie的自動(dòng)獲取流程也就是先訪問(wèn)一次首頁(yè)拿到必要的Cookie再帶著去訪問(wèn)數(shù)據(jù)頁(yè)模擬真實(shí)用戶的訪問(wèn)路徑。這比單純偽造UA要有效得多。5.2 請(qǐng)求頻率被限制并發(fā)不是越高越好另一個(gè)印象深刻的坑是我把并發(fā)從5調(diào)到10之后原本跑得好好的任務(wù)突然開(kāi)始大面積超時(shí)。查下來(lái)發(fā)現(xiàn)數(shù)據(jù)源對(duì)單IP的并發(fā)連接數(shù)做了限制超過(guò)之后新的連接會(huì)被掛起直到超時(shí)。這個(gè)問(wèn)題的本質(zhì)是“系統(tǒng)設(shè)計(jì)時(shí)沒(méi)有考慮目標(biāo)服務(wù)器的承受能力”。數(shù)據(jù)源本身只是一個(gè)普通網(wǎng)站不是為你準(zhǔn)備的高并發(fā)API服務(wù)你用太高的并發(fā)去訪問(wèn)人家第一反應(yīng)不是“服務(wù)好你”而是“這個(gè)IP有問(wèn)題”。最終我把并發(fā)調(diào)回5并且在代碼里加了自適應(yīng)限速每次收到429或503時(shí)自動(dòng)把并發(fā)數(shù)減半等一段時(shí)間后再逐步恢復(fù)。這個(gè)機(jī)制上線后再也沒(méi)有出現(xiàn)過(guò)全鏈路超時(shí)的情況。5.3 臟數(shù)據(jù)的隱蔽風(fēng)險(xiǎn)類型正確但邏輯錯(cuò)誤最坑的數(shù)據(jù)錯(cuò)誤不是格式錯(cuò)誤而是“看起來(lái)完全正常、但邏輯上不可能”的數(shù)據(jù)。有一次動(dòng)力煤價(jià)格在庫(kù)里連續(xù)三天都是600元/噸不變而數(shù)據(jù)源頁(yè)面上每天數(shù)值都有變化。排查發(fā)現(xiàn)解析器把表格中的一列錯(cuò)位了取到的是“昨日結(jié)算”而不是“今日價(jià)格”而恰好那幾天“昨日結(jié)算”的數(shù)值都是一樣的所以入庫(kù)后看起來(lái)像“數(shù)據(jù)沒(méi)更新”。這種問(wèn)題最容易被忽略因?yàn)閿?shù)據(jù)校驗(yàn)時(shí)類型、范圍都沒(méi)問(wèn)題。從那以后我加了一道專門(mén)的“環(huán)比校驗(yàn)”每個(gè)品種每日的價(jià)格與前一天做對(duì)比如果變化率超過(guò)設(shè)定閾值系統(tǒng)自動(dòng)把這條記錄標(biāo)為“待復(fù)核”而不是直接覆蓋前一日的記錄。這個(gè)機(jī)制幫我抓住了好幾次數(shù)據(jù)解析錯(cuò)位的問(wèn)題。6. 上線后的運(yùn)維細(xì)節(jié)與擴(kuò)展思路6.1 日志、監(jiān)控與告警爬蟲(chóng)系統(tǒng)的運(yùn)維核心不是“跑通”而是“可觀測(cè)”。我規(guī)定所有關(guān)鍵節(jié)點(diǎn)必須打結(jié)構(gòu)化日志包括請(qǐng)求開(kāi)始時(shí)間、請(qǐng)求結(jié)束時(shí)間、狀態(tài)碼、解析行數(shù)、入庫(kù)條數(shù)、耗時(shí)。這些日志統(tǒng)一寫(xiě)入日志文件同時(shí)按天切割方便檢索。監(jiān)控分兩層一層是基礎(chǔ)設(shè)施監(jiān)控比如任務(wù)是否在跑、進(jìn)程是否存活另一層是業(yè)務(wù)監(jiān)控比如當(dāng)天采集成功率、解析行數(shù)是否與前一天有明顯波動(dòng)。我設(shè)置了兩個(gè)告警級(jí)別單任務(wù)失敗不告警只記日志連續(xù)失敗超過(guò)3次或成功率低于80%才會(huì)推送告警消息。這里有個(gè)經(jīng)驗(yàn)告警閾值要留有余地不要一點(diǎn)小波動(dòng)就告警否則團(tuán)隊(duì)會(huì)進(jìn)入“狼來(lái)了”狀態(tài)最后真的出問(wèn)題時(shí)沒(méi)人響應(yīng)。6.2 失敗回補(bǔ)機(jī)制無(wú)論系統(tǒng)多穩(wěn)定總有那么幾天數(shù)據(jù)源不配合。我把“失敗任務(wù)重跑”做成了一個(gè)獨(dú)立的操作入口只要數(shù)據(jù)源恢復(fù)正常負(fù)責(zé)人手動(dòng)觸發(fā)一次回補(bǔ)任務(wù)系統(tǒng)就會(huì)根據(jù)任務(wù)清單里記錄的最后成功日期自動(dòng)補(bǔ)齊所有缺失的數(shù)據(jù)?;匮a(bǔ)任務(wù)復(fù)用正常的下載、解析、入庫(kù)流程唯一區(qū)別是參數(shù)里的日期范圍換成多個(gè)工作日。這個(gè)設(shè)計(jì)的價(jià)值在于它把“異常處理”從被動(dòng)變成了主動(dòng)。不用在凌晨數(shù)據(jù)缺失時(shí)人工寫(xiě)腳本臨時(shí)抓數(shù)據(jù)而是按一個(gè)固定的“補(bǔ)數(shù)流程”執(zhí)行。6.3 后續(xù)擴(kuò)展從采集到分析爬蟲(chóng)只是數(shù)據(jù)鏈路的第一公里。數(shù)據(jù)進(jìn)庫(kù)之后真正給團(tuán)隊(duì)創(chuàng)造價(jià)值的是分析應(yīng)用。目前我在庫(kù)表基礎(chǔ)上做了幾個(gè)簡(jiǎn)單的視圖品種價(jià)格趨勢(shì)、日環(huán)比排名、港口庫(kù)存與價(jià)格聯(lián)動(dòng)關(guān)系。下一步計(jì)劃接入一個(gè)簡(jiǎn)單的預(yù)測(cè)模型用歷史數(shù)據(jù)和外部事件數(shù)據(jù)做價(jià)格波動(dòng)區(qū)間的參考預(yù)測(cè)。如果你也想做類似的項(xiàng)目我給你一個(gè)忠告不要一開(kāi)始就追求大而全的分布式爬蟲(chóng)系統(tǒng)先用最簡(jiǎn)單的方式跑通數(shù)據(jù)鏈路再逐步加調(diào)度、告警、校驗(yàn)這些“耐用性”功能。一個(gè)能穩(wěn)定跑三個(gè)月的簡(jiǎn)單系統(tǒng)遠(yuǎn)比一個(gè)看起來(lái)高級(jí)但天天需要維護(hù)的復(fù)雜系統(tǒng)有價(jià)值。最后分享一個(gè)我從這個(gè)項(xiàng)目里學(xué)到的東西爬蟲(chóng)項(xiàng)目最重要的不是爬蟲(chóng)本身而是數(shù)據(jù)質(zhì)量和可維護(hù)性。把數(shù)據(jù)源變了怎么辦、數(shù)據(jù)錯(cuò)了怎么發(fā)現(xiàn)、任務(wù)掛了怎么恢復(fù)這三件事想清楚你的爬蟲(chóng)項(xiàng)目才算真正“精進(jìn)”了。