八月重啟之後,開放台灣租屋資料(以下簡稱開租)的資料處理流程,正在分階段換成新的架構,預計九月內完成遷移,九月資料集會是第一份用新架構產出的資料。
這篇文章不介紹新架構長什麼樣子,那部份寫在 repo 的 architecture-roadmap。這裡想講的是三件事:為什麼這樣選、怎麼在不中斷每日爬取的情況下換掉、以及換的過程放棄了什麼。最後也會談這一輪養成的文件寫法,以及對資料探索的期待。
這篇文章跟 repo 裡的設計文件一樣,是由 AI 起草、人給方向和目標,再由人審過的。原因寫在文末。
起點:八月的事故,全都指向同一件事
八月重啟後的每一次事故,回頭看都不是「爬蟲壞了」:
seed 零產出產生待爬清單的程式出錯,清單是空的,但流程照樣走完403 靜默陣亡被站方擋下後,錯誤處理路徑沒有寫回任何狀態,爬蟲一樣「正常結束」少一批資料爬蟲執行完畢,但其中一批物件從頭到尾沒被處理
共同點是它們全都「看起來成功」。這類問題有個名字叫 silent failure,開租八月最花時間的,就是一次又一次事後從 log 裡把它們翻出來。
翻到最後發現根源只有兩個:
- 可變狀態太多:物件資料原地覆寫、原始 HTML 塞在資料庫裡、待爬清單用「刪除一列」代表「完成一筆」,於是失敗的請求只能事後從殘留的列去推
- 控制流是隱性的:錯誤處理不寫終結狀態、用 log 字串當作程式之間的契約、四套排程機制拼成一條 pipeline
好維護、可以 scale out、模組可以抽換,這三個目標聽起來是三件事,但只要把「可觀測」做進資料模型裡,而不是事後另外架檢查,三件事會一起解決。這是整個新架構的出發點。
為什麼不重寫
新架構的最終形狀很單純:每個 stage 產出不可變(immutable)、按日期分區的檔案,S3 是唯一的真相來源,資料庫退回去只負責待爬 queue 和索引。三條規則貫穿全部:
- 檔案存在,就代表這個 stage 完成。續跑、重跑、進度追蹤不用另外做
- 只有爬取需要可變狀態,全部收在一張 queue 表;其他 stage 都是「讀檔、寫檔」的 pure function
- 每個 stage 附一份 manifest,品質門檻就是對 manifest 的斷言
但這是方向,不是排程。八月重啟時做的幾件事,其實已經自然走到這個形狀的邊緣:原始 HTML 已經打包上 S3、公開的月 zip 已經是歷史資料的永久副本、列表驅動的爬取降頻也已經上線。全部打掉重寫,等於把已經走在路上的東西丟掉。
所以採取的做法是一項一項搬,並且要求每一步都獨立有收益、搬完的每一格都是可以正常運作的完整系統,停在任何一格都不算半套。這次做的 Phase 1 和 Phase 3,到達的是最終形狀的外殼:queue 的狀態機、manifest 和斷言、原始 HTML 出資料庫、單一的 flow 定義。定義最終形狀的本體,也就是「資料庫只剩 queue、parse 之後全部是讀檔寫檔」,留給有明確觸發條件時才做,很可能永遠只做一部份。三個目標裡最痛的部份,外殼就已經解掉了。
下面這張圖可以按著走一遍:資料庫怎麼一格一格變小、S3 的目錄樹怎麼長出來,每一步又有哪些工具跟著退役。
pipeline 直接把當天的 HTML 打成按日分區的壓縮檔上 S3;資料庫停寫並清空,體積從此不再成長。
- House物件現值(原地覆寫)
- HouseTS每日快照
- HouseEtc.detail_raw停寫、清空原始 HTML
- HouseEtc.list_dict列表解析結果
- HouseEtc.detail_dict詳細頁解析結果
- RequestTS+status/attempts/error待爬 queue
- Stats停止新增每日統計
- publish/<year>/[YYYYMM]….zip
- manifests/<date>/<stage>.json
- raw/<vendor>/<date>.tar.zst + index
三個關鍵選擇
一張 queue 表撐起 scale out
開租的待爬 queue 一直是一張 PostgreSQL 表,多個爬蟲行程用 FOR UPDATE SKIP LOCKED 認領請求,早就能平行跑。缺的是收工語意:一筆請求什麼時候算結束、整天的爬取什麼時候算完成。
新架構把 queue 改成明確的 state machine:pending → in_flight → done | failed(n) | dead。錯誤處理必須寫回終結狀態,失敗可以重試、超過次數轉 dead,「刪除一列代表完成」的做法廢除。收工只有一條鐵律:
seeds == terminals:
done + dead必須等於當天產生的請求數,而且沒有任何 pending、in_flight、failed 殘留。
這一條不變量,能在當下就抓到前面列的每一種事故,而不是月底出貨才發現。
有人會問為什麼不換成 SQS 之類的 message queue,或是上 Dagster、Airflow 這類 workflow 平台。理由是需求不同:認領、終結狀態、收工對帳,需要的是一張可以查詢的表,at-least-once 投遞反而做不到對帳。至於 workflow 平台,開租一天跑一輪、六個 stage,一個「以產出檔案存在為完成判據」的 make 式 runner 就夠了,平台只是把複雜度搬到另一個地方。
這個 runner 的形狀,跟三十年前的 Makefile 沒什麼兩樣:每個 stage 的目標是一個檔案,檔案在就跳過,從任一 stage 都能續跑;本機跟雲上吃同一份定義。
F := logs/flow/$(DATE) # stamp 檔目錄 $(F)/list.done: # up to date scrapy crawl list591 $(F)/seed.done: $(F)/list.done # up to date 由列表產生 detail 請求 $(F)/detail.done: $(F)/seed.done scrapy crawl detail591 $(F)/queuefinalize.done: $(F)/detail.done seeds == terminals,不等就紅 raws/591/$(DATE).tar.zst: $(F)/queuefinalize.done rawpack --reconcile manifests/$(DATE)/{list,detail,snapshot}.json: raws/591/$(DATE).tar.zst manage.py manifest $(F)/quality.done: manifests/$(DATE)/{list,detail,snapshot}.json qualitycheck publish/$(YEAR)/[YYYYMM]….zip: $(F)/quality.done export -p(月底才有動作)
不刪列會不會讓表無限長?處理方式是把兩件事拆開:熱路徑用 partial index,只索引未終結的列,掃描量跟表的總量無關;終結統計在收工時寫進當天的 manifest,之後這些列只剩除錯價值,保留一段時間後批次清掉。「什麼時候刪」從正確性條件降級成清理政策,早刪晚刪都不影響對帳。
觀測做進資料模型:manifest 是 stage 之間的契約
八月為了資料品質,陸續長出四套工具:當日統計、欄位填充率監測、分佈不變量檢查、月報。四套工具、四種 baseline 格式,其中填充率還有「爬蟲端量」跟「資料庫端量」兩種算法並存,光是要重製 baseline,就得先解決「兩種算法不一致」。
新架構只留一個機制:每個 stage 自動產出 manifest,記錄進出筆數、逐欄填充率、錯誤分類、分佈統計、queue 的終結統計;品質門檻是 repo 裡版本化的斷言檔,對 manifest 做斷言。日檢、月報、漂移偵測,只是同一個機制套不同的時間窗。
這裡有一個設計上的關鍵:manifest 是對當日資料的 pure function,不帶任何狀態。這一點決定了下一節的「新舊並行」為什麼做得到。另外,動態基準(例如「跟過去一個月的中位數比」)也不另外物化一份 baseline 檔案,斷言引擎當場掃過去幾份 manifest 就算出來,歷史不夠長的時候自動降級成提醒而非紅燈。
原始 HTML 出資料庫:檔案是真相,引擎可以丟掉
開租一直把原始 HTML 存在資料庫裡,於是長出一整串清理工作:定期把舊的 HTML 搬出去、把舊的每日快照打包歸檔、每月整理。這些工作存在的理由,全都是 RDS 的物理性質:貴、只長不縮、容量影響效能。
新架構讓 pipeline 直接把當天的原始 HTML 打成一個按日分區的壓縮檔,加一份索引,放到 S3,資料庫不再存原始資料。「歷史原始資料」跟「當日原始資料」變成同一種格式,遷移和歸檔的概念直接消失,S3 上只剩兩條一次寫定的 lifecycle 規則。
配套的是 schema 演進的紀律,分成三層:原始 HTML 沒有 schema,永遠不遷移;站方版式的中間解析結果不落地,站方改版打到的是解析程式,不是任何存起來的資料;對外的正規化欄位只增不改。真的遇到 breaking change,不寫 migration,改用重算:原始資料都在,從 parse 這個 stage 重跑,下游依序重建。而且重算永遠由人觸發,不做「程式改一行就自動重算全部歷史」的自動連動。
實務上最直接的收益是:修完 parser 的 bug,對日包重放一次就好,不用重爬。
漸進上線:把「切換」變成刪幾行程式
新架構是分六步部署的。其中真正動到資料庫 schema 的只有一步,而且是純加欄、向後相容、部署順序不敏感,新舊程式可以同時對同一張表工作。其餘五步,是部署和切換。
六步裡有兩步需要等日曆,也就是新舊兩套要一起跑一段時間、對過帳才能切:
觀測層並行manifest 和斷言,跟舊的四套工具並行跑幾天,逐項比對一致後,退役舊工具資料層雙寫原始 HTML 同時寫資料庫和 S3 日包,抽樣比對內容一致後,資料庫停寫
兩步的哲學是一樣的。新舊兩套都是對同一份資料的唯讀推導,沒有共享狀態;觀測跟執法分開,唯一會讓 pipeline 停下來的硬閘門(收工對帳)獨立在兩套之外。這樣切換等於刪掉幾行呼叫,回退等於加回來。雙寫是同一件事的寫入版:資料庫在切換前仍是權威,S3 那份先當副本對帳。
- statscheck
- fill-rate monitor+baselines
- distcheck+national.json
- monthreport
- manifests/<date>/<stage>.json
- quality/assertions.yaml
- 動態基準:疊近幾份 manifest 即算
「要並行幾天」也不是死板的天數。兩套讀的是同一份資料,並行是在驗證「定義有沒有對齊」,不是在累積統計樣本,所以判準是連續幾天逐項一致就切,反正舊工具復原的成本只是一個 commit。
做法上有兩個原則。一是安全網先鋪再動手:改 queue 的 state machine 之前,先用測試矩陣把現制行為鎖住,再用本機的真實爬取重演過去的紅燈場景。二是即時盯著看,不要等跑完才看結果:上線當晚掛著事件流,有東西炸掉的當下就開始修。
取捨:明講不做什麼,代價是什麼
設計文件裡有一節叫「建議不做」,把落選的方案和理由留下來,免得下次重新辯論一遍。這次不做的包括:
big-bang 重寫已經走在路上的東西太多,打掉重寫性價比為負workflow 平台一天一輪、六個 stage 的量級,make 式 runner 就夠現在就把快照 parquet 化沒有觸發點(全歷史重算需求、RDS 費用痛點)之前,收益不抵遷移成本和心智轉換csv-aggregator 換 DuckDB現有的 ClickHouse local 流程已經對去重語意驗證過、一年只跑十幾次,換掉要重驗到 byte 一致,收益趨近零為了 scale out 拆微服務擴展的軸(爬蟲數、平台數)已經由 queue 表和 vendor 介面承載,行程邊界不是瓶頸
有幾個決定是有代價的,也一併寫清楚:
原始 HTML 保留一年個資和著作權的暴露面從永久變成有界;代價是遇到 breaking change,重算只能回看一年。配套是列表指紋只存雜湊、正規化欄位本來就不含標題和描述,所以只有原始資料需要過期,事件分區可以永存每日快照承載摺疊狀態每天的運算變成「昨天的快照加今天的輸入」,新環境冷啟只要同步一天的資料;代價是快照欄位的 bug 會往前傳播。緩解是 manifest 對這些欄位下斷言,加上原始資料永存,隨時可以重建單一物件的跨日查詢變慢按日分區的檔案,查某一個物件的歷史要跨很多檔案。這是承認的退化項,需要的時候在本機用 DuckDB 物化一份除錯要整包拉回不用可尋址的壓縮格式,也不依賴 S3 特有的功能,換來壓縮率最好、換任何 object storage 都成立正規化的分區檔案原則上不公開著作權的考量。對外公開的仍然只有月 zip,開發用的資料靠twrh指令自己抓
最後一條是這個專案一貫的界線:爬取的速率、排程、風控參數永遠不進版控,量測數據不公開。這也是為什麼這篇文章裡一個數字都沒有。
人問了什麼
先說清楚:新架構的設計文件、部署階梯、取捨清單,以及這篇文章,都是 AI 起草的,放在 repo 的 docs/ 目錄,先寫文件、再動手。分工的細節(怎麼收斂範圍、怎麼授權、怎麼分 commit)大多是工具本身的工作習慣,沒什麼好寫。值得留下來的,是兩個工具自己不會問、由人開口的問題,因為它們改變了結果。
第一個問題是起手式。那時八月的事故剛收尾,AWS 上的多爬蟲驗收也剛過,很自然的下一步是繼續一件一件修。那天晚上問的卻是另一件事:
如果可以重來,打掉所有的架構、流程,你會怎麼設計這個專案?
目標是好維護、多人協作、擴展,而且容易觀測資料品質、過程的異常狀況。
這是一個 greenfield 的問法,丟給一個已經跑了八年的 codebase。第一版答案太抽象,隔天要求重寫成具體的;又隔一天,才變成正式的設計文件委託,問法也收緊了:
思考如果要為了好維護、方便橫向擴展、抽換模組,而重新設計架構的話,這個專案可以如何調整。……分類、分階段,達成上述目標,不用為了工程而工程,但也不用過於保守。
有意思的是答案沒有變成重寫:先把三個目標收斂成同一組結構缺陷(可變狀態、隱性控制流),再畫出最終形狀,最後才是「不重寫,一格一格搬」。設計文件出來之後,範圍又被收了一次:
應該是把 phase 1 & 3,以及相關的 phase 2 的一小部份做完,跟我說可行性。
問一個從頭來的問題、拒絕一個從頭來的答案,這個組合是人給的:問題開得夠大,才看得到三件事其實是一件;範圍收得夠緊,才不會真的去重寫。
第二個問題出現在時程上。開工前盤顧慮的時候,AI 自己列了一條:
月末窗口:1-2 切換別跨在 9/30 上……如果切換日卡在月中偏後,9 月的月窗會一半舊格式一半新格式。兩個解法:平行期讓舊通道撐完 9 月、10 月起 monthreport 才改讀 manifest;或平行期刻意覆蓋一次月末驗證。前者省事,建議前者。
也就是月報要等十月才整月用新格式。人的回應是一個反問:
有可能跑一次 data migrate,補上 9 月需要的新架構的資料嗎?從 db 搬出來。
答案其實已經在設計裡:manifest 是對資料的 pure function,只要資料還在,筆數、填充率、分佈都能對過去的日子重算,回補工具不是拋棄式補丁,就是 manifest 產生器換個日期跑。算不回來的只有 queue 的終結統計,因為舊制「刪列=完成」,那些列早就不在了。所以回補的 manifest 標上 source=backfill,缺項對應的斷言降成 advisory,九月的月窗就整個走新格式,時程往前拉了一個月。工具的預設是等一個乾淨的切點,人問的是「為什麼要等」,而設計本身的性質剛好回答了這個問題。
這兩個問題有個共同點:它們來自知道這份資料是拿來做什麼的(月報是對外發布的東西、事故是重複發生的模式),不是來自程式本身。這大概就是這種協作方式裡,人真正需要出力的地方。
文章的產生方式也一樣。人給的是「重點不是介紹架構,是為什麼這樣選、怎麼漸進上線、取捨了什麼」這個方向,以及「用字不要太 AI、不確定的專有名詞保留英文」這類後設要求;公開跟不公開的界線(數字、爬取參數)是事先定好的規則。AI 負責組成文章,人負責審。
如果你對這些文件有意見,直接在 repo 開 issue 或 PR 就好,它們本來就是拿來被改的。
對資料探索的期待
換架構的另一個目的,是讓開租從「可以下載」變成「可以直接看見市場」。這件事的規劃寫在 monthly-insights-plan,核心的做法是:每月的統計,直接對公開的月 zip 用 ClickHouse local 或 DuckDB 掃出來,不碰資料庫。這代表任何人拿同一份公開 zip 都能重算,統計本身是可以驗證的。目前規劃的指標包括各縣市、各型態的每坪租金四分位數,同一物件跨月的開價變動,以及性別、身份限制、可養寵物這類租屋條件的比例。
這個做法能成立,是因為這幾年出現了 DuckDB、ClickHouse local 這一類工具:單一執行檔,不用架 server,直接對 CSV、zip、parquet 下 SQL,筆電上就能掃完整年的資料。放在幾年前,同樣的統計得先把資料灌進資料庫才能算,「任何人都能重算」就不會是一句能兌現的話。2023 年用 ClickHouse local 重做 csv-aggregator 已經是這條路的第一步,新架構把整個 pipeline 的後半段都搬到這個形狀上。
新架構在這件事上有幾個直接的好處:
- 資料是按日分區的檔案,分析引擎可以隨時換,檔案才是真相
- 不用架 PostGIS,同步幾天的分區檔案就能在本機跑 pipeline 的後半段
- manifest 疊起時間窗,就是每日爬取的收斂曲線,不用手動下 SQL
- parser 修好之後對日包重算,不用重爬
網站這邊,圖表會先以文章內嵌的互動元件出現;什麼時候長成可以分享查詢條件的探索介面,訊號已經事先寫在文件裡。
延續去年的徵求:如果你想拿這份資料回答某個問題,或是有統計上的建議,歡迎告訴我們。這些統計要呈現什麼,比怎麼算出來更需要外部的意見。
接下來
遷移預計九月內完成,九月資料集會是第一份用新架構產出的資料,月底釋出。在那之後,才會開始處理支援 591 以外租屋平台的前置工作(#29)。
如果發現資料有明顯錯誤,歡迎回報至 GitHub Issues,或是寄信至 open-tw-rental-house@ddio.io
