
這篇記的是一個「本來以為是週邊工具」最後變成性能除錯馬拉松的故事。我要建一個 FastAPI 監控 dashboard,觀測 FinPilot 排程與程式運行狀態——架構簡單,UI 不花俏,寫到一半被 9.6 GB 的 history.db 餵了三個性能雷,分別是 2 GB log 讀爆記憶體、重型 import 拖進 200 MB RAM、以及 COUNT(*) 卡住 5 分鐘。這篇把這三個雷怎麼踩到、怎麼救,完整記下來。
8 個 cron job 跑得怎樣?daemon 還活著嗎?history.db 多大了?最近 24 小時有 ERROR 嗎?這些問題我之前都要 ssh 進 server、grep log、cat pid、du -h 一個個查——10 分鐘起跳。
一個 web dashboard 全部塞進一頁,30 秒自動 reload,問題會直接跳出來。這個需求很樸素,UI 需求也是:不需要即時 push,不需要登入,不需要漂亮動畫——就是每半分鐘刷新一次、狀態一眼看清楚。我以為半天可以收工。
這是我在整個 session 最後一個子項目動手之前的想法。前面 4 篇——歷史 DB 爬蟲、月報生成、Claude 持倉日報——每一個都有踩雷,但我已經有點過度自信,覺得一個「小 dashboard」不可能有什麼大問題。
技術選型刻意壓低複雜度:
127.0.0.1:8080,靠 localhost 隔離外部,無需 auth<meta http-equiv="refresh" content="30"> 全頁 reload,不用 WebSocket / SSEscripts/start.sh,跟 bot / scheduler 一起由 nohup 管7 個區塊:scheduler 8 jobs 狀態、daemon 健康(PID + uptime)、DB 統計、log tail、持倉、策略 summary、24h 錯誤聚合。每個區塊一個 collect_* pure-read 函式——任何一個函式失敗,回 graceful default,不 raise,讓整頁其他區塊不受影響。
架構在白板上寫完,感覺 straightforward。每個 collect 函式職責單一,不共用狀態,失敗有 fallback。Jinja2 template 把 7 個區塊的 HTML 切成獨立 partial,容易維護。我給自己估了 4 小時。然後我開始寫 code。
第一版 _parse_last_run_from_log() 寫得非常直白:
text = log_path.read_text(encoding="utf-8", errors="ignore")
一行。邏輯清楚,把 log 讀進來再 parse 最後一筆 run 時間。
unit test 用 tmp_path 創幾百 byte 假 log,全綠。我把 dashboard 啟起來,curl 了一下——subagent killed with code 137。整個 Python process 被 OOM killer 砍掉。
真實 scheduler.log 是 2 GB。strategy_explorer daemon 24 小時不停 print DEBUG,幾個月累積下來就是這個規模。read_text() 把 2 GB 整檔載進記憶體,VM 那 8 GB RAM 直接被吃掉,kernel 殺了我的 process。
解法是 seek 到 file 尾巴 4 MB 倒著讀,覆蓋率夠(4 MB 通常含上千個 log entry,足夠抓最近 last_run):
with open(log_path, "rb") as f:
f.seek(0, 2)
size = f.tell()
read_size = min(size, 4 * 1024 * 1024)
f.seek(size - read_size)
raw = f.read().decode("utf-8", errors="ignore")
改完之後,2 GB scheduler.log 不再是 dashboard 的死神。
這個 bug 有一個讓我印象深刻的地方:tmp_path fixture 永遠跑不出來。unit test 用的假 log 是幾百 byte,read_text() 完全沒問題,測試全綠。問題藏在「production 資料規模跟 test fixture 規模差了 6 個數量級」這件事上,沒有人在 PR 時看得出來。真實的 scheduler.log 是三個月每天跑 8 個 job 累積出來的,這個規模我事先知道,但我在寫 read_text() 那一行的時候根本沒想到它。這類問題需要的是「真實規模 smoke test」進 CI,不是更多 unit test。
collect_scheduler() 要從 scheduler.job_runner import scheduler 物件,取 8 個 job 的 trigger,顯示 next_run_time。直接:
from scheduler.job_runner import scheduler
啟動 dashboard,觀察 RSS。RAM 飆到 200+ MB。
為什麼?scheduler/job_runner.py 在 module level 連帶 import 了 core.data_fetcher(pandas,約 100 MB)、core.notifier(python-telegram-bot,約 45 MB)、還有其他傳遞依賴。Dashboard 只是要 introspect 8 個 job 的 cron trigger,卻被迫把整個 bot 生態拖進來。
解法是在 import 之前,把那些重型模組 stub 掉:
def _stub_heavy_imports():
class _AnyAttrModule(types.ModuleType):
def __getattr__(self, name): return _AnyObj()
class _AnyObj:
def __call__(self, *a, **kw): return _AnyObj()
def __getattr__(self, name): return _AnyObj()
heavy = ["pandas", "telegram", "telegram.ext",
"FinMind", "FinMind.data",
"core.data_fetcher", "core.notifier"]
for m in heavy:
if m not in sys.modules:
sys.modules[m] = _AnyAttrModule(m)
_stub_heavy_imports()
from scheduler.job_runner import scheduler # 這時候 import 不會載真模組
_AnyObj 可被任何方式呼叫、可任意取屬性——讓 job_runner 的 module-level from telegram import Bot 之類不會炸,只是拿到一個可以接受任何操作的假物件。
風險是明確的:如果未來 dashboard 真的需要 real core.data_fetcher,會拿到 stub。但 dashboard 的 7 個 collect 函式只讀 DB / log / process,不會碰到被 stub 的模組,這個風險夠小,可以接受。
換完之後 dashboard RAM 從 200+ MB 降回 48 MB。
雷一和雷二花了我大概 2 小時。修完之後 dashboard 啟動、UI 跑起來了,我以為收工了。
curl http://127.0.0.1:8080/ ——直接卡住。Process 在 D state,uninterruptible sleep,通常是 disk I/O。等 5 分鐘還沒回應。
strace 一下,看到大量 SQLite I/O。Trace 到 collect_db_stats():
SELECT COUNT(*) FROM tw_history_price
tw_history_price 是 71M rows。SQLite COUNT(*) 沒有快捷路徑——它不維護一個「現在有幾 row」的計數器,每次都要 full table scan。對 71M rows 加上 9.6 GB 的 history.db,第一次跑的 disk I/O 把整個系統卡死。
MAX(rowid) 是 O(1)。SQLite 內建 rowid 是 auto-increment integer,有內建 B-tree index,取 max 直接讀 B-tree root 就拿到,不 scan 任何資料列:
def _fast_rowcount(conn, table):
r = conn.execute(f"SELECT MAX(rowid) FROM {table}").fetchone()
return r[0] or 0
對 INSERT-only 表,rowid 就是精確 row count。如果有 DELETE,rowid 會比 actual row 數高(已刪除的 rowid 不會回填)——dashboard 顯示用近似值可以接受,它本來就是觀測用途,不是審計系統。
換掉後,dashboard 首次 GET / 從 5 分鐘以上壓到 0.68 秒。
這個 bug 有趣的地方在於:tw_history_price 71M rows 不是突然出現的,是我在系列第 2 篇花了三個禮拜抓 FinMind API 填進去的 10 年 OHLCV。我知道它的規模,我也知道 SQLite 是 file-based,我甚至在第 2 篇踩過一次磁碟 I/O 的雷。我只是沒有在寫 COUNT(*) 的那一刻把這些事情連起來。「我知道規模」和「我在每個 query 設計的時候都意識到規模」是兩件不同的事,後者需要刻意練習,不會自動發生。性能問題不在 framework 選擇,在你不知道你的資料規模,或者你知道但沒有在 query 設計階段想到它。
踩完這三個雷,我有幾個結論:
性能問題不在 framework 選擇,在資料規模的認知缺口。FastAPI 沒問題、Jinja2 沒問題、uvicorn 沒問題——但 9.6 GB / 71M rows 不是手玩具規格,所有 query 都要先想清楚 cost。這個認知缺口不是「我不懂 SQLite」,而是「我知道規模,但我在寫 query 的時候沒有把它帶進來」。習慣在設計每一個 read 操作的時候問一句「這個 table 有多少 row,這個 query 有沒有 index」,是個值得養成的反射。
Unit test 通過不代表 production 通過。OOM bug 在 tmp_path fixture 永遠跑不出來。scheduler.log 的 2 GB 問題需要「真實規模 smoke test」,不是更多 unit test。CI pipeline 裡加一個用接近真實規模資料跑的 integration test,是這次最應該補但還沒補的事。
重型 import 是隱藏代價。Python 沒有 build-time import cost 的概念,大家習慣 import 就是「免費的」。直到你跑出一個 200 MB process 才會發現「我只是要 introspect 一個 scheduler 物件,怎麼連 pandas 都進來了」。module-level import 的傳遞依賴是靜態的、是確定的,但幾乎沒人在 code review 的時候去追蹤它。stub 是個有效的應急手段,但更好的設計是 job_runner 只在 function scope import 它真正需要的重型模組,而不是在 module level 把整個 bot 生態都拉進來。下次寫新模組的時候,module-level import 的傳遞依賴值得花幾分鐘追蹤一下。
這篇是系列第 5 篇,也是最後一篇。
回頭看這三天的歷程:3 天、52 commits、79 tests、9.6 GB DB、5 個新 cron job。覆蓋了四個子項目——10 年歷史 DB 爬蟲、Obsidian 月回測報告、Claude AI 持倉日報、FastAPI 監控 dashboard。每一篇都有踩雷,每一篇的雷都在不同的層——抓 API 層、資料清洗層、AI prompt 層、性能層。
幾個在這個 session 裡反覆得到驗證的工程姿態,我想在這裡說清楚,因為它們在每一篇都出現過,不只是這篇:
spec 是假設,碰壁是現實——但寫過 spec 才知道怎麼修。第 2 篇的 DB schema 設計草稿讓我在碰壁之後有一個基準可以比對,而不是在迷霧裡亂改。
不要先進 Playwright,先試 curl。不要先重寫,先重用。這個 session 裡我每次想到「這個架構太複雜了,要不要全部重來」的時候,最後的答案都是「先把它跑起來,看看哪裡真的壞了」。
開源是地基,不是房子——蓋什麼是你自己的事。我從 FinPilot 的原始架構出發,補了 4 個它沒有的東西。這 4 個東西是我的需求、我的選擇、我的設計失誤和修正。原作者的代碼給了我一個 production-ready 的起點,讓我可以用半天搭一個 dashboard,而不是花兩週從頭搭整個平台。站在別人的工作上走得更快,不是偷懶,是工程上正確的選擇。
謝謝你看到這裡。
這是系列第 5 篇,也是收尾。從「clone 到上線」到「踩 9.6 GB DB 的 COUNT(*) 雷」,5 篇把一個 3 天的部署 + 擴充 session 完整記了下來。
本系列文章源自我部署的 FinPilot 專案。原專案由 hu0937 開源並維護,是一個整合 Telegram Bot + Claude AI + APScheduler 的台股量化分析平台。本次的歷程是「部署 + 擴充」,所有原始的 Bot 指令、策略 daemon、sim() 回測架構都來自原作者的設計。
原專案:https://github.com/hu0937/FinPilot
感謝原作者把這麼完整的 base 開源出來,讓我可以站在這個地基上加東西。