一个省赛一等奖项目的技术复盘,涵盖异步管道、MQ削峰、熔断降级、缓存防护等Java后端核心技术栈。
一、项目背景
在智慧城市场景下,灯杆上挂载了摄像头和多种传感器(温湿度、光照、电压、电流)。传统方案把视频流传回云端分析——带宽成本高、延迟大。这个项目的思路是把AI推理下沉到边缘节点,本地处理完只上传结构化结果。
边缘节点的硬件很普通:4核CPU、8G内存。这台机器要同时跑视频帧抓取、YOLOv8推理、传感器数据入库、告警判定、WebSocket长连接——所有服务挤在一起,性能瓶颈很快就暴露了。
这篇文章记录我从“能跑”到“跑得稳”的优化过程。
二、整体架构
7个容器,Docker Compose一键编排:
四条核心链路:
| 链路 | 流程 |
|---|---|
| 视频帧抓取 | HTTP → CompletableFuture(FFmpeg) → JPEG压缩 → Base64 |
| 传感器数据 | HTTP → RabbitMQ → Consumer{DB+Redis+告警} |
| AI推理 | HTTP → RestTemplate(Flask) → Resilience4j{重试+熔断} → fallback |
| 设备控制 | HTTP → MQTT Publish → 设备订阅 |
三、异步视频帧管道:750ms→190ms
3.1 原始实现的问题
最早是同步串行的:
// 问题代码:每一步都阻塞当前线程
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 异步化改造
// 优化后:异步编排 + 超时保护
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做异步削峰:
// 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 三层防护体系
@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 | 宁可漏报不可崩溃 |
熔断状态机:
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。
if (data == null) {
// 缓存空值标记,TTL 5分钟
redis.set("sensor:latest:99999", "__NULL__", 300, SECONDS);
throw new BusinessException("未找到");
}
面试官可能会问“为什么不用布隆过滤器”?——布隆过滤器有误判率,且需额外维护bit数组。小规模场景空值缓存足够,大规模(千万级key)才上布隆。
6.2 击穿防护(Mutex Lock)
问题:热点灯杆的缓存刚好过期,瞬间100个请求同时查DB。
// 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同时过期。
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版本号
设备连接 → 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 原创,转载请注明出处。

1125

被折叠的 条评论
为什么被折叠?



