休市提醒日股将于 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 实时报价指南,场景方案可看 数据分析方案;规划连接数与下游查询量时,可用 配额计算器 估算 套餐额度 是否够用。