「价格突破某个阈值就通知我」——这个需求听起来只需要一个 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 实时报价指南,场景方案可看 数据分析方案;规划连接数与下游查询量时,可用 配额计算器 估算 套餐额度 是否够用。