想像這個場景:某次改版引入一個 bug,通知排程的去重邏輯失效,每 30 秒一輪的排程,每輪都把同一批通知重發一次。你睡了 8 小時,醒來時它跑了快一千輪。
如果每則推播都是真金白銀,這一晚燒掉多少?這不是罕見劇本——排程 + 迴圈 + 按量計費的外部呼叫,三個元素湊齊,就具備了「帳單炸彈」的全部條件。Day 13 的資料庫額度事故是同一個家族,只是那次燒的是資料庫流量,這次的假想敵是訊息費用。
解法借用電路的概念:斷路器(circuit breaker)——平常不參與邏輯,只在流量異常時切斷電源。實作的骨架:
class PushBudgetGuard:
"""推播成本斷路器。
邏輯層應該自己做好去重與節制——這裡是邏輯全部失效時的最後防線。"""
def try_acquire(self, count: int = 1) -> bool:
used = self._get_window_usage() # 目前時間窗內已發送量
if used + count > self.window_limit:
self._trigger_alert(used) # 通知我:斷路器跳了
return False # 拒發,但不拋例外
self._incr_window_usage(count)
return True
def send_push(user, message):
if not budget_guard.try_acquire():
log.warning("push blocked by budget guard: %s", user)
return # 靜默擋下,服務繼續跑
_actually_send(user, message)
幾個設計決策值得展開:
上限怎麼定? 取正常日用量的合理倍數(例如 3-5 倍)。太緊會誤傷正常尖峰(一個大事件觸發大量正當警報),太鬆失去保護意義。這個數字需要先有幾週的正常用量數據才定得準——所以斷路器不是第一天就能調好的,先上一個寬鬆版,再用數據收緊。
跳閘之後的行為是「擋下+告警+服務繼續」,不是「拋例外」。 斷路器的職責是止血,不是診斷。它跳了代表上游一定有 bug,但讓整個服務跟著當機只會雪上加霜——擋下超額發送、大聲通知我、其餘功能照常運作,這才是正確的失效姿態。
告警本身不能走推播。(想一下為什麼。)斷路器的告警走獨立管道,否則「推播壞了」的通知永遠送不到你手上。
檢查你的系統裡所有「內部邏輯 × 外部計費」的乘積點:AI API 呼叫、簡訊、email、雲端資源建立……每一個都值得一句靈魂拷問:
「如果這段邏輯今晚跑成無限迴圈,明早的帳單是多少?有東西擋著嗎?」
答不出來的那幾個點,就是還沒裝斷路器的地方。一人維運沒有值班同事幫你半夜發現異常——斷路器就是你睡覺時的值班工程師,它不會修 bug,但它保證你醒來面對的是一個「被控制住的事故」,而不是一張災難性的帳單。