「用什麼資料庫」這個問題,預設答案通常是 PostgreSQL。但預設答案沒有回答我真正的問題:我的瓶頸在哪裡?
選工具之前先量瓶頸,順序反過來的話,你只是在抄別人的答案。
採集一篇財報的流程是四步:查公告清單 → 下載 PDF → 解析成 markdown → 寫一列索引。
把時間畫成一條線,前三步吃掉的時間是壓倒性的多數:下載要等來源回應,解析是 CPU 密集的工作,兩者都是「秒」級的。而「寫一列」是十幾個短欄位,內容還不含正文(正文寫成檔案,資料庫只存路徑)。
我的結論是:採集系統的瓶頸在網路 IO 和來源的流量限制,不在資料庫寫入。
既然瓶頸不在資料庫,選一個要另外維運的資料庫,就是自己找事做。
這是我最常被問的問題。我的做法是「一個市場一支獨立的採集程式、各自一個行程,同時寫同一個 SQLite 檔」。
SQLite 的寫入確實是單寫鎖——同時間只有一個寫者。聽起來很糟,但配上 WAL 模式之後就不是問題:
db.py 裡的 get_conn() 就是這幾行:
conn = sqlite3.connect(DB_PATH, timeout=30) # 30s 鎖等待, 支援多市場並行寫
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
兩個細節值得講:
一、timeout=30。 這是「搶不到鎖時願意等多久」。並行寫入的場景下,等 30 秒比直接拋錯合理得多——因為我知道對面那個寫者大概幾毫秒後就會放手。
二、WAL 是持久化設定,但程式還是每次連線檢查一次。 PRAGMA journal_mode=WAL 一旦設定就會寫進資料庫檔本身,之後每次連線都是 WAL 模式。那為什麼還要在程式裡跑一次?因為防的是「第一次」——資料庫剛建立、或從別的機器同步過來的時候。而且如果兩個行程同時在切換模式,其中一個會拿到 database is locked;所以那段包了 try/except,遇到就忽略,等下次連線再說。這是真的會發生的情況,不是防禦性寫好看的。
粗估一下量級就好:一篇財報從下載到解析完是秒級,寫一列索引是毫秒級。就算三個市場同時在跑,寫入通道的利用率也還不到 1%。
這就是為什麼「單寫鎖」在我的場景裡不是問題:不是鎖不存在,是我的工作負載根本排不到那條隊伍。
反過來說,如果我做的是每秒幾百上千次寫入的東西,SQLite 會立刻爆掉,這沒什麼好辯的。
我給自己畫了三條線,任一條成立就換 PostgreSQL:
還有一個隱藏的理由:D1 本身就是 SQLite。
本地用 SQLite、雲端用 D1,表結構可以原樣搬過去。如果本地是 PostgreSQL、雲端是 D1,那份 schema 就得手寫兩次,而且兩邊的型別、日期處理、函式都不一樣,遲早會漂移。選 SQLite 有一半是為了「本地和雲端講同一種語言」。
SQLite 沒有網路層。這代表雲端那份資料不會自己出現——我得靠一支同步程式把它推上去。
這個「一份資料、兩個地方」的代價,平常看不出來,但它在刪除這件事上咬了我一口:本地清掉的東西,雲端不會跟著清。
明天(Day 5)講資料模型的時候,我會把這個洞跟補法一起寫出來——它也是那張表上唯一一個「為了補洞而存在」的設計。