雲端資料庫服務商寄來用量警告:資料傳輸量暴增,額度快燒完了。服務功能一切正常、沒有任何錯誤、使用者毫無感覺——但帳單背後,某段程式碼正在以驚人的頻率重複做同一件事。
凶手是一支週期性執行的通知排程,它的工作是逐一檢查每個使用者關注的項目、決定要不要發通知。第一版的寫法:
def run_alert_cycle():
for user in get_all_subscribed_users():
for item in get_user_items(user):
name = fetch_item_name(item.id) # ← 每個項目查一次 DB
threshold = fetch_thresholds(item.id) # ← 又查一次
...
問題的形狀:迴圈裡查的資料,其實大部分是跨使用者共用的。 項目名稱、門檻設定這些東西,一個週期裡查一次就夠了,但這段程式碼讓每個使用者的每個項目都各查一次。100 個使用者 × 平均 10 個項目 × 每 30 秒一輪 = 每小時十幾萬次本可省略的查詢。
它在開發環境完全隱形:本機資料庫(Day 2)查詢零成本、測試資料只有幾個使用者。這是一種只有在「真實規模 × 按量計費」同時成立時才會現形的 bug——而那正好就是正式站的定義。
修法是把「批次共用的值」提出迴圈,抓一次、關進 closure 共用:
def make_alert_checker():
"""工廠函式:把整輪共用的資料抓一次,關進 closure。
禁止在迴圈裡逐筆查 DB——曾燒穿雲端傳輸額度(地雷清單 #10)。"""
names = fetch_all_item_names() # 一次撈全表
thresholds = fetch_all_thresholds() # 一次撈全表
def check(user, item):
name = names[item.id] # 迴圈裡只讀記憶體
...
return check
def run_alert_cycle():
check = make_alert_checker()
for user in get_all_subscribed_users():
for item in get_user_items(user):
check(user, item)
一輪的查詢次數從「使用者數 × 項目數 × 2」降到常數 2。用 closure 而不是全域快取的理由:生命週期綁定在一輪執行,每輪開始時自然拿到新資料,不需要另外管快取失效。
最有意思的是後日談。幾個月後開發另一個新功能,一段新程式碼又長出了一模一樣的形狀——迴圈裡逐筆查共用資料。這次在 code review 階段(由讀過地雷清單的 agent 標記出來)就被攔下了。
這件事讓我對地雷清單的價值有了更立體的理解:地雷不是一個位置,是一種形狀。 記「那段程式碼有問題」只能防那段程式碼;記「『對每個使用者做某事』的迴圈裡藏著重複查詢的形狀」才能防所有還沒寫出來的複發。清單條目要寫到「形狀」這個抽象層級,它的防護才是面而不是點。
到今天,十條地雷講完了。回頭看它們的共同點:沒有一條是「不會寫程式」造成的,每一條都是「不知道這個特定系統的特定事實」造成的。 方言差異、單位約定、時區窗口、API 黑盒、舊版本包袱——這些知識不在任何教科書裡,只在這個系統的事故史裡。這就是為什麼 Day 3 那份文件是整個一人維運體系的地基。
明天開始換主題:訂閱分級系統的設計——一個商業服務跟純技術專案分道揚鑣的起點。