视频分析平台高并发告警架构实战:从"识别到告警"的工程化落地
技术栈:SpringBoot · 线程池/阻塞队列 · RocketMQ/Kafka · YOLOv12/DJL · Vue+ElementUI
一、为什么需要单独设计"告警架构"
很多人做视频分析平台,第一版往往是这样:
多路摄像头拉流 → 逐帧识别(人脸/车牌/行为) → 命中即推送告警
这个"朴素实现"在接入几路视频时没问题,一旦扩展到几十上百路、或出现突发事件(群体聚集、深夜大量车辆进出),就会集中暴露三个问题:
- 处理跟不上:每路视频都独占一个线程拉流+识别,线程爆炸,CPU 打满,帧堆积。
- 告警风暴:同一个事件(比如一辆车违停)会在连续 N 帧都被识别命中,重复推送几十条,人工根本无法处理。
- 下游被打爆:直接同步调用短信/微信/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 异步分发,好处有三:
- 削峰:突发告警先落队列,推送端按自己节奏消费,不会打爆下游。
- 解耦:告警生产方不关心推送渠道(webhook/短信/大屏各是一个消费者)。
- 可重试:推送失败重新消费,配合幂等不重复通知。
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串联链路。
七、避坑清单(都是踩过的)
- 别让识别结果直接同步推送------一定要过"引擎 + MQ"两关,否则一个推送抖动就拖垮识别链路。
- 队列必须设上限------无界队列在高峰时直接把 JVM 堆打爆,宁可丢帧不可 OOM。
- 指纹要能区分摄像头和区域------否则两个摄像头各来一次会互相误判去重。
- 消费端必须幂等------MQ 至少一次投递,不做幂等就会重复短信轰炸。
- 历史数据不回溯------时间戳早于窗口的事件只落库不告警,避免补告警风暴。
- 背压要透传到拉流------队列满时反馈给拉流线程降帧/暂停,从源头控流量。
八、总结
一句话概括这套架构:接入层只管产帧,处理层只管识别,引擎层管"值得不值得告警",队列管"怎么不被打爆地送出去",触达层管"怎么让人看见"。 每一层各司其职、单向依赖,识别能力和告警工程彻底解耦,平台才能从"能识别"进化到"能抗压、可运维、真正可用"。
如果你的平台还在朴素地"识别即推送",建议先从有界队列背压 + 滑动窗口去重 + MQ 削峰这三步改造起,性价比最高,效果立竿见影。
如果你也在做视频分析/AI 视觉平台,欢迎在评论区交流你的告警架构踩过的坑。