一个省赛一等奖项目的技术复盘,涵盖异步管道、MQ削峰、熔断降级、缓存防护等Java后端核心技术栈。
一、项目背景
在智慧城市场景下,灯杆上挂载了摄像头和多种传感器(温湿度、光照、电压、电流)。传统方案把视频流传回云端分析------带宽成本高、延迟大。这个项目的思路是把AI推理下沉到边缘节点,本地处理完只上传结构化结果。
边缘节点的硬件很普通:4核CPU、8G内存。这台机器要同时跑视频帧抓取、YOLOv8推理、传感器数据入库、告警判定、WebSocket长连接------所有服务挤在一起,性能瓶颈很快就暴露了。
这篇文章记录我从"能跑"到"跑得稳"的优化过程。
二、整体架构
7个容器,Docker Compose一键编排:
#mermaid-svg-VhLtflbv3SCe9biG{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-VhLtflbv3SCe9biG .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-VhLtflbv3SCe9biG .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-VhLtflbv3SCe9biG .error-icon{fill:#552222;}#mermaid-svg-VhLtflbv3SCe9biG .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-VhLtflbv3SCe9biG .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-VhLtflbv3SCe9biG .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-VhLtflbv3SCe9biG .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-VhLtflbv3SCe9biG .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-VhLtflbv3SCe9biG .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-VhLtflbv3SCe9biG .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-VhLtflbv3SCe9biG .marker{fill:#333333;stroke:#333333;}#mermaid-svg-VhLtflbv3SCe9biG .marker.cross{stroke:#333333;}#mermaid-svg-VhLtflbv3SCe9biG svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-VhLtflbv3SCe9biG p{margin:0;}#mermaid-svg-VhLtflbv3SCe9biG .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-VhLtflbv3SCe9biG .cluster-label text{fill:#333;}#mermaid-svg-VhLtflbv3SCe9biG .cluster-label span{color:#333;}#mermaid-svg-VhLtflbv3SCe9biG .cluster-label span p{background-color:transparent;}#mermaid-svg-VhLtflbv3SCe9biG .label text,#mermaid-svg-VhLtflbv3SCe9biG span{fill:#333;color:#333;}#mermaid-svg-VhLtflbv3SCe9biG .node rect,#mermaid-svg-VhLtflbv3SCe9biG .node circle,#mermaid-svg-VhLtflbv3SCe9biG .node ellipse,#mermaid-svg-VhLtflbv3SCe9biG .node polygon,#mermaid-svg-VhLtflbv3SCe9biG .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-VhLtflbv3SCe9biG .rough-node .label text,#mermaid-svg-VhLtflbv3SCe9biG .node .label text,#mermaid-svg-VhLtflbv3SCe9biG .image-shape .label,#mermaid-svg-VhLtflbv3SCe9biG .icon-shape .label{text-anchor:middle;}#mermaid-svg-VhLtflbv3SCe9biG .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-VhLtflbv3SCe9biG .rough-node .label,#mermaid-svg-VhLtflbv3SCe9biG .node .label,#mermaid-svg-VhLtflbv3SCe9biG .image-shape .label,#mermaid-svg-VhLtflbv3SCe9biG .icon-shape .label{text-align:center;}#mermaid-svg-VhLtflbv3SCe9biG .node.clickable{cursor:pointer;}#mermaid-svg-VhLtflbv3SCe9biG .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-VhLtflbv3SCe9biG .arrowheadPath{fill:#333333;}#mermaid-svg-VhLtflbv3SCe9biG .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-VhLtflbv3SCe9biG .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-VhLtflbv3SCe9biG .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-VhLtflbv3SCe9biG .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-VhLtflbv3SCe9biG .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-VhLtflbv3SCe9biG .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-VhLtflbv3SCe9biG .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-VhLtflbv3SCe9biG .cluster text{fill:#333;}#mermaid-svg-VhLtflbv3SCe9biG .cluster span{color:#333;}#mermaid-svg-VhLtflbv3SCe9biG div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-VhLtflbv3SCe9biG .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-VhLtflbv3SCe9biG rect.text{fill:none;stroke-width:0;}#mermaid-svg-VhLtflbv3SCe9biG .icon-shape,#mermaid-svg-VhLtflbv3SCe9biG .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-VhLtflbv3SCe9biG .icon-shape p,#mermaid-svg-VhLtflbv3SCe9biG .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-VhLtflbv3SCe9biG .icon-shape .label rect,#mermaid-svg-VhLtflbv3SCe9biG .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-VhLtflbv3SCe9biG .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-VhLtflbv3SCe9biG .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-VhLtflbv3SCe9biG :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Nginx :80
反向代理 + API限流
Spring Boot :8080
核心业务
MySQL :3306
数据持久化
Redis :6379
缓存 + 心跳 + 分布式锁
RabbitMQ :5672
传感器削峰 + 死信队列
Mosquitto :1883
MQTT指令下发
YOLOv8 Flask :5000
AI行人检测
LED灯杆设备
死信队列 DLQ
四条核心链路:
| 链路 | 流程 |
|---|---|
| 视频帧抓取 | HTTP → CompletableFuture(FFmpeg) → JPEG压缩 → Base64 |
| 传感器数据 | HTTP → RabbitMQ → Consumer{DB+Redis+告警} |
| AI推理 | HTTP → RestTemplate(Flask) → Resilience4j{重试+熔断} → fallback |
| 设备控制 | HTTP → MQTT Publish → 设备订阅 |
三、异步视频帧管道:750ms→190ms
3.1 原始实现的问题
最早是同步串行的:
java
// 问题代码:每一步都阻塞当前线程
public VideoFrameDTO getCurrentFrame(Long lampId) {
String frame = captureFrame(cameraUrl); // FFmpeg抓帧 ~500ms
String compressed = compressFrame(frame); // JPEG压缩 ~200ms
String base64 = encodeBase64(compressed); // 编码 ~50ms
return buildDTO(lampId, base64); // 总计 ~750ms
}
4路摄像头同时请求时,Tomcat线程池瞬间打满。
3.2 异步化改造
java
// 优化后:异步编排 + 超时保护
CompletableFuture<String> captureFuture = CompletableFuture.supplyAsync(
() -> videoFrameHandler.captureFrame(cameraUrl),
videoTaskExecutor // 专用线程池,不占用Tomcat线程
);
String frameData = captureFuture
.thenApply(videoFrameHandler::compressFrame) // 异步链式编排
.get(10, TimeUnit.SECONDS); // 超时保护,防RTSP流卡死
线程池参数设计:
| 参数 | 值 | 原因 |
|---|---|---|
| corePoolSize | 4 | CPU核数,保底处理能力 |
| maxPoolSize | 7 | IO密集型,阻塞系数≈1,理论值8个,留余量给其他服务 |
| 队列 | LinkedBlockingQueue(100) | 有界队列,防止无限堆积导致OOM |
| 拒绝策略 | CallerRunsPolicy | 队列满时由调用者线程执行,形成自然背压 |
为什么CallerRunsPolicy? DiscardPolicy会丢帧(数据丢失不可接受),AbortPolicy会抛异常(增加调用方复杂度)。CallerRunsPolicy让Tomcat线程亲自执行任务------虽然慢一点,但下游处理完之前不会接收更多请求,形成自然背压。
3.3 效果
单帧处理 750ms → 190ms(↓74.7%),4路并发时Tomcat线程池保持健康水位。
四、传感器削峰:同步写库→MQ异步
4.1 问题
设备每5秒上报一次传感器数据(温度、湿度、光照、电压、电流共5个指标)。100个灯杆就是每秒20条数据。每条数据要:写MySQL、更新Redis缓存、检查告警规则------全同步链路,高峰期MySQL连接池被打满。
4.2 方案
引入RabbitMQ做异步削峰:
java
// Producer:只投递到MQ,不阻塞
public void reportData(SensorDataDTO dto) {
boolean sent = producer.sendSensorData(dto); // 投递到RabbitMQ
if (!sent) {
processSync(dto); // MQ不可用时降级同步兜底,保证数据不丢
}
}
// Consumer:异步消费 + 死信队列保护
@RabbitListener(queues = "sensor.data.queue")
public void handleSensorData(SensorDataDTO dto) {
// 1. MySQL insert
// 2. Redis cache(随机TTL防雪崩)
// 3. 告警判定
// 消费失败 → Spring AMQP自动retry 3次 → 全部失败进死信队列sensor.data.dlq
}
为什么选RabbitMQ而不是Kafka?
| 维度 | RabbitMQ | Kafka | 本项目选型 |
|---|---|---|---|
| 吞吐量 | 万级/秒 | 百万级/秒 | 传感器几十条/秒,RabbitMQ够用 |
| 路由灵活性 | Exchange多类型路由 | 仅Topic订阅 | 需要按灯杆ID灵活路由 |
| 死信队列 | 原生支持,配置简单 | 需自行实现 | RabbitMQ开箱即用 |
| 运维复杂度 | 低 | 高(需ZK/KRaft) | 边缘节点资源有限 |
结论:不是越新的技术越好,匹配场景的才是最优解。
五、AI推理容错:不崩溃比什么都重要
YOLOv8是外部的Python Flask服务。HTTP调用天然不可靠------网络抖动、模型加载慢、GPU显存溢出都可能导致失败。
5.1 三层防护体系
java
@CircuitBreaker(name = "yoloCB", fallbackMethod = "inferenceFallback")
@Retry(name = "yoloRetry", fallbackMethod = "inferenceFallback")
public InferenceResultVO inference(InferenceRequestDTO request) {
// HTTP调用YOLOv8 Flask推理服务
ResponseEntity<String> response = restTemplate.exchange(...);
// ...
}
// 降级方法:不崩溃,返回空结果
private InferenceResultVO inferenceFallback(RequestDTO req, Throwable t) {
return new InferenceResultVO(req.getLampId(), 0, "[]");
// personCount=0,不阻塞主流程
}
配置参数解释:
| 层级 | 配置 | 为什么这样配 |
|---|---|---|
| RestTemplate超时 | connect 2s / read 10s | 2s连不上大概率网络不通;推理<10s正常 |
| @Retry | 3次 / 500ms / 指数退避×2 | 间隔500ms→1s→2s,给服务短暂恢复窗口 |
| @CircuitBreaker | 滑动窗口10 / 失败率50% / 熔断30s | 10次调用统计,过半失败就熔断,30s后放3个请求探测 |
| fallbackMethod | personCount=0 | 宁可漏报不可崩溃 |
熔断状态机:
text
CLOSED(正常) → 失败率≥50% → OPEN(熔断,30s)
↓
HALF_OPEN(放行3次)
↙ ↘
成功→CLOSED 失败→OPEN
5.2 为什么不用Hystrix?
Hystrix已进入维护模式(Netflix官方声明),Spring Cloud官方推荐Resilience4j。而且Resilience4j是纯Java实现,不依赖Archaius/RxJava等外部库,启动更快、内存占用更小。
六、Redis缓存三板斧
getLatestData(lampId) 是最频繁的调用------前端轮询、告警检查、数据展示都调它。
6.1 穿透防护(Null Value Cache)
问题 :恶意请求不存在的lampId=99999,每次都穿透到DB。
java
if (data == null) {
// 缓存空值标记,TTL 5分钟
redis.set("sensor:latest:99999", "__NULL__", 300, SECONDS);
throw new BusinessException("未找到");
}
面试官可能会问"为什么不用布隆过滤器"?------布隆过滤器有误判率,且需额外维护bit数组。小规模场景空值缓存足够,大规模(千万级key)才上布隆。
6.2 击穿防护(Mutex Lock)
问题:热点灯杆的缓存刚好过期,瞬间100个请求同时查DB。
java
// SETNX互斥锁:只有一个线程能获锁重建缓存
boolean locked = redis.setIfAbsent("sensor:lock:" + lampId, "1", 10, SECONDS);
if (locked) {
try {
// 双重检查:获锁后缓存可能已被其他线程重建
cache = redis.get(key);
if (cache != null) return cache;
return rebuildFromDB(lampId);
} finally {
redis.delete(lockKey);
}
}
// 未获锁→自旋等待(5次×50ms)→重试读缓存
6.3 雪崩防护(Random TTL)
问题:TTL全部设为3600s,1小时后所有key同时过期。
java
long randomizeTtl(long base) {
long offset = (long)(base * 0.1 * random()); // ±10%
return base + (random() > 0.5 ? offset : -offset);
}
1000个key,TTL分布在3240s~3960s之间,不会同时过期,DB压力被均匀分散。
七、设备心跳协议
边缘设备通过4G/NB-IoT联网,网络极不稳定。简单的心跳超时会导致频繁误判。
方案:WebSocket长连接 + Redis INCR版本号
text
设备连接 → Redis SET device:version:{id} = 0
收到心跳 → Redis INCR → 回复 heartbeat_ack{version}
断连 → Redis SET device:status:{id} = offline
前端判断 → 版本号跳跃=发生过断连,触发数据重新拉取
为什么INCR而不是SET? 版本号必须严格递增。前端通过版本连续性判断是否有数据丢失。INCR是Redis原子操作,多实例部署也不会有竞态。
八、测试与工程化
8.1 56个纯Mockito单元测试
全部使用 @ExtendWith(MockitoExtension.class),无需Spring容器,56个测试<2秒跑完。
AlarmRecordServiceImplTest 9 覆盖超限/低限/正常/无规则/空值/多传感器
SensorDataServiceImplTest 9 覆盖MQ成功/降级/穿透/击穿/雪崩
VideoStreamServiceImplTest 8 覆盖正常/空/异常/超时/连续帧
DeviceWebSocketHandlerTest 8 覆盖连接/心跳/连续心跳/断连/非法JSON
AIInferenceServiceTest 5 覆盖推理成功/HTTP失败/记录查询
DeviceControlServiceTest 6 覆盖MQTT下发/离线/不存在/状态查询
DeviceHeartbeatServiceTest 11 覆盖初始化/校验/更新/断连/连续
8.2 为什么纯Mockito而不是@SpringBootTest?
@SpringBootTest 需要完整Spring上下文+MySQL+Redis,跑一次30秒起步。纯Mockito隔离外部依赖,CI友好,且能精确验证每个方法的调用参数和调用次数。
九、总结
经过9轮优化:
| 维度 | 优化前 | 优化后 |
|---|---|---|
| 视频帧延迟 | 750ms | 190ms |
| 传感器吞吐 | 同步写库 | MQ异步削峰 |
| AI推理 | 无保护 | 重试+熔断+降级 |
| 缓存 | 无防护 | 穿透/击穿/雪崩全覆盖 |
| 视频帧阻塞 | 永久阻塞 | 10s超时熔断 |
| 测试 | 0个纯单测 | 56个 |
| 部署 | 4容器 | 7容器 |
核心经验:
- 先跑通,再优化------不要过早优化,但要知道瓶颈在哪
- 异步不是银弹------线程池参数要根据IO/CPU密集类型计算,拒绝策略要结合业务语义
- 容错是基本功------外部依赖总会挂,重试/熔断/降级不是可选项
- 缓存三板斧必须会------Java面试送分题,项目里有实现就是加分项
- 测试写多少都不嫌多------56个纯Mockito测试<2秒跑完,改完代码立刻验证
附录:项目信息
- 技术栈:Spring Boot 2.7 + MyBatis-Plus + MySQL 8.0 + Redis + RabbitMQ + MQTT + WebSocket + JavaCV/FFmpeg + YOLOv8(Flask) + Resilience4j + Docker Compose + Nginx + Prometheus + Springdoc + JUnit5/Mockito
- 获奖:全国大学生物联网设计竞赛 湖南赛区一等奖
- GitHub :EdgeVideoAnalysis
本文由 BugFreeHunter 原创,转载请注明出处。