「價格突破某個閾值就通知我」——這個需求聽起來只需要一個 if 判斷。但真正上線後,團隊會遇到三個典型問題:告警風暴(價格在閾值附近震盪,一次突破發了幾百條通知)、重複告警(重連或重啟後重新觸發一遍)、額度超支(為了告警寫了輪詢,把套餐額度燒光)。
這篇文章講的是告警系統的數據層設計——不是通知渠道怎麼接,而是數據怎麼進來、狀態怎麼管、通知怎麼限流。
告警系統的三層職責
把告警系統拆開,數據層要承擔三件事,而且必須分開實現:
| 職責 | 要解決的問題 | 實現手段 |
|---|---|---|
| 數據接入 | 如何低成本拿到實時價格 | WebSocket 流式訂閱 |
| 狀態判定 | 何時算「觸發」,何時算「恢復」 | 去抖 + 狀態機 |
| 通知下發 | 如何不把用戶淹沒 | 限流 + 靜默窗口 |
這三層混在一起寫是絕大多數告警系統失控的原因。分開之後,每一層都可以獨立測試和調參。
第一層:數據接入必須用流式
告警的本質是「儘快知道變化」,這和輪詢天然衝突。用 REST 輪詢做告警有兩個致命問題:
- 檢測延遲等於輪詢間隔:1 分鐘輪詢意味著最壞情況延遲 1 分鐘,突破瞬間可能被完全錯過。
- 額度按標的數量乘以頻率消耗:100 個標的秒級輪詢就是每月數百萬次請求,很快擊穿套餐。
WebSocket 是唯一合理的選擇:一條連接訂閱全部標的,建連握手計入額度、之後的 tick 推送不按條計費。這意味著告警系統的額度成本與標的數量基本無關,只與連接次數相關。
第二層:去抖是必須的,不是優化
價格在閾值附近震盪時,逐條 tick 判定會產生大量抖動告警。假設閾值為 100,價格在 99.98 與 100.02 之間來回跳動,一秒可能產生幾十次「突破」與「回落」。
去抖有兩種常見策略,適用場景不同:
- 時間去抖:狀態變化後,在 N 秒內忽略後續同向變化。實現簡單,但對持續震盪無能為力。
- 幅度去抖(滯回):設置兩個閾值——突破用 100,恢復用 99.5。價格必須回落足夠幅度才算恢復。這是解決震盪的標準做法。
實踐上兩者應該一起用:滯回解決震盪,時間去抖解決瞬時尖刺。
第三層:狀態機,而不是布爾標記
用布爾變量記錄「是否已告警」是最常見的實現,也是重複告警的根源。原因是布爾狀態在進程重啟或 WebSocket 重連後會丟失,系統會認為「從未告警過」並重新觸發一遍。
正確的做法是顯式狀態機,並把狀態持久化:
| 狀態 | 含義 | 轉移條件 |
|---|---|---|
| ARMED | 已佈防,等待觸發 | 價格 ≥ 觸發閾值 → TRIGGERED |
| TRIGGERED | 已觸發,已通知 | 價格 ≤ 恢復閾值 → ARMED(並通知恢復) |
| MUTED | 靜默期內,抑制通知 | 靜默窗口結束 → 按當前價格決定回到 ARMED 或 TRIGGERED |
狀態持久化到外部儲存(Redis、數據庫)後,進程重啟不會重新觸發告警。重連導致的重複 tick 也不會造成二次告警,因為狀態機已經在 TRIGGERED 狀態。
第四層:推送預算
即使去抖和狀態機都做對了,仍然需要一個通知預算。原因很簡單:用戶能承受的通知量遠小於系統能產生的告警量。
建議按三層限制:
- 單條規則:同一規則在 N 分鐘內最多通知一次。
- 單用戶:每小時最多 M 條通知,超出則合併為摘要。
- 全局:系統級熔斷,防止配置錯誤導致的全量告警。
全局熔斷這一層經常被忽略,但它在配置錯誤時能救命——比如某個閾值被誤設為 0,會瞬間觸發所有規則。
Python 實現:去抖 + 狀態機 + 限流
import asyncio, json, os, time
import websockets
URL = "wss://api.tiqex.io/api/v1/market/stream"
API_KEY = os.environ["TIQEX_API_KEY"]
DEBOUNCE_SEC = 5.0 # 時間去抖窗口
HYSTERESIS_RATIO = 0.005 # 滯回幅度:0.5%
MIN_NOTIFY_INTERVAL = 60 # 同一規則最短通知間隔(秒)
class AlertRule:
"""帶滯回與時間去抖的告警規則,狀態顯式建模。"""
def __init__(self, symbol: str, threshold: float, above: bool = True):
self.symbol = symbol
self.threshold = threshold
self.above = above
# 滯回:恢復閾值比觸發閾值退讓 HYSTERESIS_RATIO
self.recover = (threshold * (1 - HYSTERESIS_RATIO) if above
else threshold * (1 + HYSTERESIS_RATIO))
self.state = "ARMED"
self.last_notify = 0.0
self.last_change = 0.0
def on_tick(self, price: float) -> str | None:
now = time.monotonic()
if self.state == "ARMED":
crossed = price >= self.threshold if self.above else price <= self.threshold
if crossed and now - self.last_change >= DEBOUNCE_SEC:
self.state = "TRIGGERED"
self.last_change = now
return self._notify("triggered", price)
elif self.state == "TRIGGERED":
recovered = price <= self.recover if self.above else price >= self.recover
if recovered and now - self.last_change >= DEBOUNCE_SEC:
self.state = "ARMED"
self.last_change = now
return self._notify("recovered", price)
return None
def _notify(self, kind: str, price: float) -> str | None:
now = time.monotonic()
if now - self.last_notify < MIN_NOTIFY_INTERVAL:
return None # 命中通知預算,靜默丟棄
self.last_notify = now
return f"[{kind}] {self.symbol} @ {price}"
RULES = [
AlertRule("BTCUSDT", 80000, above=True),
AlertRule("ETHUSDT", 2500, above=False),
]
BY_SYMBOL = {r.symbol: r for r in RULES}
async def main():
symbols = list(BY_SYMBOL)
backoff = 1.0
while True:
try:
async with websockets.connect(
URL, additional_headers={"X-API-Key": API_KEY}, open_timeout=10,
) as ws:
await ws.send(json.dumps({
"action": "subscribe", "market": "crypto", "symbols": symbols,
}))
backoff = 1.0
async for raw in ws:
frame = json.loads(raw)
data = frame.get("data") or {}
if data.get("type") != "tick":
continue
tick = data["tick"]
rule = BY_SYMBOL.get(tick["symbol"])
if not rule:
continue
message = rule.on_tick(float(tick["last"]))
if message:
print(message) # 這裡換成你的通知渠道
except Exception as exc: # noqa: BLE001
print(f"連接中斷: {exc}")
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 60)
# 注意:rule.state 保留在內存中,重連後不會重複觸發
asyncio.run(main())
這段代碼把三層職責都體現了:流式接入、帶滯回與去抖的狀態判定、通知預算限流。關鍵細節是 rule.state 在重連後保持不變——所以網絡抖動不會導致告警重複。
生產環境還應把 state 與 last_notify 持久化到外部儲存,這樣進程重啟也能保持狀態。
容量與成本估算
告警系統上線前值得先算一筆賬:
- 連接數:每個進程一條 WebSocket 連接即可覆蓋全部標的,握手計入額度。多進程部署時注意連接總數。
- 通知量:按「規則數 × 觸發頻率 × 去抖抑制比」估算。經驗上去抖能把通知量壓到原始觸發次數的 5% 以下。
- 下游壓力:每條告警可能觸發一次下游查詢(如查持倉),這部分請求走 REST,會消耗額度,需要單獨預算。
把第三項單獨拆出來很重要——很多團隊算漏了「告警觸發的下游查詢」,結果在行情劇烈的日子把額度耗盡。
常見問題
為什麼不用 REST 輪詢做告警?
兩個原因:檢測延遲等於輪詢間隔,可能完全錯過突破瞬間;額度消耗與標的數量、頻率成正比,很快會超出套餐。流式接入下推送不按條計費,成本與標的數量基本無關。
去抖窗口設多長合適?
取決於標的波動性。加密等高波動標的需要更長窗口(5–10 秒)與更寬的滯回幅度;低波動標的可以短一些。建議先用滯回解決震盪,再用時間去抖處理尖刺。
進程重啟後如何避免重複告警?
把告警狀態持久化到外部儲存(Redis 或數據庫),啟動時恢復。只用內存變量會導致重啟後重新觸發一遍全部告警。
告警風暴怎麼根治?
三層限流:單規則最短通知間隔、單用戶小時配額、全局熔斷。前兩層控制體驗,第三層防止配置錯誤導致的全量觸發。
告警觸發的下游查詢會消耗額度嗎?
會。這類查詢走 REST 接口,按請求計數。建議在容量規劃時單獨預算這部分流量,避免行情劇烈時額度被集中消耗。
下一步
把數據接入、狀態判定、通知下發分成三層實現,告警系統就穩定了。流式訂閱協議見 WebSocket 流式接口文檔,實時接入的實踐參考 WebSocket 實時報價指南,場景方案可看 數據分析方案;規劃連接數與下游查詢量時,可用 配額計算器 估算 套餐額度 是否夠用。