视频分析平台高并发告警架构实战:从"识别到告警"的工程化落地

视频分析平台高并发告警架构实战:从"识别到告警"的工程化落地

技术栈:SpringBoot · 线程池/阻塞队列 · RocketMQ/Kafka · YOLOv12/DJL · Vue+ElementUI


一、为什么需要单独设计"告警架构"

很多人做视频分析平台,第一版往往是这样:

复制代码
多路摄像头拉流 → 逐帧识别(人脸/车牌/行为) → 命中即推送告警

这个"朴素实现"在接入几路视频时没问题,一旦扩展到几十上百路、或出现突发事件(群体聚集、深夜大量车辆进出),就会集中暴露三个问题:

  1. 处理跟不上:每路视频都独占一个线程拉流+识别,线程爆炸,CPU 打满,帧堆积。
  2. 告警风暴:同一个事件(比如一辆车违停)会在连续 N 帧都被识别命中,重复推送几十条,人工根本无法处理。
  3. 下游被打爆:直接同步调用短信/微信/webhook,推送服务一抖动,整个识别链路被拖垮。

所以,"识别能力"和"告警工程"必须解耦。识别负责"看到什么",告警架构负责"怎么可控地通知出去"。本文就围绕这条主线,讲清楚分层设计、关键代码和避坑点。


二、整体架构:五层解耦

arduino 复制代码
┌─────────────────────────────────────────────────────────┐
│                    接入层  Camera Source                  │
│     多路RTSP/GB28181拉流,每路一个拉流线程,只产帧不处理      │
└──────────────────────────┬──────────────────────────────┘
                           ▼
┌─────────────────────────────────────────────────────────┐
│                    处理层  Frame Processor                │
│   有界阻塞队列 + 线程池做背压;帧采样、缩放、识别(YOLO/OCR)    │
│   只产"事件",不做任何推送决策                             │
└──────────────────────────┬──────────────────────────────┘
                           ▼
┌─────────────────────────────────────────────────────────┐
│                  告警引擎  Alert Engine                   │
│   规则匹配 → 滑动窗口去重 → 聚合 → 风暴抑制                 │
│   产出"一条可触达的业务告警"                               │
└──────────────────────────┬──────────────────────────────┘
                           ▼
┌─────────────────────────────────────────────────────────┐
│                  分发层  Message Queue                   │
│   RocketMQ/Kafka 异步削峰,异步解耦,消费端可重试、可幂等      │
└──────────────────────────┬──────────────────────────────┘
                           ▼
┌─────────────────────────────────────────────────────────┐
│                  触达层  Notifier                         │
│   webhook / 短信 / 站内推送 / 大屏;分级、限频、人工确认闭环   │
└─────────────────────────────────────────────────────────┘

各层职责单一、单向依赖,任意一层崩溃都不会拖垮上游。下面逐个展开。


三、接入层 + 处理层:用"有界队列 + 线程池"做背压

3.1 核心思想

拉流线程(生产者)只负责把帧塞进有界队列 ,识别由独立的线程池(消费者)负责。有界队列就是天然的背压闸门:当识别速度跟不上时,队列写满,拉流线程阻塞或丢帧,而不是无限堆积内存。

3.2 代码实现

java 复制代码
@Component
public class FramePipeline {

    // 每路视频一个有界队列,容量按帧率估算,比如 30fps 给 60 帧缓冲
    private final Map<String, BlockingQueue<Frame>> queueMap = new ConcurrentHashMap<>();
    private final ExecutorService processorPool;

    private static final int QUEUE_CAPACITY = 60;

    public FramePipeline(@Value("${video.process.threads:8}") int threads) {
        this.processorPool = new ThreadPoolExecutor(
                threads, threads,
                0L, TimeUnit.MILLISECONDS,
                new ArrayBlockingQueue<>(threads * 4),
                new ThreadFactoryBuilder().setNameFormat("frame-processor-%d").build(),
                new ThreadPoolExecutor.CallerRunsPolicy() // 队列满时由调用线程兜底,避免丢弃
        );
    }

    /** 拉流线程调用:产帧 */
    public void push(String cameraId, Frame frame) {
        queueMap.computeIfAbsent(cameraId, k -> new ArrayBlockingQueue<>(QUEUE_CAPACITY));
        BlockingQueue<Frame> queue = queueMap.get(cameraId);
        boolean offered = queue.offer(frame);          // 非阻塞入队
        if (!offered) {
            // 队列已满:丢弃最旧帧(丢帧优于堆积内存),可埋点统计
            queue.poll();
            queue.offer(frame);
        }
        // 每 N 帧才提交一次识别任务,降低线程池压力
        if (frame.getSeq() % SAMPLE_INTERVAL == 0) {
            processorPool.submit(() -> process(frame));
        }
    }

    private void process(Frame frame) {
        // 识别逻辑:缩放 → YOLOv12 目标检测 / 车牌 OCR → 产出 Event
        List<Detection> detections = detector.detect(frame);
        for (Detection d : detections) {
            if (ruleEngine.match(cameraId, d)) {
                eventBus.publish(toEvent(cameraId, d, frame.getTs()));
            }
        }
    }
}

关键点:

  • 有界队列 ArrayBlockingQueue 固定容量,配合 offer 非阻塞,满了丢最旧帧,用"丢帧"换"不 OOM"。
  • 线程池隔离 :识别用独立线程池,不跟拉流线程混用;CallerRunsPolicy 兜底避免任务直接丢弃。
  • 采样降帧 :不是每一帧都识别,按 SAMPLE_INTERVAL 抽帧,这是最廉价的高并发优化。

四、告警引擎:去重、聚合、风暴抑制

这是整个架构的灵魂,直接决定告警质量。三个层层递进的机制:

4.1 滑动窗口去重(同一事件只告警一次)

同一辆车违停、同一个人连续出现,会产生大量相邻事件。用时间窗口 + 事件指纹去重:窗口内相同指纹只保留一条。

java 复制代码
public class AlertDedup {

    private final long windowMs;
    // 指纹 -> 该指纹最近一次已告警的时间
    private final Cache<String, Long> window = Caffeine.newBuilder()
            .expireAfterWrite(Duration.ofMillis(windowMs))
            .build();

    public AlertDedup(long windowMs) {
        this.windowMs = windowMs;
    }

    /** 返回 true 表示这条事件是"新告警",应该推送 */
    public boolean shouldAlert(String fingerprint, long now) {
        Long last = window.getIfPresent(fingerprint);
        if (last == null || now - last >= windowMs) {
            window.put(fingerprint, now);
            return true;
        }
        return false;
    }

    /** 指纹:按摄像头 + 事件类型 + 识别主体(车牌/人脸id) + 目标区域 组合 */
    public static String fingerprint(String cameraId, String type, String subject, String zone) {
        return cameraId + ":" + type + ":" + subject + ":" + zone;
    }
}

4.2 告警聚合(连续事件合并成一条持续告警)

有些事件本身是"持续状态"(比如区域入侵后一直存在)。去重只解决了"重复推送",聚合则把窗口内的同类事件归并为一条,携带次数和起止时间,便于大屏展示和处置。

java 复制代码
public class AlertAggregator {

    private final Cache<String, AggregatedAlert> active = Caffeine.newBuilder()
            .expireAfterWrite(Duration.ofSeconds(30))
            .build();

    public AggregatedAlert aggregate(String fingerprint, Event e) {
        AggregatedAlert agg = active.get(fingerprint, k -> new AggregatedAlert(e));
        agg.increaseCount();
        agg.setLastTs(e.getTs());
        return agg;
    }
}

4.3 告警风暴抑制(分级限频 + 静默期)

真正的风暴场景(摄像头全部离线、大片区域触发),必须做全局限频:按告警级别配置每秒/每分钟上限,超出则进入静默期并只保留一条"风暴提示"。

java 复制代码
public class StormGuard {

    // 级别 -> 每分钟允许的告警上限
    private final Map<String, Integer> rateLimit = Map.of(
            "HIGH", 10, "MEDIUM", 30, "LOW", 100
    );
    private final Map<String, RateLimiter> limiters = new ConcurrentHashMap<>();

    public boolean allow(String level) {
        return limiters.computeIfAbsent(level, k -> RateLimiter.create(rateLimit.get(level) / 60.0))
                .tryAcquire();
    }
}

业务口径提醒 :告警引擎里有一个经常被忽略的规则------历史数据不回溯。比如摄像头补传、或识别延迟导致的事件时间戳早于当前告警窗口,一律不触发新告警,只落库记录,避免系统重启后把旧事件全部"补告警"一遍。


五、分发层:消息队列削峰 + 消费幂等

告警引擎产出的是低频、但每条都重要的业务消息。用 MQ 异步分发,好处有三:

  1. 削峰:突发告警先落队列,推送端按自己节奏消费,不会打爆下游。
  2. 解耦:告警生产方不关心推送渠道(webhook/短信/大屏各是一个消费者)。
  3. 可重试:推送失败重新消费,配合幂等不重复通知。
java 复制代码
// 生产者:告警引擎确认"可推送"后,投递 MQ
rocketMQTemplate.syncSend(
        "alert-topic",
        MessageBuilder.withPayload(alert).setHeader("KEY", alert.getId()).build()
);
java 复制代码
// 消费者:推送,失败重试,用幂等表保证同一条只推一次
@RocketMQMessageListener(topic = "alert-topic", consumerGroup = "alert-notifier")
@Component
public class AlertNotifier implements RocketMQListener<Alert> {

    @Autowired private AlertPushLogService logService;

    @Override
    public void onMessage(Alert alert) {
        // 幂等校验:同一 alertId 已推送成功则跳过
        if (logService.existsPushed(alert.getId())) {
            return;
        }
        try {
            notifierFacade.push(alert);            // 按级别路由到 webhook/短信/大屏
            logService.markPushed(alert.getId());  // 落库标记,防重复
        } catch (Exception e) {
            log.error("push alert failed, will retry: {}", alert.getId(), e);
            throw new RuntimeException(e);         // 抛异常触发 MQ 重试
        }
    }
}

六、触达层与可观测性

  • 分级触达:HIGH → 短信+电话加急;MEDIUM → webhook + 站内;LOW → 只入大屏/记录,不打扰。级别在规则引擎里定好。
  • 人工确认闭环:告警应有"确认/误报/已处理"状态流转,回写数据库,大屏展示待处置队列。这条往往决定平台是否真正可用,而不是只会推送。
  • 可观测性三件套 :prometheus 打点(拉流帧率、队列水位、识别耗时、丢弃帧数、告警产出量)、jstack 排查 CPU 高(看 frame-processor 线程是否堆积)、日志中给每条事件打 traceId 串联链路。

七、避坑清单(都是踩过的)

  1. 别让识别结果直接同步推送------一定要过"引擎 + MQ"两关,否则一个推送抖动就拖垮识别链路。
  2. 队列必须设上限------无界队列在高峰时直接把 JVM 堆打爆,宁可丢帧不可 OOM。
  3. 指纹要能区分摄像头和区域------否则两个摄像头各来一次会互相误判去重。
  4. 消费端必须幂等------MQ 至少一次投递,不做幂等就会重复短信轰炸。
  5. 历史数据不回溯------时间戳早于窗口的事件只落库不告警,避免补告警风暴。
  6. 背压要透传到拉流------队列满时反馈给拉流线程降帧/暂停,从源头控流量。

八、总结

一句话概括这套架构:接入层只管产帧,处理层只管识别,引擎层管"值得不值得告警",队列管"怎么不被打爆地送出去",触达层管"怎么让人看见"。 每一层各司其职、单向依赖,识别能力和告警工程彻底解耦,平台才能从"能识别"进化到"能抗压、可运维、真正可用"。

如果你的平台还在朴素地"识别即推送",建议先从有界队列背压 + 滑动窗口去重 + MQ 削峰这三步改造起,性价比最高,效果立竿见影。


如果你也在做视频分析/AI 视觉平台,欢迎在评论区交流你的告警架构踩过的坑。

相关推荐
小蒜学长1 小时前
基于SpringBoot+Vue的租房管理系统的设计与实现(代码+数据库+LW)
java·数据库·spring boot·后端·租房管理
洋就在江州1 小时前
gitlab-cicd 离线集成——springboot-cicd (非docker形式,shell形式)
java·spring boot·后端·ci/cd·gitlab·gitlab-runner
用户9479135811621 小时前
Jev 是什么,能做什么?和 Agent 的区别,其实是一个「判断」和「执行」的分工问题
后端
air_link1 小时前
陪伴型 AI 最容易出的六个问题,像人还是像心理健康的人。
后端
根目录下的猫1 小时前
虚拟机复制过来运行时:“虚拟机使用的此版本,VMware Workstation 不支持的硬件版本。”错误解决办法,亲测可用
linux·运维·服务器·后端
蜗牛互联网2 小时前
Java Agent 工具调用的 allowlist、参数校验与调用预算
java·开发语言·人工智能·后端·oracle
用户813267933252 小时前
行情数据晚到几秒,会让量化策略失去优势吗?从信号时间到回测偏差
后端·github·api
九零HTTP2 小时前
一次 TCP 连接的一生:从三次握手到四次挥手
后端
ZOnePieceC2 小时前
消息队列之Kafka
后端