Skip to content

从 0 到 1 构建企业级视频智能分析平台:完整工程实践(四)

约 3914 字大约 13 分钟

MediaMTXFFmpegWebSocket

2026-02-13

从 0 到 1 构建企业级视频智能分析平台:完整工程实践

一份可直接落地的全栈技术方案 覆盖相机接入、流媒体编排、存储治理、智能分析全链路 包含架构设计、核心代码、数据模型、运维体系与性能基准


目录

第一部分:平台基础与相机接入

  1. 问题定义与架构设计
  2. 技术选型与核心组件
  3. 数据模型设计
  4. ONVIF 协议深度解析与实现
  5. MediaMTX 流媒体网关集成
  6. FFmpeg 编解码治理
  7. 相机接入完整流程
  8. 设备状态巡检与事件闭环

第二部分:录像存储系统

  1. 录像系统架构设计
  2. 存储卷治理与容量管理
  3. 录像策略编排
  4. 文件索引与回放检索

第三部分:智能分析平台

  1. 算法任务模型设计
  2. 算法节点管理与调度
  3. WebSocket 协议规范
  4. 结果处理与告警推送
  5. 预览链路实现

第四部分:运维与演进

  1. 配置管理
  2. 可观测性体系
  3. 典型问题与解决方案
  4. FAQ 与最佳实践
  5. 性能基准与压测
  6. 架构演进路线

第四部分:运维与演进

18. 配置管理

18.1 应用配置模板

# application.yml
server:
  port: 8080

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/video_analysis?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
    username: root
    password: ${DB_PASSWORD}
    driver-class-name: com.mysql.cj.jdbc.Driver
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
      connection-timeout: 30000

# 相机配置
camera:
  onboarding:
    async-enabled: true
    sync-timeout-ms: 15000
    duplicate-check:
      by-ip-port: true
  
  onvif:
    default-port: 80
    connect-timeout-ms: 3000
    read-timeout-ms: 5000
    heartbeat-timeout-ms: 3000
    soap:
      strict-fault-check: true
      allow-capability-fallback: true
  
  stream:
    naming: "cam_{ip}_ch{channel}_{subtype}"
    subtype-rule:
      main-min-width: 1280
    retry:
      register-max-attempts: 3
      register-backoff-ms: 1000
  
  health:
    check-interval-ms: 30000
    initial-delay-ms: 10000
    offline-alarm-threshold-sec: 180

# 编解码配置
codec:
  probe:
    ffprobe-bin: ffprobe
    timeout-ms: 3000
    max-concurrency: 8
  
  transcode:
    enabled: true
    assume-h265-when-unknown: true
    ffmpeg-bin: ffmpeg
    rtsp-transport: tcp
    stimeout-us: 5000000
    probesize: 5000000
    analyzeduration: 5000000
    x264:
      preset: veryfast
      crf: 23
      gop: 50
    enable-audio: false

# MediaMTX 配置
media:
  mtx:
    api-base-url: http://127.0.0.1:9997
    rtsp-port: 8554
    webrtc-port: 8889
    api:
      path-add: /v3/config/paths/add/
      path-get: /v3/config/paths/get/
      path-list: /v3/config/paths/list
      path-delete: /v3/config/paths/delete/

# 录像配置
recording:
  default:
    retention-days: 7
    segment-duration: 10m
    record-format: fmp4
  
  index:
    fixed-delay-ms: 30000
    scan-depth: 4
    lookback-hours: 24
  
  storage:
    refresh-interval-ms: 15000
    auto-switch-enabled: true
    migrate-batch-size: 20

# 算法任务配置
ai:
  task:
    keepalive-timeout-seconds: 30
    scene-keepalive-timeout-seconds: 30
    dedup-running-seconds: 10
  
  result:
    queue:
      size: 20000
    worker:
      min: 4
      max: 16
    sample-interval-ms: 1000
    batch:
      enabled: false
      size: 200
      flush-ms: 500
  
  preview:
    auto-start: true
    default-fps: 15
    default-width: 1280
    default-height: 720
    default-bitrate: 3000k
    default-encoder: h264_nvenc
  
  push:
    queue-size: 20000
    flush-ms: 1000
    routes-file: push_routes.json
    level-policy-file: level_policy.json

# WebSocket 配置
ws:
  ai-engine:
    endpoint: /ws/ai-engine
  web-notify:
    endpoint: /ws/web-notify

18.2 MediaMTX 配置模板

# mediamtx.yml
###############################################
# Global settings

# API
api: yes
apiAddress: :9997

# Metrics
metrics: yes
metricsAddress: :9998

###############################################
# RTSP server

rtsp: yes
rtspAddress: :8554
protocols: [tcp]
encryption: "no"
rtspAddress: :8554

###############################################
# WebRTC server

webrtc: yes
webrtcAddress: :8889
webrtcICEServers:
  - urls: [stun:stun.l.google.com:19302]

###############################################
# HLS server

hls: yes
hlsAddress: :8888
hlsAlwaysRemux: no
hlsVariant: lowLatency
hlsSegmentCount: 7
hlsSegmentDuration: 1s
hlsPartDuration: 200ms

###############################################
# Recording

record: no
recordPath: /recordings/%path/%Y-%m-%d/%H-%M-%S
recordFormat: fmp4
recordPartDuration: 1s
recordSegmentDuration: 10m
recordDeleteAfter: 7d

###############################################
# Path defaults

paths:
  all:
    sourceOnDemand: yes
    sourceOnDemandStartTimeout: 10s
    sourceOnDemandCloseAfter: 10s
    runOnDemandStartTimeout: 10s
    runOnDemandCloseAfter: 10s

18.3 Docker Compose 部署模板

version: "3.8"

services:
  mysql:
    image: mysql:8.0
    container_name: video-mysql
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      MYSQL_DATABASE: video_analysis
      TZ: Asia/Shanghai
    ports:
      - "3306:3306"
    volumes:
      - ./data/mysql:/var/lib/mysql
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql
    command: --default-authentication-plugin=mysql_native_password

  mediamtx:
    image: bluenviron/mediamtx:latest
    container_name: video-mediamtx
    restart: always
    ports:
      - "8554:8554"   # RTSP
      - "8888:8888"   # HLS
      - "8889:8889"   # WebRTC
      - "9997:9997"   # API
      - "9998:9998"   # Metrics
    volumes:
      - ./mediamtx.yml:/mediamtx.yml
      - ./data/recordings:/recordings
    environment:
      TZ: Asia/Shanghai

  app:
    image: video-analysis-platform:latest
    container_name: video-app
    restart: always
    depends_on:
      - mysql
      - mediamtx
    ports:
      - "8080:8080"
    environment:
      SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/video_analysis?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
      SPRING_DATASOURCE_USERNAME: root
      SPRING_DATASOURCE_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      MEDIA_MTX_API_BASE_URL: http://mediamtx:9997
      TZ: Asia/Shanghai
    volumes:
      - ./data/snapshots:/app/snapshots
      - ./logs:/app/logs

  nginx:
    image: nginx:alpine
    container_name: video-nginx
    restart: always
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf
      - ./data/recordings:/usr/share/nginx/html/recordings
      - ./data/snapshots:/usr/share/nginx/html/snapshots
    depends_on:
      - app

19. 可观测性体系

19.1 核心指标定义

相机接入层:

camera_onboarding_total               # 接入总数
camera_onboarding_success_total       # 接入成功数
camera_onboarding_duration_seconds    # 接入耗时
camera_online_count                   # 在线设备数
camera_offline_count                  # 离线设备数
camera_offline_event_duration_seconds # 离线时长

流媒体层:

stream_path_total                     # 路径总数
stream_path_register_success_rate     # 注册成功率
stream_transcode_count                # 转码路数
media_gateway_request_duration_seconds # API 调用耗时

录像层:

recording_plan_active_count           # 活跃计划数
recording_file_index_lag_seconds      # 索引延迟
storage_volume_usage_percent          # 存储使用率
storage_volume_full_count             # 满盘卷数

算法层:

ai_task_running_count                 # 运行中任务数
ai_task_error_count                   # 异常任务数
ai_engine_online_nodes                # 在线节点数
ai_result_queue_depth                 # 结果队列深度
ai_result_drop_total                  # 丢弃结果数
ai_alarm_push_success_rate            # 推送成功率

19.2 Prometheus 指标暴露

@Component
public class MetricsCollector {
    
    private final Counter cameraOnboardingTotal;
    private final Counter cameraOnboardingSuccess;
    private final Histogram cameraOnboardingDuration;
    private final Gauge cameraOnlineCount;
    private final Gauge cameraOfflineCount;
    
    public MetricsCollector(MeterRegistry registry) {
        this.cameraOnboardingTotal = Counter.builder("camera_onboarding_total")
            .description("Total camera onboarding attempts")
            .register(registry);
        
        this.cameraOnboardingSuccess = Counter.builder("camera_onboarding_success_total")
            .description("Successful camera onboardings")
            .register(registry);
        
        this.cameraOnboardingDuration = Histogram.builder("camera_onboarding_duration_seconds")
            .description("Camera onboarding duration")
            .register(registry);
        
        this.cameraOnlineCount = Gauge.builder("camera_online_count", this::getOnlineCount)
            .description("Online camera count")
            .register(registry);
        
        this.cameraOfflineCount = Gauge.builder("camera_offline_count", this::getOfflineCount)
            .description("Offline camera count")
            .register(registry);
    }
    
    public void recordOnboardingAttempt() {
        cameraOnboardingTotal.increment();
    }
    
    public void recordOnboardingSuccess(long durationMs) {
        cameraOnboardingSuccess.increment();
        cameraOnboardingDuration.record(durationMs / 1000.0);
    }
    
    private double getOnlineCount() {
        return cameraRepository.countByStatus(1);
    }
    
    private double getOfflineCount() {
        return cameraRepository.countByStatus(0);
    }
}

19.3 告警规则模板

# prometheus-alerts.yml
groups:
  - name: camera_alerts
    interval: 30s
    rules:
      - alert: CameraOnboardingFailureRateHigh
        expr: |
          (
            rate(camera_onboarding_total[5m]) - 
            rate(camera_onboarding_success_total[5m])
          ) / rate(camera_onboarding_total[5m]) > 0.05
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "相机接入失败率过高"
          description: "过去 5 分钟接入失败率超过 5%"
      
      - alert: CameraOfflineCountHigh
        expr: camera_offline_count > 10
        for: 3m
        labels:
          severity: critical
        annotations:
          summary: "离线设备数量过多"
          description: "当前离线设备数: {{ $value }}"
  
  - name: storage_alerts
    interval: 30s
    rules:
      - alert: StorageVolumeAlmostFull
        expr: storage_volume_usage_percent > 90
        for: 3m
        labels:
          severity: warning
        annotations:
          summary: "存储卷即将满盘"
          description: "卷 {{ $labels.volume_id }} 使用率: {{ $value }}%"
      
      - alert: RecordingIndexLagHigh
        expr: recording_file_index_lag_seconds > 120
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "录像索引延迟过高"
          description: "索引延迟: {{ $value }} 秒"
  
  - name: ai_alerts
    interval: 30s
    rules:
      - alert: AiResultQueueFull
        expr: ai_result_queue_depth > 18000
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "结果队列即将满"
          description: "队列深度: {{ $value }}"
      
      - alert: AlarmPushFailureRateHigh
        expr: ai_alarm_push_success_rate < 0.95
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "告警推送成功率过低"
          description: "成功率: {{ $value }}"

19.4 日志规范

日志级别使用:

  • ERROR: 系统异常,需要人工介入
  • WARN: 降级或重试成功的场景
  • INFO: 关键业务事件 (接入成功、任务启动等)
  • DEBUG: 详细调试信息

日志格式:

[时间] [级别] [线程] [类名] - [业务标识] 消息内容

示例:

2026-02-13 14:30:00.123 INFO  [main] c.e.CameraOnboardingService - [camera:1001] Device onboarded successfully: ip=10.0.1.20, manufacturer=Hikvision
2026-02-13 14:30:05.456 WARN  [task-1] c.e.MediaPathService - [path:cam_10_0_1_20_ch1_main] MediaMTX registration failed, retrying (1/3)
2026-02-13 14:30:10.789 ERROR [task-2] c.e.OnvifManager - [camera:1002] ONVIF connection timeout: ip=10.0.1.21, timeout=3000ms

20. 典型问题与解决方案

20.1 设备接入层

问题 1: ONVIF 连接超时

现象:

OnvifException: Connection timeout after 3000ms

排查步骤:

  1. ping 设备 IP 确认网络连通性
  2. telnet ip 80 确认端口开放
  3. 浏览器访问 http://ip/onvif/device_service 验证服务
  4. 检查防火墙规则

解决方案:

  • 增加超时时间至 5-10 秒
  • 配置网络路由
  • 使用设备 Web 界面开启 ONVIF 服务

问题 2: WS-Security 认证失败

现象:

SOAP Fault: The security token could not be authenticated

原因: 服务器与设备时间误差 > 5 秒

解决方案:

# 服务器端同步 NTP
ntpdate ntp.aliyun.com

# 设备端配置 NTP
设备 Web 界面 -> 系统配置 -> 时间设置 -> NTP 服务器: ntp.aliyun.com

问题 3: 在线但无画面

现象: camera_status=1, 但 RTSP 拉流失败

原因: 控制面可达但媒体面不可达

解决方案:

// 增加媒体可用性检查
public boolean checkMediaAvailable(String rtspUrl) {
    try {
        ProcessBuilder pb = new ProcessBuilder(
            "ffmpeg", "-i", rtspUrl, 
            "-t", "1", "-f", "null", "-"
        );
        Process p = pb.start();
        return p.waitFor(5, TimeUnit.SECONDS) && p.exitValue() == 0;
    } catch (Exception e) {
        return false;
    }
}

20.2 录像层

问题 4: 计划显示已开启但无文件

排查步骤:

  1. 检查 recording_plan.status 是否为 1
  2. 检查 MediaMTX 路径配置: GET /v3/config/paths/get/{pathName}
  3. 检查存储卷状态: storage_volume.statusis_full
  4. 检查源 RTSP 可拉流性

解决方案:

// 增加健康检查
@Scheduled(fixedDelay = 60000)
public void checkRecordingHealth() {
    List<RecordingPlan> plans = planRepository.findByStatus(1);
    
    for (RecordingPlan plan : plans) {
        // 检查最近 5 分钟是否有新文件
        long recentFileCount = fileRepository.countByStreamNameAndTimeSince(
            plan.getStreamName(),
            LocalDateTime.now().minusMinutes(5)
        );
        
        if (recentFileCount == 0) {
            log.warn("No recent files for plan {}, reapplying config", plan.getId());
            planService.applyPlan(plan.toRequest());
        }
    }
}

问题 5: 回放查询有空洞

现象: 物理文件存在,数据库查不到

原因: 索引任务只扫描最近窗口,漏扫历史补写文件

解决方案:

// 增加全量补扫任务
@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨 2 
public void fullScan() {
    List<StorageVolume> volumes = volumeRepository.findByStatus(1);
    
    for (StorageVolume volume : volumes) {
        scanVolumeFullRange(volume);
    }
}

20.3 算法层

问题 6: 任务显示运行中,但算法端无负载

排查步骤:

  1. 检查 ai_task_runtime.state 是否真的为 RUNNING
  2. 检查节点 WebSocket 会话是否在线
  3. 检查算法端日志是否收到 START_TASK 命令
  4. 检查 TASK_KEEPALIVE 心跳上报

解决方案:

// 增加守护任务
@Scheduled(fixedDelay = 10000)
public void watchdogTask() {
    List<TaskRuntime> runtimes = runtimeRepository.findByState("RUNNING");
    
    for (TaskRuntime runtime : runtimes) {
        long elapsed = Duration.between(
            runtime.getLastKeepalive(),
            LocalDateTime.now()
        ).getSeconds();
        
        if (elapsed > 30) {
            log.warn("Task {} keepalive timeout, marking ERROR", runtime.getTaskId());
            runtime.setState("ERROR");
            runtimeRepository.updateById(runtime);
            
            // 通知节点断连处理
            onNodeDisconnected(runtime.getNodeId());
        }
    }
}

问题 7: 结果队列堆积

现象: ai_result_queue_depth 持续增长,内存告警

原因: 处理速度跟不上生产速度

解决方案:

// 1. 启用非告警抽样
@Value("${ai.result.sample-interval-ms:1000}")
private long sampleIntervalMs;

// 2. 启用批量入库
@Value("${ai.result.batch.enabled:false}")
private boolean batchEnabled;

@Value("${ai.result.batch.size:200}")
private int batchSize;

// 3. 增加处理线程
int coreThreads = Runtime.getRuntime().availableProcessors();
this.processExecutor = new ThreadPoolExecutor(
    coreThreads * 2,  // 增加并发
    coreThreads * 4,
    60L, TimeUnit.SECONDS,
    new LinkedBlockingQueue<>(1000)
);

21. FAQ 与最佳实践

21.1 相机接入

Q: 是否必须支持 ONVIF?

A: 不是必须,但强烈建议。ONVIF 提供标准化的设备管理和流获取方式。不支持 ONVIF 的设备可以通过厂商兜底模式接入,但需要手动维护 RTSP 模板。

Q: 如何处理厂商差异?

A: 分层处理:

  1. 协议层做最大兼容 (Fault 解析、能力降级)
  2. 业务层提供厂商兜底开关
  3. 配置层维护厂商 RTSP 模板库

Q: 主码流和子码流如何选择?

A: 建议策略:

  • 录像: 主码流
  • 实时预览: 子码流
  • 算法分析: 根据精度要求选择

21.2 流媒体

Q: 为什么推荐 MediaMTX 而不是 ZLMediaKit?

A: 两者都是优秀的开源方案,选择 MediaMTX 的原因:

  1. 单一二进制,部署简单
  2. RESTful API 设计现代化
  3. WebRTC 支持原生
  4. 社区活跃,文档完善

Q: H.265 一定要转码吗?

A: 不是。建议策略:

  • 浏览器实时预览: 转 H.264
  • 录像存储: 保留 H.265 (节省空间)
  • 算法分析: 视算法输入要求而定

Q: 转码会增加多少延迟?

A: 软件转码: 1-3 秒 硬件转码 (NVENC): 500ms-1 秒

21.3 录像

Q: 切片时长怎么选?

A: 综合考虑:

  • 1-2 分钟: 文件数量大,IO 压力高
  • 5-10 分钟: 推荐,综合最优
  • 30-60 分钟: 文件少,但回放拖拽粗糙

Q: FMP4 vs TS 格式?

A: FMP4 优势:

  • 现代浏览器原生支持
  • 元数据结构清晰
  • 后期处理友好

TS 优势:

  • 兼容性更广 (老设备/老浏览器)
  • 容错性强 (部分损坏不影响播放)

Q: 如何避免磁盘满导致录像中断?

A: 多层防护:

  1. 水位线机制自动切卷
  2. 定时清理过期文件
  3. 磁盘使用率告警
  4. 降级策略 (关闭非关键路径录像)

21.4 算法

Q: 一个任务可以配置多少个场景?

A: 理论无上限,实际建议:

  • 单节点模式: 3-5 个
  • 拆分模式: 5-10 个

过多场景会导致:

  • 推理开销增加
  • 结果处理复杂
  • 告警频率过高

Q: 任务失败如何重试?

A: 分级重试:

  1. 瞬时失败 (网络抖动): 自动重启,计数器 +1
  2. 持续失败 (设备离线): 标记 ERROR,等待设备恢复
  3. 配置错误: 不重试,需要人工修复

Q: 如何控制告警推送频率?

A: 使用推送模式:

  • 高优先级: IMMEDIATE
  • 中优先级: COUNT=5, WINDOW=600s
  • 低优先级: WINDOW=3600s

并配合冷却期避免风暴。


22. 性能基准与压测

22.1 接入性能基准

测试环境:

  • CPU: 8C16G
  • 网络: 千兆局域网
  • 设备: 海康威视 DS-2CD2142FWD-I

测试结果:

并发数接入成功率P50 耗时P95 耗时P99 耗时
10100%850ms1200ms1500ms
5099.2%1100ms1800ms2400ms
10097.5%1450ms2500ms3200ms
20094.8%2100ms3800ms4800ms

瓶颈分析:

  • 网络 I/O 是主要瓶颈
  • ONVIF SOAP 解析占用 15-20% CPU
  • FFprobe 探测占用 30-40% CPU

优化建议:

  • 接入任务异步化
  • FFprobe 并发限制 (信号量)
  • 设备信息缓存

22.2 录像性能基准

测试环境:

  • CPU: 16C32G
  • 存储: NVMe SSD
  • 路数: 500 路并发录像

测试结果:

码率切片时长磁盘写入CPU 使用内存使用
2Mbps10m125MB/s18%4.2GB
4Mbps10m250MB/s22%5.8GB
6Mbps10m375MB/s28%7.5GB

索引性能:

  • 扫描速度: 10000 文件/秒
  • 索引延迟: P95 < 30 秒

22.3 算法性能基准

测试环境:

  • GPU: NVIDIA RTX 3080
  • 算法: YOLOv8m
  • 输入: 1280x720 @ 15fps

单节点容量:

场景数/任务并发任务数GPU 使用推理延迟
15068%45ms
23572%52ms
32578%61ms

结果处理:

  • 队列吞吐: 20000 条/秒
  • 入库延迟: P95 < 200ms
  • 告警推送: P95 < 500ms

23. 架构演进路线

23.1 当前架构 (v1.0)

特点:

  • 单体应用
  • MySQL 单实例
  • 本地文件存储
  • 单节点 MediaMTX

适用场景:

  • 设备数 < 1000
  • 并发流 < 200
  • 算法任务 < 100

23.2 中期演进 (v2.0)

目标: 支撑 5000 设备、1000 并发流

改进点:

  1. 服务拆分
   单体应用  接入服务 + 录像服务 + 算法服务
  1. 存储优化
   本地存储  对象存储 (MinIO/OSS)
   分层存储: 热数据 SSD + 冷数据 HDD
  1. MediaMTX 集群
   单节点  多节点 + LVS 负载均衡
   路径哈希分片
  1. 数据库读写分离
   主库: 写入
   从库: 查询 + 统计

23.3 长期演进 (v3.0)

目标: 支撑 10万+ 设备、万级并发流

改进点:

  1. 微服务化
   按业务域拆分:
   - 设备管理服务
   - 流媒体服务
   - 录像服务
   - 算法服务
   - 告警服务
   
   服务间通信: gRPC + MQ
  1. 分布式存储
   录像: Ceph 集群
   元数据: TiDB
   缓存: Redis 集群
  1. 边缘计算
   中心节点 + 边缘节点
   边缘: 实时分析 + 本地录像
   中心: 长期存储 + 全局调度
  1. 云原生改造
   容器化: Kubernetes
   服务网格: Istio
   可观测: Prometheus + Grafana + Jaeger

总结

本文从零开始,构建了一个完整的企业级视频智能分析平台,覆盖:

  1. 相机接入: ONVIF 协议深度实践、流路径治理、编码决策
  2. 录像存储: 存储卷管理、文件索引、回放检索
  3. 智能分析: 多场景任务模型、节点调度、结果处理、告警推送
  4. 运维体系: 配置管理、可观测性、问题排查、性能优化

核心设计原则:

  • 分层解耦: 控制面/媒体面、策略/执行、实时/批量
  • 统一抽象: 路径模型、场景模型、状态模型
  • 容错优先: 异步化、重试、降级、回滚
  • 可观测: 日志、指标、链路、告警

技术栈总结:

层次技术选型
协议ONVIF (SOAP/WS-Security)
流媒体MediaMTX
编解码FFmpeg/FFprobe
存储MySQL + 文件系统
通信WebSocket + RESTful
监控Prometheus + Grafana

贡献者

  • flycodeuflycodeu

公告板

2025-03-04正式迁移知识库到此项目