休市提醒日股將於 2026-11-03 休市(文化日),距今天還有 5 天。全部公告
文章

實時告警系統數據層設計:去抖與狀態機

告警系統失控通常源於把三層職責寫在一起。本文拆解數據接入、狀態判定、通知下發三層設計,說明為什麼去抖是必需項、布爾標記為何導致重複告警,並給出帶滯回與狀態機的 Python 實現。

「價格突破某個閾值就通知我」——這個需求聽起來只需要一個 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 實現:去抖 + 狀態機 + 限流

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 實時報價指南,場景方案可看 數據分析方案;規劃連接數與下游查詢量時,可用 配額計算器 估算 套餐額度 是否夠用。