當雲端帳單一夜暴漲二十倍:一次資料庫流量失血的完整偵查紀錄

帳單從幾百元變成四萬多,沒有人入侵——每一分錢都是自己的程式碼花掉的。從總金額一路下鑽到函式名的五層偵查法,以及「payload × 距離」為什麼比讀取次數更貴。

當雲端帳單一夜暴漲二十倍:一次資料庫流量失血的完整偵查紀錄

前言 (Introduction)

某個月初,我打開雲端帳單,看到一個平常只要幾百元台幣的專案,變成了四萬多。

第一反應當然是「被盜刷了吧」。但很快我就發現一個更難堪的事實:沒有人入侵,每一分錢都是我自己寫的程式碼花掉的。這個 side project 是一個處理財經 Podcast 的內容平台——把語音串流轉成逐字稿、摘要,再和市場標的數據(Market Ticker Data)連結起來。流量不大,使用者是個位數到雙位數的規模,照理說怎麼樣都燒不出這種帳單。

這篇想記錄的不是「我多慘」,而是那兩天的偵查流程:怎麼從一個總金額,一路拆到「是哪兩個函式、以什麼頻率、燒掉了哪個計費項目」。事後回頭看,這套流程其實比結論更有價值,因為它可以套用在任何一次「帳單看起來不對勁」的場合。


架構設計 (Architectural Overview)

出事當下的系統長這樣:

使用者 → Edge 前端(CDN 託管)
       → FastAPI Gateway(歐洲 VPS 上的 Docker,dev / staging / prod 三套)
       → Redis 快取(VPS 本地)
       → 文件資料庫(雲端託管,資料中心在美國中部)
       → 非同步內容 Pipeline(uv workspace,負責轉錄、摘要、實體抽取)

關鍵的地理事實:運算在歐洲,資料在美國。這個配置是歷史演化的結果——文件資料庫是專案早期為了開發速度選的,VPS 是後來為了成本搬的,兩者從來沒有被放在同一張地圖上檢視過。每一次後端向資料庫要資料,payload 都要跨一次大西洋。

還有一個容易被忽略的乘數:dev、staging、prod 三套環境跑在同一台 VPS 上,共用同一個雲端文件資料庫。任何一段「每 N 分鐘做一次」的背景邏輯,實際執行頻率都要乘以三。


方法論拆解 (Methodology Breakdown)

從總金額到函式名的五層下鑽。

第一層:服務級 → SKU 級帳單分解

帳單頁面預設按「服務」分組,只能告訴你「文件資料庫佔了 99%」。真正有資訊量的是切到 SKU 級(計費細項)。我的分解結果大致是:

計費項目佔比直覺預期
網路輸出流量(Internet Data Transfer Out)約 78%完全沒想到
文件讀取次數(Read Ops)約 17%以為是主因
其他(儲存、寫入、秘密管理)不到 1%符合預期

這一步直接推翻了我的第一個假設。我原本以為「讀太多次」是問題,實際上「每次讀取搬運的位元組 × 跨洲距離」才是帳單的本體。讀取單價大約是每十萬次幾塊錢台幣,流量單價卻是每 GiB 三塊多——當你的文件平均十幾 KB、一天讀兩千多萬次,流量費用會是讀取費用的四到五倍。

第二層:監控指標的時間序列

帳單只有金額,沒有行為。下一步是拉雲端監控的每日操作數時間序列:

然後把粒度切細到每小時:讀取量全天平坦,凌晨三點和晚上八點幾乎一樣高。

這是整個偵查裡最有診斷力的一個訊號。使用者流量有日夜節律,定時器沒有。一條 24 小時等高的讀取曲線,幾乎可以直接宣判:兇手是排程,不是人。

第三層:讀寫比與資料規模的矛盾

資料庫本體多大?不到 1 GB。一天兩千多萬次讀取,等於把整個資料庫每天重讀上萬遍。這種比例不可能來自任何正常的產品行為,只可能來自某種「每隔幾分鐘就全量重算」的迴圈。

第四層:程式碼掃描——每一條讀取路徑列冊

接下來回到程式碼,把後端與 Pipeline 兩個 tier 的每一個資料庫讀取呼叫點列冊:誰觸發(請求?定時器?)、掃全集合還是抓單筆、前面有沒有快取、估計每次執行讀幾筆。

結果指向兩個「快取預熱迴圈」(refresh-ahead loop):

  1. 熱門標籤看板:每 10 分鐘,強制略過快取,全量掃描標籤集合(裡面積了數千個歷史遺留的雜訊標籤),再對每個有效標籤做多達數百筆的 N+1 文件抓取——而且同一份文件會被不同標籤重複抓取,沒有去重。
  2. 主題板塊看板:每 5 分鐘全量掃描數千筆內容文件。

兩個迴圈 × 三套環境 × 全天候,精確對上了監控曲線的平坦形狀,估算讀取量也對上了每天兩千多萬次。破案。

第五層:建立成本模型,決定修法優先序

最後把單價寫成一個粗模型:

月費 ≈ 讀取次數 × 讀取單價 + 讀取次數 × 平均文件大小 × 流量單價

代入數字後有兩個推論。第一,降低「每次讀取的 payload」和降低「讀取次數」一樣重要,因為流量項的係數更大。第二,只要資料和運算不在同一個地方,這個模型的第二項就永遠掛在那裡——這個推論直接埋下了後來整個資料層搬遷的種子(下一篇的主題)。


生產環境踩坑與優化 (Production Optimization)

Refresh-ahead 的反噬。 這兩個迴圈的本意是好的:把昂貴的重算移出請求路徑,讓使用者永遠打到熱快取。問題出在三個實作細節:迴圈用「強制刷新」旗標繞過快取讀(於是快取只剩寫入、沒有讀取,形同擺設);刷新頻率(5–10 分鐘)遠高於資料實際變動頻率(內容一天才進來幾批);以及它在所有環境都跑,包括根本沒人看的 dev 和 staging。修法就是這三件事的反面:非正式環境直接關掉、頻率降到每小時、讓快取 TTL 蓋過刷新間隔,讓「預熱」重新成立。

「資料多久變一次」是刷新頻率唯一的錨。 事後我給自己的規則是:任何定時重算,先回答「上游資料的變動頻率是多少」。我的內容一天進來幾批,看板卻每 5 分鐘重算一次——中間那 288 分之 287 次都是純粹的燒錢。

空結果不能快取太久。 這是修復過程中一位擔任「唱反調審查」的第二雙眼睛抓到的:上游短暫故障時重算會得到空集合,如果把空集合寫進長 TTL 快取,一次抖動會讓看板空白一兩個小時。修法是空結果不寫快取,讓下一次請求自然重試。

帳單警報應該在第一天就設。 這次失血持續了整整一個月才被月結帳單揭發。事後我補了預算警報(50%、90%、100% 三段通知)。以 burn rate 來算,警報能把「一個月後發現」變成「兩天內發現」,價差是二十倍的帳單。

修復效果的量化驗證。 熱修上線後,監控上每 6 小時的讀取量從約六百萬次掉到十一萬次,降幅 98%。這裡的教訓是驗證要看監控指標而不是帳單——帳單有一到兩天的延遲,監控是小時級的。


總結 (Conclusion)

這次事件對我而言的三個帶得走的教訓:

  1. 帳單要拆到 SKU 級才有資訊量。 服務級分組只會告訴你「資料庫很貴」,SKU 級才會告訴你「貴的是流量不是讀取」——而這兩者的修法完全不同。
  2. 平坦的使用曲線 = 定時器嫌疑。 任何與人類作息無關的資源消耗曲線,先查排程,再查流量。
  3. 成本模型是架構決策的輸入。 「payload × 距離」這一項,只要資料和運算分居兩洲就不會消失。止血只能砍係數,搬家才能砍掉整一項——這就是下一篇的故事。

Reference