告警总线:把 N 个告警源解耦成一条消息流的架构设计

说明:当告警源从五个涨到五十个,点对点对接会退化成蜘蛛网。本文设计一条"告警总线":所有源只管发布,路由、分级、防抖、限流、分发全部挂在总线上,附 Redis Stream 的削峰实现与回压策略。这是把本系列散落的中间件收拢成一个架构的收官之作。不放站外链接。


一、蜘蛛网问题:点对点的死亡曲线

按照本系列的协议矩阵,每个告警源直连终端。三个源时这很优雅;三十个源时它是一张蜘蛛网

  • 每个源各自实现签名、重试、防抖------同一套逻辑写了三十遍;
  • 终端接口出问题时,三十个源各自报错,你要挨个安抚;
  • 新增一个告警源 = 一次全链路配置变更,牵一发动全身;
  • 没有任何一处能做全局的削峰------三十个源同时风暴,终端队列直接躺平。

解法是所有消息系统都走过的路:在源与终端之间插入一条总线。源只管往总线发,总线负责削峰、路由、分发;终端只听总线。网状变星型,死亡曲线变成线性。

text 复制代码
[HTTP源] [MQTT] [SNMP] [邮件] [Modbus]   ← 适配层:协议收编为统一消息
     └──────┴──────┴──────┴──────┘
               [告警总线]                ← Redis Stream / MQTT Broker
          削峰 · 防抖 · 分级 · 路由
               ┌──────┴──────┐
        [终端·现场声光]   [工单/IM/短信]   ← 消费者各取所需

二、统一消息格式:总线上的通用语

总线的第一产品是消息 schema------所有适配层把各自的方言翻译成这一种通用语:

json 复制代码
{
  "id": "evt-20260919-000123",
  "source": "hikvision-nvr",
  "level": "critical",
  "place": "仓库东侧",
  "event": "视频信号丢失",
  "dev": "cam-07",
  "fired_at": "2026-09-19T08:54:30+08:00",
  "ttl": 3600,
  "dedup_key": "hikvision/仓库东侧/cam-07/videoloss"
}

字段设计的三个决策:

  1. placeevent 是人话(专栏文案篇的翻译在适配层完成)------总线之后的所有消费者不用再翻译;
  2. dedup_key 是防抖的锚(专栏收敛篇)------总线级去重把"三十个源各自防抖"变成"一处防抖";
  3. ttl 是告警的保质期------风暴消退后总线上滞留的旧告警自动过期,专治"考古播报"(专栏收敛篇的坑 8)。

三、总线实现:Redis Stream 的削峰与回压

为什么选 Redis Stream 而不是直接上 Kafka:告警总线要的是"轻量持久 + 消费组 + 有限回压",不是亿级吞吐。Redis Stream 一台 Redis 全有:

python 复制代码
# bus_core.py ------ 告警总线:生产(削峰入库)与消费(路由分发)
import json
import time

import redis

r = redis.Redis(host="192.168.10.6", port=6379, decode_responses=True)
STREAM = "alarms"
GROUP = "router"
MAXLEN = 100_000                    # 流最大长度:总线自身的容量上限

RATE = {"critical": 20, "warning": 10}      # 每秒发往终端的上限(终端播报能力)
_bucket: dict[str, list[float]] = {}


def publish(msg: dict) -> str:
    """适配层调用:统一入总线。超压时 critical 仍进,低级别直接丢弃"""
    level = msg.get("level", "warning")
    now = time.time()
    _bucket.setdefault(level, [])
    _bucket[level] = [t for t in _bucket[level] if now - t < 1]
    if len(_bucket[level]) >= RATE[level] and level != "critical":
        return "dropped:rate-limit"         # 削峰:低级别超速直接丢(有日志)
    _bucket[level].append(now)

    msg.setdefault("fired_at", time.strftime("%FT%T%z"))
    msg["ttl"] = msg.get("ttl", 3600)
    r.xadd(STREAM, {"m": json.dumps(msg, ensure_ascii=False)}, maxlen=MAXLEN)
    return "ok"


def consume_forever() -> None:
    """路由消费者组:读 → 按 level 分发 → Ack"""
    try:
        r.xgroup_create(STREAM, GROUP, id="0", mkstream=True)
    except Exception:
        pass                                 # 组已存在
    while True:
        rows = r.xreadgroup(GROUP, "router-1", {STREAM: ">"}, count=10, block=2000)
        for _, entries in rows:
            for entry_id, fields in entries:
                msg = json.loads(fields["m"])
                if time.time() - time.mktime(time.strptime(
                        msg["fired_at"][:19], "%Y-%m-%dT%H:%M:%S")) < msg["ttl"]:
                    dispatch(msg)            # 未过期才分发
                r.xack(STREAM, GROUP, entry_id)


def dispatch(msg: dict) -> None:
    """按级别路由:终端声光 + 工单 + IM 各自订阅各自的世界"""
    requests_post(f"http://192.168.0.66/api/api/user_msg/bus-{msg['level']}", msg)
    if msg["level"] == "critical":
        requests_post("http://ticket-system/api/open", msg)


def requests_post(url: str, msg: dict) -> None:
    import requests
    try:
        requests.post(url, json=msg, timeout=3)
    except requests.RequestException:
        r.xadd("alarms.retry", {"m": json.dumps(msg, ensure_ascii=False)})  # 失败入重试流

架构上的四个关键点:

  1. 削峰在入口做publish 的令牌桶按级别限速------warning 超速直接丢(敢丢的前提是分级收口,critical 永不丢),终端播报能力从此不再被风暴考验(专栏队列篇的"终端兜底"升级为"总线主防");
  2. 消费组保证不丢xreadgroup + xack,路由器崩了重启从上次的位置继续;分发失败进 alarms.retry 重试流------总线把"至少一次"变成了体系默认语义
  3. TTL 过期:分发前检查保质期,风暴中滞留五分钟的旧 warning 不再播报------队列治理(专栏队列篇)在总线层提前完成;
  4. 多消费者天然扩展:工单、IM、报表各自开消费组各订各的,互不影响------这就是"一次发布、多方受益"。

四、迁移路径:从蜘蛛网到总线

存量系统不必一夜重构,三步灰度:

  1. 新源先上总线:新接入的告警源一律走适配层进总线(先让蜘蛛网停止生长);
  2. 逐个收编存量源:把存量源的直连地址改为总线适配层------按源迁移,每迁一个观察一周;
  3. 终端只认总线 :最终态终端侧白名单(专栏安全篇)只剩总线一个源------暴露面收敛、防抖、限流、审计全部收口在一处

迁移期保留双跑对账(专栏日志篇):同一事件直连与总线各触发一次,播报日志对账一致后再切换------架构迁移的安全感,来自对账而不是自信


五、常见坑

  1. 总线做成单点 → Redis 没有持久化与主从,总线挂 = 全体失聪;持久化 + 哨兵/集群起步;
  2. 削峰把 critical 也丢了 → 限速豁免 critical 是铁律(专栏收敛篇),丢掉的 warning 要有日志可查;
  3. 消息格式没有版本字段 → 适配层升级后老消费者解析失败,schema 加 v 字段提前留门;
  4. 消费组不 Ack → pending 列表无限膨胀,Redis 内存被吃光,监控 xpending;
  5. TTL 没有上限 → 有人传 ttl=一年,过期检查形同虚设,服务端 clamp 到最大值;
  6. 重试流没有上限alarms.retry 无限重试毒消息,重试三次进死信流人工处理;
  7. 总线成了新的大泥球 → 路由、文案、防抖、工单全塞进总线进程------总线只做"收、削、转",业务逻辑留在适配层与消费者;
  8. 跳过对账直接切换 → 直连改总线的窗口期正是漏报高发期,双跑对账是唯一安全的过桥方式。

六、验收清单

  • 统一 schema 落地,全部适配层消息可互译;
  • 压测:warning 100 条/秒注入,终端侧 ≤ 10 条/秒且 critical 全量到达;
  • 路由器 kill -9 后重启,pending 消息无丢失续传;
  • TTL 过期与死信流验证,毒消息三试后隔离;
  • 存量源迁移双跑对账通过,直连通道按计划退役;
  • 终端白名单收口到总线单源,暴露面清单更新;
  • 总线自身纳入看门狗与监控(xlen / xpending / 消费延迟三指标)。

七、小结

告警总线解决的是规模化后的秩序问题 :统一语(schema)让三十个方言源说一种话,削峰(令牌桶)让风暴到不了终端,消费组让分发不丢不重,TTL 让考古播报成为历史。它把本系列散落在各篇的中间件------海康转发器、MQTT 桥、防抖状态机、限流闸------收拢成一处:源越来越多样,总线越来越稳,终端始终简单

架构演进的终点很朴素:让每一个新告警源的接入,变成往适配层加一个翻译文件的事。到那一天,这张网就不再是蜘蛛网,而是电网。

相关推荐
DLYSB_2 天前
喇叭往哪摆才喊得醒人:声光告警的现场声学工程
报警灯
DLYSB_4 天前
谁来监控监控本身:告警链路的可用性工程
报警灯
DLYSB_5 天前
Alertmanager 的告警怎么“喊“出来:Webhook 到声光语音终端的路由与抑制设计
报警灯
DLYSB_12 天前
混合云与工业边缘场景下“云原生告警与物理现场声光”的集成架构实践
云原生·架构·报警灯
DLYSB_15 天前
旧上位机不肯改怎么办:原生 TCP 字节帧接入声光语音终端的改造实录
网络·网络协议·tcp/ip·报警灯
DLYSB_16 天前
交换机不会 HTTP 怎么办:用 SNMP Get / Trap 把指标和中断打到博灵声光 TTS
数据库·报警灯
DLYSB_17 天前
监控回调字段对不上 send_msg?用博灵自定义 API 把 WebHook 映射成声光 TTS
http·报警灯
DLYSB_1 个月前
让 PLC“开口说话”:基于 Modbus TCP 的声光语音告警设计与 Python 调试
python·网络协议·tcp/ip·报警灯
DLYSB_1 个月前
从监控告警到现场处置:用 HTTP API、Modbus TCP 构建声光语音告警系统
网络协议·tcp/ip·http·报警灯